diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 46aa009e20..0b5311867a 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -112,6 +112,7 @@ [:font-family types.font/schema:font-family] [:font-weight [::sm/one-of {:format "number"} valid-weight]] [:font-style [::sm/one-of {:format "string"} valid-style]] + [:variant-name {:optional true} [:maybe [::sm/text {:max 250}]]] [:uploads [:map-of ::sm/text ::sm/uuid]]]) (defn- prepare-font-data-from-uploads @@ -139,7 +140,8 @@ chunked-upload API)." {::doc/added "1.18" ::doc/changes [["2.16" "Add :uploads param for chunked upload support"] - ["2.18" "Remove :data param, use :uploads exclusively"]] + ["2.18" "Remove :data param, use :uploads exclusively"] + ["2.20" "Add optional :variant-name param"]] ::climit/id [[:process-font/by-profile ::rpc/profile-id] [:process-font/global]] ::webhooks/event? true diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 4ef4533f7a..4526676ae7 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -403,6 +403,51 @@ (t/is (nil? (:error out))) (assert-font-variant-result params (:result out))))) +(t/deftest create-font-variant-persists-variant-name + "The optional :variant-name is stored and returned by get-font-variants." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 34) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024)) + out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "variant-name-font" + :font-weight 600 + :font-style "normal" + :variant-name "SemiBold" + :uploads {"font/ttf" session-id}})] + (t/is (th/success? out)) + (t/is (= "SemiBold" (-> out :result :variant-name))) + + (let [variants (-> (th/command! {::th/type :get-font-variants + ::rpc/profile-id (:id prof) + :team-id team-id}) + :result) + variant (first (filter #(= font-id (:font-id %)) variants))] + (t/is (= "SemiBold" (:variant-name variant))))) + + ;; An explicit nil must be accepted (the param is optional). + (let [prof (th/create-profile* 2 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 35) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024)) + out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "variant-name-nil" + :font-weight 400 + :font-style "normal" + :variant-name nil + :uploads {"font/ttf" session-id}})] + (t/is (th/success? out)) + (t/is (nil? (-> out :result :variant-name)))))) + ;; ----------------------------------------------------------------------- ;; Error cases ;; ----------------------------------------------------------------------- diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index a82759a7bd..f881684cbe 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -98,36 +98,90 @@ (let [image-extensions-re #"(\.png)|(\.jpg)|(\.jpeg)|(\.webp)|(\.gif)|(\.svg)$"] (str/replace filename image-extensions-re ""))) +(def ^:private font-variant-tokens + "Weight and style tokens stripped from a font name to derive its family. + Keep the alternatives in sync with `parse-font-weight` and + `parse-font-style`." + ["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" "oblique"]) + +(def ^:private font-variant-tokens-re + ;; The trailing boundary is a lookahead, so two tokens separated by a single + ;; space are both stripped in one pass (the separator is not consumed). + (re-pattern (str "(?i)(^|[-_\\s])(" (str/join "|" font-variant-tokens) ")(?=[-_\\s]|$)"))) + +(def ^:private font-concatenated-style-re + ;; Separate a style token glued to a weight token, e.g. "BoldItalic" -> + ;; "Bold Italic". Only a known weight token followed by a style token is + ;; split, so families such as "OpenSans" are left untouched. The trailing + ;; boundary is a lookahead, so a glued style token followed by a technical + ;; suffix (e.g. "Italic_v2") is split too. + #"(?i)(black|bold|heavy|solid|medium|light|thin|hairline|regular|normal)(italic|oblique)(?=[-_\s]|$)") + +(def ^:private font-technical-suffix-re + ;; Trailing foundry/build suffixes, e.g. "Roboto-Bold-VF" or "Inter_v2". + ;; Requires a separator; stripped before token detection so the parsers + ;; interpret the name the same way. + #"(?i)[-_\s]+(?:vf|v[0-9]+)$") + +(defn- normalize-font-name + "Normalize a font name or variant string before token detection: + split a style token glued to a weight token, then strip a trailing + technical suffix. Applied by `parse-font-family`, `parse-font-weight` + and `parse-font-style` so all three interpret the name the same way." + [s] + (-> s + (str/replace font-concatenated-style-re "$1 $2") + (str/replace font-technical-suffix-re ""))) + +(defn parse-font-family + "Derive a font family name from a base name (a filename without its + extension), stripping the known weight/style tokens." + [base-name] + (let [normalized (normalize-font-name base-name) + stripped (-> normalized + (str/replace font-variant-tokens-re "$1") + (str/replace #"[-_\s]+" " ") + (str/trim))] + (if (str/blank? stripped) base-name stripped))) + (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] - (let [variant (if (string? variant) variant "")] + (let [variant (normalize-font-name (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))) + (re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b|oblique\b))" variant) 100 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b|oblique\b))" variant) 200 + (re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b|oblique\b))" variant) 300 + (re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b|oblique\b))" variant) 400 + (re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b|oblique\b))" variant) 500 + (re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b|oblique\b))" variant) 600 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b|oblique\b))" variant) 800 + (re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b|oblique\b))" variant) 700 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b|oblique\b))" variant) 950 + (re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b|oblique\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] - (let [variant (if (string? variant) variant "")] - (if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant) - (re-seq #"(?i)italic$" variant)) + (let [variant (normalize-font-name (if (string? variant) variant ""))] + (if (or (re-seq #"(?i)(?:^|[-_\s])(italic|oblique)(?:[-_\s]|$)" variant) + (re-seq #"(?i)(?:italic|oblique)$" variant)) "italic" "normal"))) +(defn first-nonblank-string + "Return the first nonblank string from the candidates, or nil." + [& candidates] + (first (filter #(and (string? %) (not (str/blank? %))) candidates))) + (defn font-weight->name [weight] (case (long weight) diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index f94525cae7..18ad0fc9fd 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -37,6 +37,11 @@ (t/is (= 400 (media/parse-font-weight "Thinker"))) (t/is (= 400 (media/parse-font-weight "Mediaeval")))) + (t/testing "matches a weight glued to a style token" + (t/is (= 700 (media/parse-font-weight "Roboto-BoldItalic"))) + (t/is (= 700 (media/parse-font-weight "Roboto-BoldOblique"))) + (t/is (= 600 (media/parse-font-weight "Roboto-SemiBoldItalic")))) + (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 ""))) @@ -54,11 +59,79 @@ (t/is (= "normal" (media/parse-font-style "Italica"))) (t/is (= "normal" (media/parse-font-style "Roboto-Regular")))) + (t/testing "treats oblique as italic" + (t/is (= "italic" (media/parse-font-style "Roboto-Oblique"))) + (t/is (= "italic" (media/parse-font-style "Roboto-BoldOblique"))) + (t/is (= "italic" (media/parse-font-style "RobotoOblique")))) + (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-parse-font-family + (t/testing "strips weight and style tokens" + (t/is (= "Roboto" (media/parse-font-family "Roboto-Bold"))) + (t/is (= "Roboto" (media/parse-font-family "Roboto_Bold"))) + (t/is (= "Roboto" (media/parse-font-family "Roboto Bold"))) + (t/is (= "Roboto" (media/parse-font-family "Roboto-Oblique"))) + (t/is (= "OpenSans" (media/parse-font-family "OpenSans-SemiBold"))) + (t/is (= "Lato" (media/parse-font-family "Lato-Light")))) + + (t/testing "separates a style token glued to a weight token" + (t/is (= "Roboto" (media/parse-font-family "Roboto-BoldItalic"))) + (t/is (= "Roboto" (media/parse-font-family "Roboto-SemiBoldItalic"))) + (t/is (= "Roboto" (media/parse-font-family "Roboto-LightOblique"))) + (t/is (= "Roboto" (media/parse-font-family "Roboto-BlackOblique")))) + + (t/testing "does not strip tokens embedded in words" + (t/is (= "Boldini" (media/parse-font-family "Boldini-Bold"))) + (t/is (= "Lighthaus" (media/parse-font-family "Lighthaus"))) + (t/is (= "Blackwood" (media/parse-font-family "Blackwood"))) + (t/is (= "Mediaeval" (media/parse-font-family "Mediaeval")))) + + (t/testing "keeps unknown tokens in the family" + (t/is (= "Satoshi Variable" (media/parse-font-family "Satoshi-Variable")))) + + (t/testing "falls back to the base name when everything is stripped" + (t/is (= "BoldItalic" (media/parse-font-family "BoldItalic"))))) + +(t/deftest test-font-glued-tokens + (t/testing "a glued style token is recognized consistently by all three parsers" + ;; From EvaMarco's review on #11951: without a shared interpretation of + ;; the name, the bold-italic face collided with the bold-only one and + ;; was silently overwritten on upload. + (t/is (= "opensans webfont" (media/parse-font-family "opensans-bolditalic-webfont"))) + (t/is (= 700 (media/parse-font-weight "opensans-bolditalic-webfont"))) + (t/is (= "italic" (media/parse-font-style "opensans-bolditalic-webfont")))) + + (t/testing "a glued style token followed by a technical suffix" + (t/is (= "Roboto" (media/parse-font-family "Roboto-BoldItalic-VF"))) + (t/is (= 700 (media/parse-font-weight "Roboto-BoldItalic-VF"))) + (t/is (= "italic" (media/parse-font-style "Roboto-BoldItalic-VF")))) + + (t/testing "numeric build suffix after a glued style token" + (t/is (= "Roboto" (media/parse-font-family "Roboto-LightItalic_v2"))) + (t/is (= 300 (media/parse-font-weight "Roboto-LightItalic_v2"))) + (t/is (= "italic" (media/parse-font-style "Roboto-LightItalic_v2")))) + + (t/testing "the bold-only control case is unchanged" + (t/is (= "opensans webfont" (media/parse-font-family "opensans-bold-webfont"))) + (t/is (= 700 (media/parse-font-weight "opensans-bold-webfont"))) + (t/is (= "normal" (media/parse-font-style "opensans-bold-webfont"))))) + +(t/deftest test-first-nonblank-string + (t/testing "returns the first nonblank string" + (t/is (= "Bold" (media/first-nonblank-string " " "Bold"))) + (t/is (= "Bold" (media/first-nonblank-string "Bold" "Light"))) + (t/is (= "Bold" (media/first-nonblank-string nil "Bold"))) + (t/is (= "Inter" (media/first-nonblank-string "" "Inter")))) + + (t/testing "returns nil when every candidate is blank or not a string" + (t/is (nil? (media/first-nonblank-string nil " "))) + (t/is (nil? (media/first-nonblank-string))) + (t/is (nil? (media/first-nonblank-string 42 " "))))) + (t/deftest test-strip-image-extension (t/testing "removes extension from supported image files" (t/is (= (media/strip-image-extension "foo.png") "foo")) diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index dca3c54263..ec67e752a6 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -94,7 +94,7 @@ can assemble the chunks and materialise the final font-variant record. Returns an observable that emits the created font-variant." - [{:keys [data team-id font-id font-family font-weight font-style] :as _item}] + [{:keys [data team-id font-id font-family font-weight font-style variant-name] :as _item}] ;; Upload each mtype as a separate chunked session in parallel, collect ;; all [mtype session-id] pairs, then call create-font-variant with :uploads. (->> (rx/from (seq data)) @@ -108,12 +108,100 @@ {}) (rx/mapcat (fn [uploads] (rp/cmd! :create-font-variant - {:team-id team-id - :font-id font-id - :font-family font-family - :font-weight font-weight - :font-style font-style - :uploads uploads}))))) + {:team-id team-id + :font-id font-id + :font-family font-family + :font-weight font-weight + :font-style font-style + :variant-name variant-name + :uploads uploads}))))) + +(defn resolve-font-metadata + "Resolve the upload metadata for a font file. + + `names` is a map carrying the `:family` and `:variant` values read by + opentype.js; each may be nil when the font has no usable name records. + `filename` is the original file name. The opentype.js values win per + field; any missing value is derived from the filename instead." + [names filename] + (let [{:keys [family variant]} names + base-name (str/replace filename #"\.[^.]+$" "") + variant (not-empty (str/trim (or variant ""))) + family (not-empty (str/trim (or family ""))) + variant-source (or variant base-name)] + {:font-family (or family (cm/parse-font-family base-name)) + :font-weight (cm/parse-font-weight variant-source) + :font-style (cm/parse-font-style variant-source) + :variant-name variant})) + +(defn prepare-font-entry + "Prepare a single read font entry for upload. + + `:font` is the object parsed by opentype.js (nil for woff2, which cannot + be parsed); `:name`, `:type` and `:data` come from reading the file. + Returns the entry metadata, the `:content` to upload and, for parsed + fonts, the `:height-warning?` flag. A font with missing vertical-metric + tables uploads with `:height-warning? false` instead of failing." + [{:keys [font type name data] :as _params}] + (if font + ;; Font was parsed with opentype.js (ttf, otf, woff) + (let [;; Select the first nonblank name candidate: a blank + ;; preferredFamily/preferredSubfamily must not shadow a valid + ;; fontFamily/fontSubfamily. + family (cm/first-nonblank-string + (.getEnglishName ^js font "preferredFamily") + (.getEnglishName ^js font "fontFamily")) + variant (cm/first-nonblank-string + (.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) + metadata (resolve-font-metadata {:family family :variant variant} name)] + (assoc metadata + :content {:data data + :name name + :type type} + :height-warning? height-warning?)) + ;; Font could not be parsed (woff2), extract metadata from filename + (let [data (js/Uint8Array. data) + metadata (resolve-font-metadata {} name)] + (assoc metadata + :content {:data data + :name name + :type type} + :height-warning? false)))) (defn- parse-font-mtype "Detects a font mime-type from the first four bytes of an ArrayBuffer. @@ -159,84 +247,6 @@ (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. @@ -296,7 +306,7 @@ (if (:error item) (update acc :errors conj (str "'" (:error item) "'")) (try - (update acc :fonts #(join % (prepare-font item))) + (update acc :fonts #(join % (prepare-font-entry item))) (catch :default cause (log/warn :msg "skipping font file" :cause cause :name (:name item)) (update acc :errors conj (str "'" (:name item) "'")))))) diff --git a/frontend/test/frontend_tests/data/fonts_test.cljs b/frontend/test/frontend_tests/data/fonts_test.cljs new file mode 100644 index 0000000000..0bbd806bff --- /dev/null +++ b/frontend/test/frontend_tests/data/fonts_test.cljs @@ -0,0 +1,158 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.data.fonts-test + "Tests for the upload metadata resolution in app.main.data.fonts." + (:require + [app.common.uuid :as uuid] + [app.main.data.fonts :as df] + [app.main.data.uploads :as uploads] + [app.main.repo :as rp] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock])) + +(t/deftest resolve-font-metadata-prefers-opentype-values + (t/testing "opentype.js values win over the filename" + (t/is (= {:font-family "Inter" + :font-weight 700 + :font-style "normal" + :variant-name "Bold"} + (df/resolve-font-metadata {:family "Inter" :variant "Bold"} + "Inter.ttf"))))) + +(t/deftest resolve-font-metadata-falls-back-to-filename + (t/testing "derives weight and style from the filename when the variant is missing" + (t/is (= {:font-family "Pretendard" + :font-weight 600 + :font-style "normal" + :variant-name nil} + (df/resolve-font-metadata {:family "Pretendard" :variant nil} + "Pretendard-SemiBold.otf")))) + + (t/testing "derives weight and style from the filename when the variant is blank" + (t/is (= {:font-family "Lato" + :font-weight 300 + :font-style "normal" + :variant-name nil} + (df/resolve-font-metadata {:family "Lato" :variant ""} + "Lato-Light.ttf")))) + + (t/testing "derives the family from the filename when it is missing" + (t/is (= {:font-family "Blender Inter" + :font-weight 700 + :font-style "normal" + :variant-name nil} + (df/resolve-font-metadata {:family nil :variant nil} + "Blender-Inter-Bold.ttf")))) + + (t/testing "derives family, weight and style from the filename when nothing is available" + (t/is (= {:font-family "Pretendard" + :font-weight 600 + :font-style "normal" + :variant-name nil} + (df/resolve-font-metadata {} "Pretendard-SemiBold.woff2")))) + + (t/testing "derives italic from the filename" + (t/is (= {:font-family "Roboto" + :font-weight 400 + :font-style "italic" + :variant-name nil} + (df/resolve-font-metadata {} "Roboto-Italic.ttf")))) + + (t/testing "separates a style token glued to a weight token" + (t/is (= {:font-family "Roboto" + :font-weight 700 + :font-style "italic" + :variant-name nil} + (df/resolve-font-metadata {:family "Roboto" :variant nil} + "Roboto-BoldItalic.ttf")))) + + (t/testing "treats an oblique variant as italic" + (t/is (= {:font-family "Roboto" + :font-weight 700 + :font-style "italic" + :variant-name "BoldOblique"} + (df/resolve-font-metadata {:family "Roboto" :variant "BoldOblique"} + "Roboto.ttf")))) + + (t/testing "handles filenames without extension" + (t/is (= {:font-family "Lato" + :font-weight 300 + :font-style "normal" + :variant-name nil} + (df/resolve-font-metadata {} "Lato-Light"))))) + +(t/deftest ^:async upload-font-variant-sends-variant-name + (await + (mock/with-mocks* + {rp/cmd! mock/rpc-cmd-mock + uploads/upload-blob-chunked + (fn [_blob & _opts] (rx/of {:session-id (uuid/next)}))} + (let [item {:data {"font/ttf" (js/Uint8Array. 4)} + :team-id (uuid/next) + :font-id (uuid/next) + :font-family "Roboto" + :font-weight 700 + :font-style "italic" + :variant-name "BoldOblique"}] + (await (async/->promise (df/upload-font-variant item))) + (let [{:keys [cmd params]} (first @mock/rpc-calls)] + (t/is (= :create-font-variant cmd)) + (t/is (= "BoldOblique" (:variant-name params))))) + (await (async/settle))))) + +(defn- fake-font + "A minimal opentype.js-like font object exposing only what + prepare-font-entry reads." + [names] + #js {:getEnglishName (fn [k] (get names k)) + :tables #js {:hhea #js {:ascender 800 :descender -200} + :os2 #js {:usWinAscent 800 + :usWinDescent 200 + :sTypoAscender 800 + :sTypoDescender -200 + :fsSelection 0}}}) + +(t/deftest prepare-font-entry-falls-back-when-metadata-is-missing + (t/testing "parsed font with a missing subfamily" + (let [result (df/prepare-font-entry + {:font (fake-font {"preferredFamily" "Pretendard"}) + :type "font/ttf" + :name "Pretendard-SemiBold.otf" + :data (js/Uint8Array. 4)})] + (t/is (= "Pretendard" (:font-family result))) + (t/is (= 600 (:font-weight result))) + (t/is (= "normal" (:font-style result))) + (t/is (nil? (:variant-name result))) + (t/is (false? (:height-warning? result))) + (t/is (= {:name "Pretendard-SemiBold.otf" :type "font/ttf"} + (select-keys (:content result) [:name :type]))))) + + (t/testing "unparseable woff2 entry is resolved from the filename" + (let [result (df/prepare-font-entry + {:font nil + :type "font/woff2" + :name "Pretendard-SemiBold.woff2" + :data (js/Uint8Array. 4)})] + (t/is (= "Pretendard" (:font-family result))) + (t/is (= 600 (:font-weight result))) + (t/is (= "normal" (:font-style result))) + (t/is (false? (:height-warning? result))))) + + (t/testing "a blank first candidate does not shadow a valid second one" + (let [result (df/prepare-font-entry + {:font (fake-font {"preferredFamily" " " + "preferredSubfamily" " " + "fontSubfamily" "Bold"}) + :type "font/ttf" + :name "Roboto-Light.ttf" + :data (js/Uint8Array. 4)})] + (t/is (= "Roboto" (:font-family result))) + (t/is (= 700 (:font-weight result))) + (t/is (= "normal" (:font-style result))) + (t/is (= "Bold" (:variant-name result)))))) diff --git a/frontend/test/frontend_tests/fonts_test.cljs b/frontend/test/frontend_tests/fonts_test.cljs index dad9adf043..2be018f0a4 100644 --- a/frontend/test/frontend_tests/fonts_test.cljs +++ b/frontend/test/frontend_tests/fonts_test.cljs @@ -281,28 +281,28 @@ (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 +(t/deftest prepare-font-entry-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])})] + entry (df/prepare-font-entry {: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 +(t/deftest prepare-font-entry-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])})] + entry (df/prepare-font-entry {: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))))) @@ -351,3 +351,25 @@ (t/is (= 700 (:font-weight entry))) (t/is (= "normal" (:font-style entry)))) (t/is (empty? @emitted)))) + +(t/deftest ^:async process-upload-keeps-bold-and-bolditalic-woff2 + ;; Regression for the review on #11951: the bold-italic file resolved to + ;; style "normal", collided with the bold-only file on the (family, + ;; weight, style) join key and silently overwrote it. + (let [team-id (uuid/next) + blobs [#js {:name "Inter-Bold.woff2"} + #js {:name "Inter-BoldItalic.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 blobs team-id) + :on-next #(reset! result %))) + (await (async/settle)))) + (t/is (= 2 (count (vals @result)))) + (t/is (every? #(= "Inter" (:font-family %)) (vals @result))) + (t/is (every? #(= 700 (:font-weight %)) (vals @result))) + (t/is (= #{"normal" "italic"} (set (map :font-style (vals @result))))) + (t/is (empty? @emitted)))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 38f5975de3..1be1f38f0a 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -17,6 +17,7 @@ [frontend-tests.data.exports-assets-test] [frontend-tests.data.exports-files-test] [frontend-tests.data.fix-deleted-fonts-test] + [frontend-tests.data.fonts-test] [frontend-tests.data.imports-test] [frontend-tests.data.jobs-test] [frontend-tests.data.modal-test] @@ -227,6 +228,7 @@ 'frontend-tests.data.store-test 'frontend-tests.data.exports-assets-test 'frontend-tests.data.exports-files-test + 'frontend-tests.data.fonts-test 'frontend-tests.data.imports-test 'frontend-tests.data.jobs-test 'frontend-tests.worker.exports-test