Andrey Antukh dc38eaa843
🐛 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>
2026-11-12 15:29:30 +01:00

159 lines
6.5 KiB
Clojure

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