🐛 Fix font family parsing and persist variant name

Address the review findings on the earlier font upload fix.

parse-font-family now lives in app.common.media and separates a
style token glued to a weight token, so "Roboto-BoldItalic" yields
family "Roboto". oblique is recognized as italic in parse-font-style
and in the weight lookahead, avoiding a collision with the regular
face.

The upload now sends :variant-name to create-font-variant, which
accepts it as an optional param, so the foundry name survives the
upload. prepare-font-entry is extracted and covered by a regression
test for the parsed and woff2 branches.

Closes #10436

AI-assisted-by: deepseek-v4.1-flash
This commit is contained in:
Andrey Antukh 2026-09-28 13:28:28 +00:00
parent 6999b7e501
commit 273a968c62
6 changed files with 285 additions and 99 deletions

View File

@ -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.19" "Add optional :variant-name param"]]
::climit/id [[:process-font/by-profile ::rpc/profile-id]
[:process-font/global]]
::webhooks/event? true

View File

@ -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
;; -----------------------------------------------------------------------

View File

@ -81,27 +81,58 @@
(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.
#"(?i)(black|bold|heavy|solid|medium|light|thin|hairline|regular|normal)(italic|oblique)\b")
(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 (str/replace base-name font-concatenated-style-re "$1 $2")
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
[variant]
(let [variant (or 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
[variant]
(let [variant (or variant "")]
(if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant)
(re-seq #"(?i)italic$" variant))
(if (or (re-seq #"(?i)(?:^|[-_\s])(italic|oblique)(?:[-_\s]|$)" variant)
(re-seq #"(?i)(?:italic|oblique)$" variant))
"italic"
"normal")))

View File

@ -30,6 +30,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 "handles nil and blank variants safely"
(t/is (= 400 (media/parse-font-weight nil)))
(t/is (= 400 (media/parse-font-weight "")))
@ -47,11 +52,43 @@
(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 "handles nil and blank variants safely"
(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-strip-image-extension
(t/testing "removes extension from supported image files"
(t/is (= (media/strip-image-extension "foo.png") "foo"))

View File

@ -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,47 +108,90 @@
{})
(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})))))
(def ^:private font-variant-tokens-re
;; Strip known weight/style tokens and separators to derive the family name.
;; Use word boundaries to avoid matching substrings (e.g. "Boldini" must not
;; match "bold").
#"(?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]|$)")
(defn- font-family-from-name
"Derive a font family name from a font filename, stripping the known
weight/style tokens and separators."
[name]
(let [base-name (str/replace name #"\.[^.]+$" "")
raw-family-name (-> base-name
(str/replace font-variant-tokens-re "$1$3")
(str/replace #"[-_\s]+" " ")
(str/trim))]
(if (str/blank? raw-family-name) base-name raw-family-name)))
{: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` holds the family and variant names 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."
[{:keys [family variant]} filename]
(let [base-name (str/replace filename #"\.[^.]+$" "")
`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 (font-family-from-name filename))
{: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."
[{: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)
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 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.
@ -158,58 +201,7 @@
wrap it in a `Blob` and hand it directly to `upload-blob-chunked`
without any intermediate client-side chunking."
[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)
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))))
(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
@ -281,7 +273,7 @@
(rx/filter #(nil? (:error %)))
(rx/map parse-font)
(rx/filter some?)
(rx/map prepare)
(rx/map prepare-font-entry)
(rx/reduce join {})))))
(defn- calculate-family-to-id-mapping

View File

@ -7,8 +7,14 @@
(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]
[cljs.test :as t :include-macros true]))
[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"
@ -58,9 +64,82 @@
: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))))))