🐛 Fix font upload crash and preserve font metadata (#11951)

* 🐛 Fix font upload crash and preserve variant metadata

opentype.js 2.0.0 returns no fontSubfamily name for fonts whose
Macintosh name table only holds custom records. That made
parse-font-weight/parse-font-style call re-seq on nil and crash the
upload.

A nil or blank variant is now treated as no match in
app.common.media, defaulting to weight 400 and style normal. The
variant parsing also accepts oblique as italic, and the new
parse-font-family helper derives the family from a filename by
stripping the known weight/style tokens, separating a style token
glued to a weight token ("Roboto-BoldItalic" -> "Roboto").
first-nonblank-string returns the first nonblank candidate, so a
blank preferredFamily/preferredSubfamily no longer shadows a valid
fontFamily/fontSubfamily.

app.main.data.fonts gains resolve-font-metadata and
prepare-font-entry. opentype.js values win per field; any missing
value falls back to the filename-derived family, weight and style.
The parsed and woff2 branches share the same helper, so metadata is
preserved instead of collapsing every face to weight 400, and
fonts with missing vertical-metric tables upload with no height
warning instead of failing.

process-upload reports per-file failures (unknown signature,
unparseable file, preparation error) with a single bad-font
notification instead of silently dropping them, and never errors
for per-file failures.

The upload now sends :variant-name to create-font-variant, which
accepts it as an optional param (ships in 2.20), so the foundry
name survives the upload.

Closes #10436

AI-assisted-by: deepseek-v4.1-flash
AI-assisted-by: muse-spark-1.3-contributor
Signed-off-by: CHOI JUNSOO <junsoo1172@gmail.com>

* 🐛 Parse glued font style tokens in the font parsers

EvaMarco's review on #11951: parse-font-family and
parse-font-weight handled glued tokens like "BoldItalic", but
parse-font-style did not. For names such as
"opensans-bolditalic-webfont.woff2" the three parsers disagreed:
the italic face resolved to style normal and collided with the
bold-only file on the (family, weight, style) join key. One
binary silently overwrote the other during upload, losing one
font face without any warning.

A new private normalize-font-name helper is applied by all three
parsers. It splits a style token glued to a weight token, now
with a lookahead boundary so "LightItalic_v2" splits too, and
strips a trailing technical suffix (-VF or _v<number>).
parse-font-family, parse-font-weight and parse-font-style now
interpret the name consistently.

Tests cover the three cases from the review, the bold-only
control case, and an end-to-end process-upload check that a bold
and a bold-italic WOFF2 survive as two variants.

Relates to #10436 and PR #11951.

AI-assisted-by: inkling-2-small-free

* Remove duplicate frontend test registration

---------

Signed-off-by: CHOI JUNSOO <junsoo1172@gmail.com>
This commit is contained in:
Andrey Antukh 2026-11-12 15:29:30 +01:00 committed by GitHub
parent 5803f2594c
commit dc38eaa843
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 478 additions and 112 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.20" "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

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

View File

@ -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"))

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,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) "'"))))))

View File

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

View File

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

View File

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