From 03134d35ae666565020f9fd88e439eef02a90377 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 16 Oct 2026 12:51:48 +0200 Subject: [PATCH] :bug: Fix silent failure when uploading custom fonts (#12315) * :bug: 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 * :recycle: 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 * :lipstick: 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 * :bug: 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 --- common/src/app/common/media.cljc | 40 ++- common/test/common_tests/media_test.cljc | 14 +- frontend/src/app/main/data/fonts.cljs | 281 ++++++++++-------- frontend/src/app/main/ui/dashboard/fonts.cljs | 1 + frontend/test/frontend_tests/fonts_test.cljs | 109 ++++++- 5 files changed, 310 insertions(+), 135 deletions(-) diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index ea0c11b530..a82759a7bd 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -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] diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index 00bbe932ef..f94525cae7 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -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" diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index 2ec056487d..dca3c54263 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -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] diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 703a162e25..5574f21572 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -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 diff --git a/frontend/test/frontend_tests/fonts_test.cljs b/frontend/test/frontend_tests/fonts_test.cljs index 11ba4d9573..dad9adf043 100644 --- a/frontend/test/frontend_tests/fonts_test.cljs +++ b/frontend/test/frontend_tests/fonts_test.cljs @@ -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))))