diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 7d6bb2a894..a1d22c0832 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -381,6 +381,22 @@ (when (zero? (:exit res)) (:out res)))) + (woff2->sfnt [data] + ;; woff2_decompress outputs to same directory with .ttf extension + (let [finput (tmp/tempfile :prefix "penpot.font." :suffix ".woff2") + foutput (fs/path (str/replace (str finput) #"\.woff2$" ".ttf"))] + (try + (io/write* finput data) + (let [res (sh/sh "woff2_decompress" (str finput))] + (if (zero? (:exit res)) + foutput + (do + (when (fs/exists? foutput) + (fs/delete foutput)) + nil))) + (finally + (fs/delete finput))))) + ;; Documented here: ;; https://docs.microsoft.com/en-us/typography/opentype/spec/otff#table-directory (get-sfnt-type [data] @@ -430,4 +446,27 @@ (= stype :ttf) (-> (assoc "font/otf" (ttf->otf sfnt)) - (assoc "font/ttf" sfnt))))))))) + (assoc "font/ttf" sfnt))))) + + (contains? current "font/woff2") + (let [data (get input "font/woff2") + foutput (woff2->sfnt data)] + (when-not foutput + (ex/raise :type :validation + :code :invalid-woff2-file + :hint "invalid woff2 file")) + (try + (let [sfnt (io/read* foutput) + type (get-sfnt-type sfnt)] + (cond-> input + (= type :otf) + (-> (assoc "font/otf" sfnt) + (assoc "font/ttf" (otf->ttf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))) + + (= type :ttf) + (-> (assoc "font/ttf" sfnt) + (assoc "font/otf" (ttf->otf sfnt)) + (update "font/woff" gen-if-nil #(ttf-or-otf->woff sfnt))))) + (finally + (fs/delete foutput)))))))) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 1316b237c9..be5410ffd0 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -93,6 +93,41 @@ :font-weight :font-style)))) +(t/deftest woff2-font-upload-1 + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + proj-id (:default-project-id prof) + font-id (uuid/custom 10 1) + + data (-> (io/resource "backend_tests/test_files/font-1.woff2") + (io/read*)) + + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "somefont" + :font-weight 400 + :font-style "normal" + :data {"font/woff2" data}} + out (th/command! params)] + + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (let [result (:result out)] + (t/is (uuid? (:id result))) + (t/is (uuid? (:ttf-file-id result))) + (t/is (uuid? (:otf-file-id result))) + (t/is (uuid? (:woff1-file-id result))) + (t/is (uuid? (:woff2-file-id result))) + (t/are [k] (= (get params k) + (get result k)) + :team-id + :font-id + :font-family + :font-weight + :font-style)))) + (t/deftest font-deletion-1 (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) diff --git a/backend/test/backend_tests/test_files/font-1.woff2 b/backend/test/backend_tests/test_files/font-1.woff2 new file mode 100644 index 0000000000..492d463d90 Binary files /dev/null and b/backend/test/backend_tests/test_files/font-1.woff2 differ diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index 4cdf8488ce..fc349765a2 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -12,6 +12,7 @@ (def font-types #{"font/ttf" "font/woff" + "font/woff2" "font/otf" "font/opentype"}) @@ -81,21 +82,22 @@ (defn parse-font-weight [variant] (cond - (re-seq #"(?i)(?:hairline|thin)" variant) 100 - (re-seq #"(?i)(?:extra\s*light|ultra\s*light)" variant) 200 - (re-seq #"(?i)(?:light)" variant) 300 - (re-seq #"(?i)(?:normal|regular)" variant) 400 - (re-seq #"(?i)(?:medium)" variant) 500 - (re-seq #"(?i)(?:semi\s*bold|demi\s*bold)" variant) 600 - (re-seq #"(?i)(?:extra\s*bold|ultra\s*bold)" variant) 800 - (re-seq #"(?i)(?:bold)" variant) 700 - (re-seq #"(?i)(?:extra\s*black|ultra\s*black)" variant) 950 - (re-seq #"(?i)(?:black|heavy|solid)" variant) 900 - :else 400)) + (re-seq #"(?i)(?:^|[-_\s])(hairline|thin)(?=(?:[-_\s]|$|italic\b))" variant) 100 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*light|ultra\s*light)(?=(?:[-_\s]|$|italic\b))" variant) 200 + (re-seq #"(?i)(?:^|[-_\s])(light)(?=(?:[-_\s]|$|italic\b))" variant) 300 + (re-seq #"(?i)(?:^|[-_\s])(normal|regular)(?=(?:[-_\s]|$|italic\b))" variant) 400 + (re-seq #"(?i)(?:^|[-_\s])(medium)(?=(?:[-_\s]|$|italic\b))" variant) 500 + (re-seq #"(?i)(?:^|[-_\s])(semi\s*bold|demi\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 600 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*bold|ultra\s*bold)(?=(?:[-_\s]|$|italic\b))" variant) 800 + (re-seq #"(?i)(?:^|[-_\s])(bold)(?=(?:[-_\s]|$|italic\b))" variant) 700 + (re-seq #"(?i)(?:^|[-_\s])(extra\s*black|ultra\s*black)(?=(?:[-_\s]|$|italic\b))" variant) 950 + (re-seq #"(?i)(?:^|[-_\s])(black|heavy|solid)(?=(?:[-_\s]|$|italic\b))" variant) 900 + :else 400)) (defn parse-font-style [variant] - (if (re-seq #"(?i)(?:italic)" variant) + (if (or (re-seq #"(?i)(?:^|[-_\s])(italic)(?:[-_\s]|$)" variant) + (re-seq #"(?i)italic$" variant)) "italic" "normal")) diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index 5098bf6e82..b6c18aab2d 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -9,6 +9,39 @@ [app.common.media :as media] [clojure.test :as t])) +(t/deftest test-parse-font-weight + (t/testing "matches weight tokens with proper boundaries" + (t/is (= 700 (media/parse-font-weight "Roboto-Bold"))) + (t/is (= 700 (media/parse-font-weight "Roboto_Bold"))) + (t/is (= 700 (media/parse-font-weight "Roboto Bold"))) + (t/is (= 700 (media/parse-font-weight "Bold"))) + (t/is (= 800 (media/parse-font-weight "Roboto-ExtraBold"))) + (t/is (= 600 (media/parse-font-weight "OpenSans-SemiBold"))) + (t/is (= 300 (media/parse-font-weight "Lato-Light"))) + (t/is (= 100 (media/parse-font-weight "Roboto-Thin"))) + (t/is (= 200 (media/parse-font-weight "Roboto-ExtraLight"))) + (t/is (= 500 (media/parse-font-weight "Roboto-Medium"))) + (t/is (= 900 (media/parse-font-weight "Roboto-Black")))) + + (t/testing "does not match weight tokens embedded in words" + (t/is (= 400 (media/parse-font-weight "Boldini"))) + (t/is (= 400 (media/parse-font-weight "Lighthaus"))) + (t/is (= 400 (media/parse-font-weight "Blackwood"))) + (t/is (= 400 (media/parse-font-weight "Thinker"))) + (t/is (= 400 (media/parse-font-weight "Mediaeval"))))) + +(t/deftest test-parse-font-style + (t/testing "matches italic with proper boundaries" + (t/is (= "italic" (media/parse-font-style "Roboto-Italic"))) + (t/is (= "italic" (media/parse-font-style "Roboto_Italic"))) + (t/is (= "italic" (media/parse-font-style "Roboto Italic"))) + (t/is (= "italic" (media/parse-font-style "Italic"))) + (t/is (= "italic" (media/parse-font-style "Roboto-BoldItalic")))) + + (t/testing "does not match italic embedded in words" + (t/is (= "normal" (media/parse-font-style "Italica"))) + (t/is (= "normal" (media/parse-font-style "Roboto-Regular"))))) + (t/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 28f702ed8a..d72cde8436 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -99,46 +99,65 @@ map with temporal ID's associated to each font entry." [blobs team-id] (letfn [(prepare [{:keys [font type name data] :as params}] - (let [family (or (.getEnglishName ^js font "preferredFamily") - (.getEnglishName ^js font "fontFamily")) - variant (or (.getEnglishName ^js font "preferredSubfamily") - (.getEnglishName ^js font "fontSubfamily")) + (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. + ;; 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)) + 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)) + 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)) + 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)) + ;; useTypoMetrics can be read from the 7th bit + f-selection (-> ^js font .-tables .-os2 .-fsSelection (bit-test 7)) - height-warning? (or (not= hhea-ascender win-ascent) - (not= hhea-descender win-descent) - (and f-selection (or - (not= hhea-ascender os2-ascent) - (not= hhea-descender os2-descent)))) - data (js/Uint8Array. data)] - {:content {:data (chunk-array data default-chunk-size) - :name name - :type type} - :font-family (or family "") - :font-weight (cm/parse-font-weight variant) - :font-style (cm/parse-font-style variant) - :height-warning? height-warning?})) + height-warning? (or (not= hhea-ascender win-ascent) + (not= hhea-descender win-descent) + (and f-selection (or + (not= hhea-ascender os2-ascent) + (not= hhea-descender os2-descent)))) + data (js/Uint8Array. data)] + {:content {:data (chunk-array data default-chunk-size) + :name name + :type type} + :font-family (or family "") + :font-weight (cm/parse-font-weight variant) + :font-style (cm/parse-font-style 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 (chunk-array data default-chunk-size) + :name name + :type type} + :font-family family-name + :font-weight (cm/parse-font-weight base-name) + :font-style (cm/parse-font-style base-name) + :height-warning? false}))) (join [res {:keys [content] :as font}] (let [key-fn (juxt :font-family :font-weight :font-style) @@ -166,14 +185,18 @@ (case sg "117 124 124 117" "font/otf" "0 1 0 0" "font/ttf" - "167 117 106 106" "font/woff"))) + "167 117 106 106" "font/woff" + "167 117 106 62" "font/woff2"))) - (parse-font [{:keys [data] :as params}] - (try - (assoc params :font (ot/parse data)) - (catch :default _e - (log/warn :msg (str/fmt "skipping file %s, unsupported format" (:name params))) - nil))) + (parse-font [{:keys [data type name] :as params}] + (if (= type "font/woff2") + ;; woff2 cannot be parsed by opentype.js, extract metadata from filename + (assoc params :font nil) + (try + (assoc params :font (ot/parse data)) + (catch :default _e + (log/warn :msg (str/fmt "skipping file %s, unsupported format" name)) + nil)))) (read-blob [blob] (->> (wa/read-file-as-array-buffer blob) diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 45c7f101b5..fb40826a49 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -9,6 +9,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.media :as cm] [app.common.uuid :as uuid] [app.config :as cf] @@ -34,7 +35,7 @@ (def ^:private accept-font-types (str (str/join "," cm/font-types) ;; A workaround to solve a problem with chrome input selector - ",.ttf,application/font-woff,woff,.otf")) + ",.ttf,application/font-woff,.woff,.woff2,.otf")) (defn- use-page-title [team section] @@ -118,10 +119,10 @@ (swap! fonts* dissoc id) (swap! uploading* disj id) (st/emit! (df/add-font font))) - (fn [error] + (fn [cause] (st/emit! (ntf/error (tr "errors.bad-font" (first (:names item))))) (swap! fonts* dissoc id) - (js/console.log "error" error)))))) + (ex/print-throwable cause)))))) on-upload (mf/use-fn