🐛 Fix silent failure when uploading custom fonts (#12315)

* 🐛 Fix silent failure when uploading custom fonts

Custom font upload crashed with re-seq must match
against a string when opentype.js returned nil
subfamily names, leaving the user with no feedback.
Unknown signatures and unparseable files were also
silently dropped.

Make variant parsing nil-safe, harden entry
preparation for missing tables, report every
per-file failure with a bad-font notification, and
show a generic error if the stream itself fails.

Closes #12304

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Rename font mtype helper and mark helpers private

Avoid confusion with app.worker.import/parse-mtype,
which detects binfile signatures. The font helper now
lives as parse-font-mtype, and the upload helpers only
used internally are private, with tests reaching them
through var references.

AI-assisted-by: muse-spark-1.3-contributor

* 💄 Fix formatting in font upload tests

Realign map keys shifted by the var-quote prefix so
cljfmt check passes again. No behavior change.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix infer warnings in fonts metrics code

Add missing ^js type hints to the hhea and os2 metric lookups in
prepare-font so shadow-cljs can infer the interop targets and the
release build reports zero warnings.

AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
Andrey Antukh 2026-10-16 12:51:48 +02:00 committed by GitHub
parent ccdfaffbef
commit 03134d35ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 310 additions and 135 deletions

View File

@ -99,26 +99,34 @@
(str/replace filename image-extensions-re "")))
(defn parse-font-weight
"Parses a font variant/subfamily string into a numeric weight.
A nil or non-string variant means the font carries no subfamily
metadata, so it defaults to 400 (regular), the same as a blank string."
[variant]
(cond
(re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100
(re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200
(re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300
(re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400
(re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500
(re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600
(re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800
(re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700
(re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950
(re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900
:else 400))
(let [variant (if (string? variant) variant "")]
(cond
(re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100
(re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200
(re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300
(re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400
(re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500
(re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600
(re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800
(re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700
(re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950
(re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900
:else 400)))
(defn parse-font-style
"Parses a font variant/subfamily string into \"italic\" or \"normal\".
A nil or non-string variant means the font carries no subfamily
metadata, so it defaults to \"normal\"."
[variant]
(if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant)
(re-seq #"(?i)italic$" variant))
"italic"
"normal"))
(let [variant (if (string? variant) variant "")]
(if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant)
(re-seq #"(?i)italic$" variant))
"italic"
"normal")))
(defn font-weight->name
[weight]

View File

@ -35,7 +35,12 @@
(t/is (= 400 (media/parse-font-weight "Lighthaus")))
(t/is (= 400 (media/parse-font-weight "Blackwood")))
(t/is (= 400 (media/parse-font-weight "Thinker")))
(t/is (= 400 (media/parse-font-weight "Mediaeval")))))
(t/is (= 400 (media/parse-font-weight "Mediaeval"))))
(t/testing "defaults to regular when the variant metadata is missing"
(t/is (= 400 (media/parse-font-weight nil)))
(t/is (= 400 (media/parse-font-weight "")))
(t/is (= 400 (media/parse-font-weight " ")))))
(t/deftest test-parse-font-style
(t/testing "matches italic with proper boundaries"
@ -47,7 +52,12 @@
(t/testing "does not match italic embedded in words"
(t/is (= "normal" (media/parse-font-style "Italica")))
(t/is (= "normal" (media/parse-font-style "Roboto-Regular")))))
(t/is (= "normal" (media/parse-font-style "Roboto-Regular"))))
(t/testing "defaults to normal when the variant metadata is missing"
(t/is (= "normal" (media/parse-font-style nil)))
(t/is (= "normal" (media/parse-font-style "")))
(t/is (= "normal" (media/parse-font-style " ")))))
(t/deftest test-strip-image-extension
(t/testing "removes extension from supported image files"

View File

@ -115,6 +115,128 @@
:font-style font-style
:uploads uploads})))))
(defn- parse-font-mtype
"Detects a font mime-type from the first four bytes of an ArrayBuffer.
Returns the `font/*` mime-type string, or nil when the buffer is
missing, too short, or carries an unknown signature. Unknown
signatures are treated as errors by `read-blob`, never silently
dropped."
[ba]
(when (and (some? ba)
(>= (.-byteLength ^js ba) 4))
(let [u8 (js/Uint8Array. ba 0 4)
sg (areduce u8 i ret "" (str ret (if (zero? i) "" " ") (.toString (aget u8 i) 8)))]
(case sg
"117 124 124 117" "font/otf"
"0 1 0 0" "font/ttf"
"167 117 106 106" "font/woff"
"167 117 106 62" "font/woff2"
nil))))
(defn- parse-font
"Associates the opentype.js font object for TTF/OTF/WOFF blobs.
Takes a `read-blob` result map. Passes `{:error ...}` maps through
untouched. WOFF2 blobs cannot be parsed by opentype.js, so they keep
`:font nil` and metadata is derived from the filename later. Any other
blob is parsed with opentype.js; when parsing fails an
`{:error filename}` map is returned so the caller can notify the user
instead of silently dropping the file."
[{:keys [error data type name] :as params}]
(cond
(some? error)
params
(= type "font/woff2")
;; woff2 cannot be parsed by opentype.js, extract metadata from filename
(assoc params :font nil)
:else
(try
(assoc params :font (ot/parse data))
(catch :default _e
(log/warn :msg (str/fmt "skipping file %s, unsupported format" name))
{:error name}))))
(defn- prepare-font
"Builds a ready-to-upload font entry from a parsed blob.
Never throws for missing font metadata: a nil subfamily (opentype.js
returns nil when the font carries no English preferred/subfamily
names, as seen with some Fontshare families) defaults to regular
weight/style, and missing vertical-metric tables default to no
height warning."
[{:keys [font type name data] :as _params}]
(let [name (or name "")]
(if (some? font)
;; Font was parsed with opentype.js (ttf, otf, woff)
(let [family (or (.getEnglishName ^js font "preferredFamily")
(.getEnglishName ^js font "fontFamily")
"")
variant (or (.getEnglishName ^js font "preferredSubfamily")
(.getEnglishName ^js font "fontSubfamily")
"")
;; Vertical metrics determine the baseline in a text and the space between lines of
;; text. For historical reasons, there are three pairs of ascender/descender
;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating
;; system and application a different set will be used to render text on the
;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox
;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If
;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea
;; table. On Windows, all browsers use the usWin metrics, but respect the
;; useTypoMetrics setting and if set will use the OS/2 values.
hhea (some-> ^js font .-tables .-hhea)
os2 (some-> ^js font .-tables .-os2)
hhea-ascender (some-> ^js hhea .-ascender abs)
hhea-descender (some-> ^js hhea .-descender abs)
win-ascent (some-> ^js os2 .-usWinAscent abs)
win-descent (some-> ^js os2 .-usWinDescent abs)
os2-ascent (some-> ^js os2 .-sTypoAscender abs)
os2-descent (some-> ^js os2 .-sTypoDescender abs)
;; useTypoMetrics can be read from the 7th bit
f-selection (some-> ^js os2 .-fsSelection (bit-test 7))
height-warning? (boolean
(and hhea-ascender hhea-descender win-ascent win-descent
(or (not= hhea-ascender win-ascent)
(not= hhea-descender win-descent)
(and f-selection os2-ascent os2-descent
(or (not= hhea-ascender os2-ascent)
(not= hhea-descender os2-descent))))))
data (js/Uint8Array. data)]
{:content {:data data
:name name
:type type}
:font-family (or family "")
:font-weight (cm/parse-font-weight variant)
:font-style (cm/parse-font-style variant)
:variant-name variant
:height-warning? height-warning?})
;; Font could not be parsed (woff2), extract metadata from filename
(let [base-name (str/replace name #"\.[^.]+$" "")
;; Strip known weight/style tokens and separators to derive family name
;; Use word boundaries to avoid matching substrings (e.g. "Boldini" should not match "bold")
raw-family-name (-> base-name
(str/replace #"(?i)(^|[-_\s])(extra\s*black|ultra\s*black|extra\s*bold|ultra\s*bold|semi\s*bold|demi\s*bold|extra\s*light|ultra\s*light|hairline|thin|light|normal|regular|medium|bold|black|heavy|solid|italic)([-_\s]|$)" "$1$3")
(str/replace #"[-_\s]+" " ")
(str/trim))
family-name (if (str/blank? raw-family-name) base-name raw-family-name)
data (js/Uint8Array. data)]
{:content {:data data
:name name
:type type}
:font-family family-name
:font-weight (cm/parse-font-weight base-name)
:font-style (cm/parse-font-style base-name)
:height-warning? false}))))
(defn process-upload
"Given a seq of blobs and the team id, creates a ready-to-use fonts
map with temporal ID's associated to each font entry.
@ -122,71 +244,16 @@
Each font entry's `:data` is a map of `{mtype -> ArrayBuffer}`. The
raw `ArrayBuffer` is kept as-is so that `upload-font-variant` can
wrap it in a `Blob` and hand it directly to `upload-blob-chunked`
without any intermediate client-side chunking."
without any intermediate client-side chunking.
Files that cannot be read, carry an unknown signature, fail
opentype.js parsing, or fail entry preparation are collected and
reported with a `bad-font` notification instead of silently dropped,
so the user always gets feedback. The returned observable never
errors for per-file failures; it emits the map of successfully
processed fonts (possibly empty)."
[blobs team-id]
(letfn [(prepare [{:keys [font type name data] :as params}]
(if font
;; Font was parsed with opentype.js (ttf, otf, woff)
(let [family (or (.getEnglishName ^js font "preferredFamily")
(.getEnglishName ^js font "fontFamily"))
variant (or (.getEnglishName ^js font "preferredSubfamily")
(.getEnglishName ^js font "fontSubfamily"))
;; Vertical metrics determine the baseline in a text and the space between lines of
;; text. For historical reasons, there are three pairs of ascender/descender
;; values, known as hhea, OS/2 and uSWin metrics. Depending on the font, operating
;; system and application a different set will be used to render text on the
;; screen. On Mac, Safari and Chrome use the hhea values to render text. Firefox
;; will respect the useTypoMetrics setting and will use the OS/2 if it is set. If
;; the useTypoMetrics is not set, Firefox will also use metrics from the hhea
;; table. On Windows, all browsers use the usWin metrics, but respect the
;; useTypoMetrics setting and if set will use the OS/2 values.
hhea-ascender (abs (-> ^js font .-tables .-hhea .-ascender))
hhea-descender (abs (-> ^js font .-tables .-hhea .-descender))
win-ascent (abs (-> ^js font .-tables .-os2 .-usWinAscent))
win-descent (abs (-> ^js font .-tables .-os2 .-usWinDescent))
os2-ascent (abs (-> ^js font .-tables .-os2 .-sTypoAscender))
os2-descent (abs (-> ^js font .-tables .-os2 .-sTypoDescender))
;; useTypoMetrics can be read from the 7th bit
f-selection (-> ^js font .-tables .-os2 .-fsSelection (bit-test 7))
height-warning? (or (not= hhea-ascender win-ascent)
(not= hhea-descender win-descent)
(and f-selection (or
(not= hhea-ascender os2-ascent)
(not= hhea-descender os2-descent))))
data (js/Uint8Array. data)]
{:content {:data data
:name name
:type type}
:font-family (or family "")
:font-weight (cm/parse-font-weight variant)
:font-style (cm/parse-font-style variant)
:variant-name variant
:height-warning? height-warning?})
;; Font could not be parsed (woff2), extract metadata from filename
(let [base-name (str/replace name #"\.[^.]+$" "")
;; Strip known weight/style tokens and separators to derive family name
;; Use word boundaries to avoid matching substrings (e.g. "Boldini" should not match "bold")
raw-family-name (-> base-name
(str/replace #"(?i)(^|[-_\s])(extra\s*black|ultra\s*black|extra\s*bold|ultra\s*bold|semi\s*bold|demi\s*bold|extra\s*light|ultra\s*light|hairline|thin|light|normal|regular|medium|bold|black|heavy|solid|italic)([-_\s]|$)" "$1$3")
(str/replace #"[-_\s]+" " ")
(str/trim))
family-name (if (str/blank? raw-family-name) base-name raw-family-name)
data (js/Uint8Array. data)]
{:content {:data data
:name name
:type type}
:font-family family-name
:font-weight (cm/parse-font-weight base-name)
:font-style (cm/parse-font-style base-name)
:height-warning? false})))
(join [res {:keys [content] :as font}]
(letfn [(join [res {:keys [content] :as font}]
(let [key-fn (juxt :font-family :font-weight :font-style)
existing (d/seek #(= (key-fn font) (key-fn %)) (vals res))]
(if existing
@ -206,60 +273,42 @@
(:data content)})
(dissoc :content)))))))
(parse-mtype [ba]
(let [u8 (js/Uint8Array. ba 0 4)
sg (areduce u8 i ret "" (str ret (if (zero? i) "" " ") (.toString (aget u8 i) 8)))]
(case sg
"117 124 124 117" "font/otf"
"0 1 0 0" "font/ttf"
"167 117 106 106" "font/woff"
"167 117 106 62" "font/woff2")))
(parse-font [{:keys [data type name] :as params}]
(if (= type "font/woff2")
;; woff2 cannot be parsed by opentype.js, extract metadata from filename
(assoc params :font nil)
(try
(assoc params :font (ot/parse data))
(catch :default _e
(log/warn :msg (str/fmt "skipping file %s, unsupported format" name))
nil))))
(read-blob [blob]
(->> (wa/read-file-as-array-buffer blob)
(rx/map (fn [data]
{:data data
:name (.-name blob)
:type (parse-mtype data)}))
(rx/catch (fn []
(rx/of {:error (.-name blob)})))
(rx/mapcat (fn [{:keys [type, error] :as font}]
(if (or type error)
(rx/of font)
(rx/empty))))))]
(let [blob-name (.-name blob)]
(->> (wa/read-file-as-array-buffer blob)
(rx/map (fn [data]
{:data data
:name blob-name
:type (parse-font-mtype data)}))
(rx/catch (fn []
(rx/of {:error blob-name})))
(rx/mapcat (fn [{:keys [type error] :as font}]
(if (or type error)
(rx/of font)
;; Unknown signature: report it instead of silently dropping it.
(rx/of {:error blob-name})))))))]
(let [fonts (->> (rx/from blobs)
(rx/mapcat read-blob))
errors (->> fonts
(rx/filter #(some? (:error %)))
(rx/reduce (fn [acc font]
(conj acc (str "'" (:error font) "'")))
[]))]
(rx/subscribe errors
#(when
(not-empty %)
(st/emit!
(ntf/error
(if (> (count %) 1)
(tr "errors.bad-font-plural" (str/join ", " %))
(tr "errors.bad-font" (first %)))))))
(->> fonts
(rx/filter #(nil? (:error %)))
(rx/map parse-font)
(rx/filter some?)
(rx/map prepare)
(rx/reduce join {})))))
(->> (rx/from blobs)
(rx/mapcat read-blob)
(rx/map parse-font)
(rx/filter some?)
(rx/reduce (fn [acc item]
(if (:error item)
(update acc :errors conj (str "'" (:error item) "'"))
(try
(update acc :fonts #(join % (prepare-font item)))
(catch :default cause
(log/warn :msg "skipping font file" :cause cause :name (:name item))
(update acc :errors conj (str "'" (:name item) "'"))))))
{:fonts {} :errors []})
(rx/mapcat (fn [{:keys [fonts errors]}]
(when (seq errors)
(st/emit!
(ntf/error
(if (> (count errors) 1)
(tr "errors.bad-font-plural" (str/join ", " errors))
(tr "errors.bad-font" (first errors))))))
(rx/of fonts))))))
(defn- calculate-family-to-id-mapping
[existing]

View File

@ -114,6 +114,7 @@
(rx/subs! (fn [result]
(swap! fonts* df/merge-and-group-fonts installed-fonts result))
(fn [error]
(st/emit! (ntf/error (tr "errors.generic")))
(js/console.error "error" error))))))
on-upload

View File

@ -6,13 +6,18 @@
(ns frontend-tests.fonts-test
(:require
[app.common.uuid :as uuid]
[app.main.data.fonts :as df]
[app.main.fonts :as fonts]
[app.main.store :as st]
[app.util.globals :as globals]
[app.util.http :as http]
[app.util.webapi :as wa]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as async]
[frontend-tests.helpers.mock :as mock]))
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(def sample-font
{:id "sourcesanspro"
@ -244,3 +249,105 @@
;; Trailing settle: the body is synchronous, but `with-mocks*`
;; evaluates to a promise, so the body must settle one.
(await (async/settle))))))
;; --- custom font upload ------------------------------------------------------
;;
;; Regression tests for https://github.com/penpot/penpot/issues/12304:
;; uploading a font whose opentype.js names are missing (nil subfamily,
;; as seen with some Fontshare families) crashed `process-upload` with
;; `re-seq must match against a string` and left the user with no
;; feedback. Unknown signatures and unparseable files were silently
;; dropped as well.
(defn- bytes->buffer
[bytes]
(.-buffer (js/Uint8Array. (clj->js bytes))))
(defn- fake-ot-font
[{:keys [family variant hhea os2]}]
(let [names {"preferredFamily" family
"fontFamily" family
"preferredSubfamily" variant
"fontSubfamily" variant}]
#js {:getEnglishName (fn [k] (get names k))
:tables #js {:hhea hhea :os2 os2}}))
(t/deftest parse-font-mtype-detects-font-signatures
(t/is (= "font/ttf" (#'df/parse-font-mtype (bytes->buffer [0 1 0 0]))))
(t/is (= "font/otf" (#'df/parse-font-mtype (bytes->buffer [79 84 84 79]))))
(t/is (= "font/woff" (#'df/parse-font-mtype (bytes->buffer [119 79 70 70]))))
(t/is (= "font/woff2" (#'df/parse-font-mtype (bytes->buffer [119 79 70 50]))))
(t/is (nil? (#'df/parse-font-mtype (bytes->buffer [1 2 3 4]))))
(t/is (nil? (#'df/parse-font-mtype nil)))
(t/is (nil? (#'df/parse-font-mtype (bytes->buffer [0 1])))))
(t/deftest prepare-font-defaults-missing-subfamily
(let [font (fake-ot-font {:family "Satoshi"
:variant nil
:hhea #js {:ascender 1000 :descender 300}
:os2 #js {:usWinAscent 1000 :usWinDescent 300
:sTypoAscender 800 :sTypoDescender 200
:fsSelection 0}})
entry (#'df/prepare-font {:font font
:type "font/otf"
:name "Satoshi-Regular.otf"
:data (bytes->buffer [79 84 84 79 0 0 0 0])})]
(t/is (= "Satoshi" (:font-family entry)))
(t/is (= 400 (:font-weight entry)))
(t/is (= "normal" (:font-style entry)))
(t/is (false? (:height-warning? entry)))))
(t/deftest prepare-font-tolerates-missing-tables
(let [font (fake-ot-font {:family "Satoshi" :variant "Regular" :hhea nil :os2 nil})
entry (#'df/prepare-font {:font font
:type "font/ttf"
:name "Satoshi-Regular.ttf"
:data (bytes->buffer [0 1 0 0 0 0 0 0])})]
(t/is (= "Satoshi" (:font-family entry)))
(t/is (false? (:height-warning? entry)))))
(t/deftest parse-font-returns-error-on-unparseable
(t/testing "read errors pass through"
(t/is (= {:error "x.ttf"} (#'df/parse-font {:error "x.ttf"}))))
(t/testing "woff2 keeps a nil font for filename-based metadata"
(let [result (#'df/parse-font {:data (bytes->buffer [119 79 70 50])
:type "font/woff2"
:name "Satoshi-Bold.woff2"})]
(t/is (nil? (:font result)))
(t/is (nil? (:error result)))))
(t/testing "opentype.js failures become errors instead of silent drops"
(let [garbage (bytes->buffer (into [0 1 0 0] (repeat 96 0)))
result (#'df/parse-font {:data garbage :type "font/ttf" :name "bad.ttf"})]
(t/is (= "bad.ttf" (:error result))))))
(t/deftest ^:async process-upload-reports-unknown-signature
(let [team-id (uuid/next)
blob #js {:name "mystery.bin"}
emitted (atom [])
result (atom nil)]
(await (mock/with-mocks*
{wa/read-file-as-array-buffer (mock/stub (fn [_] (rx/of (bytes->buffer [1 2 3 4]))))
st/emit! (mock/stub (fn [& evts] (swap! emitted into evts) nil))}
(await (async/observe (df/process-upload [blob] team-id)
:on-next #(reset! result %)))))
(t/is (= {} @result))
(t/is (= 1 (count @emitted)))
(t/is (= :app.main.data.notifications/show (ptk/type (first @emitted))))))
(t/deftest ^:async process-upload-woff2-succeeds-from-filename
(let [team-id (uuid/next)
blob #js {:name "Satoshi-Bold.woff2"}
emitted (atom [])
result (atom nil)]
(await (mock/with-mocks*
{wa/read-file-as-array-buffer (mock/stub (fn [_] (rx/of (bytes->buffer [119 79 70 50 0 0 0 0]))))
st/emit! (mock/stub (fn [& evts] (swap! emitted into evts) nil))}
(await (async/observe (df/process-upload [blob] team-id)
:on-next #(reset! result %)))
(await (async/settle))))
(t/is (= 1 (count (vals @result))))
(let [entry (first (vals @result))]
(t/is (= "Satoshi" (:font-family entry)))
(t/is (= 700 (:font-weight entry)))
(t/is (= "normal" (:font-style entry))))
(t/is (empty? @emitted))))