mirror of
https://github.com/penpot/penpot.git
synced 2026-08-28 15:48:52 +00:00
522 lines
19 KiB
Clojure
522 lines
19 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 app.main.fonts
|
|
"Fonts management and loading logic."
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.fonts :as cfnt]
|
|
[app.common.logging :as log]
|
|
[app.common.types.text :as txt]
|
|
[app.common.uri :as u]
|
|
[app.config :as cf]
|
|
[app.util.dom :as dom]
|
|
[app.util.globals :as globals]
|
|
[app.util.http :as http]
|
|
[app.util.object :as obj]
|
|
[app.util.timers :as tm]
|
|
[beicon.v2.core :as rx]
|
|
[cuerdas.core :as str]
|
|
[okulary.core :as l]
|
|
[promesa.core :as p]))
|
|
|
|
(log/set-level! :warn)
|
|
|
|
(defonce fontsdb (l/atom {}))
|
|
(defonce fonts (l/atom []))
|
|
|
|
(add-watch fontsdb "main"
|
|
(fn [_ _ _ db]
|
|
(->> (vals db)
|
|
(sort-by :name)
|
|
(map-indexed #(assoc %2 :index %1))
|
|
(vec)
|
|
(reset! fonts))))
|
|
|
|
(defn register!
|
|
[backend fonts]
|
|
(swap! fontsdb
|
|
(fn [db]
|
|
(let [db (reduce-kv #(cond-> %1 (= backend (:backend %3)) (dissoc %2)) db db)
|
|
fonts (map #(assoc % :backend backend) fonts)]
|
|
(merge db (d/index-by :id fonts))))))
|
|
|
|
(register! :builtin cfnt/local-fonts)
|
|
|
|
(when (contains? cf/flags :google-fonts-provider)
|
|
(register! :google cfnt/catalog))
|
|
|
|
(defn get-font-data [id]
|
|
(get @fontsdb id))
|
|
|
|
(defn installed?
|
|
"True when a font with `font-id` is currently registered in `fontsdb`."
|
|
[font-id]
|
|
(contains? @fontsdb font-id))
|
|
|
|
(defn valid-default-font
|
|
"Return the remembered default-font attrs only when its font is still
|
|
installed in `fontsdb`; otherwise nil, so callers fall back to the built-in
|
|
default text attrs (Source Sans Pro).
|
|
|
|
A new text shape inherits the last-used font (`[:workspace-global
|
|
:default-font]`). If that font is gone (deleted, unavailable in the current
|
|
team, or never resolved), its attrs carry a missing/nil font-family, which
|
|
produces content that fails the backend `validate-shape` schema."
|
|
[default-font]
|
|
(when (and (some? default-font)
|
|
(installed? (:font-id default-font)))
|
|
default-font))
|
|
|
|
(defn find-font-data [data]
|
|
(d/seek
|
|
(fn [font]
|
|
(= (select-keys font (keys data))
|
|
data))
|
|
(vals @fontsdb)))
|
|
|
|
(defn find-font-family
|
|
"Case insensitive lookup of font-family."
|
|
[family]
|
|
(let [family' (str/lower family)]
|
|
(d/seek
|
|
(fn [{:keys [family]}]
|
|
(= family' (str/lower family)))
|
|
(vals @fontsdb))))
|
|
|
|
(defn resolve-variants
|
|
[id]
|
|
(get-in @fontsdb [id :variants]))
|
|
|
|
(defn resolve-fonts
|
|
[backend]
|
|
(get @fonts backend))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; FONTS LOADING
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defonce ^:dynamic loaded (l/atom #{}))
|
|
(defonce ^:dynamic loading (l/atom {}))
|
|
|
|
;; NOTE: mainly used on worker, when you don't really need load font
|
|
;; only know if the font is needed or not
|
|
(defonce ^:dynamic loaded-hints (l/atom #{}))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; PREVIEW SPRITE
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
;; A prebuilt SVG sprite (generated by scripts/build-fonts-preview.js) holds every
|
|
;; built-in + Google font name outlined in its own typeface, so the picker can
|
|
;; preview the whole catalog with no per-font requests. Fonts not in it (custom
|
|
;; uploads, ones that fail to bake) use the runtime fallback.
|
|
;;
|
|
;; The sprite is heavy (~2000 nodes), so we DON'T keep it in the DOM: the fetched
|
|
;; markup is parsed once eagerly into a cached node (`:node`) so attaching is a
|
|
;; cheap appendChild. `:ids` are the font ids it covers (also pre-computed), so
|
|
;; the UI can pick sprite vs fallback. `:refs` counts open dropdowns sharing the
|
|
;; node, so the last one to close is the one that detaches it.
|
|
(defonce preview-sprite (l/atom {:status :idle :ids #{} :node nil :refs 0}))
|
|
|
|
;; Id prefix shared with the generator and the UI's `<use href>`; referenced here
|
|
;; rather than re-declared so the contract stays in one place.
|
|
(def preview-sprite-prefix "font-preview-")
|
|
|
|
(defn- collect-preview-ids
|
|
"Set of font ids present in the sprite, read from the injected `container`
|
|
element's `<g id=\"font-preview-…\">` groups (prefix stripped)."
|
|
[^js container]
|
|
(let [nodes (dom/query-all container (dm/str "g[id^=\"" preview-sprite-prefix "\"]"))
|
|
plen (count preview-sprite-prefix)]
|
|
(persistent!
|
|
(reduce (fn [acc node]
|
|
(conj! acc (subs (dom/get-attribute node "id") plen)))
|
|
(transient #{})
|
|
(array-seq nodes)))))
|
|
|
|
(defn- reset-preview-sprite-error!
|
|
[]
|
|
;; :error → the UI shows plain names (no previews, no per-font load storm); a
|
|
;; later `prefetch-preview-sprite!` call can retry.
|
|
(reset! preview-sprite {:status :error :ids #{} :node nil :refs 0}))
|
|
|
|
(defn- parse-sprite-svg
|
|
"Parse the cached sprite markup as SVG (not HTML, so no innerHTML injection
|
|
surface). Returns the root `<svg>` element, or nil if it isn't valid SVG."
|
|
[text]
|
|
(let [doc (.parseFromString (js/DOMParser.) text "image/svg+xml")
|
|
root (.-documentElement doc)]
|
|
;; A malformed document yields a <parsererror> root instead of <svg>.
|
|
(when (and (= "svg" (.-tagName ^js root))
|
|
(nil? (dom/query doc "parsererror")))
|
|
root)))
|
|
|
|
(defn prefetch-preview-sprite!
|
|
"Fetch the font-preview sprite markup, pre-parse it on idle, and cache the
|
|
parsed DOM node with the font ids it covers. Idempotent: fetches only when
|
|
nothing is cached yet (`:idle`) or a previous attempt failed (`:error`); no-op
|
|
while `:loading` or `:ready`."
|
|
[]
|
|
(when (and (globals/browser?)
|
|
(contains? #{:idle :error} (:status @preview-sprite)))
|
|
(swap! preview-sprite assoc :status :loading)
|
|
(->> (http/send! {:method :get
|
|
:uri cf/fonts-preview-sprite-uri
|
|
:response-type :text
|
|
:mode :cors})
|
|
(rx/subs!
|
|
(fn [response]
|
|
;; http/send! doesn't reject on non-2xx; guard so an error body isn't
|
|
;; cached as the sprite. The parse is deferred to idle so the
|
|
;; ~2000-node import doesn't spike the main thread at load time;
|
|
;; `:status` stays `:loading` until it's done.
|
|
(if (http/success? response)
|
|
(let [svg (:body response)]
|
|
(tm/schedule-on-idle
|
|
(fn []
|
|
(if-let [node (some-> (parse-sprite-svg svg) (dom/import-node))]
|
|
(do
|
|
(dom/set-attribute! node "id" "font-preview-sprite")
|
|
(let [ids (collect-preview-ids node)]
|
|
(swap! preview-sprite assoc
|
|
:status :ready
|
|
:node node
|
|
:ids ids)))
|
|
(do
|
|
(log/wrn :hint "cannot parse font preview sprite")
|
|
(reset-preview-sprite-error!))))))
|
|
(do
|
|
(log/wrn :hint "cannot load font preview sprite" :status (:status response))
|
|
(reset-preview-sprite-error!))))
|
|
(fn [cause]
|
|
(log/wrn :hint "cannot load font preview sprite" :cause cause)
|
|
(reset-preview-sprite-error!))))))
|
|
|
|
(defn attach-preview-sprite!
|
|
"Append the pre-parsed sprite node into the DOM (hidden) so rows can reference
|
|
its glyph groups via `<use>`. Returns the node (pass it to
|
|
`detach-preview-sprite!` on close), or nil if not ready. Parsing and id
|
|
collection happen once during `prefetch-preview-sprite!`, so this is just a
|
|
cheap appendChild. Multiple dropdowns may share the node; each attach
|
|
increments `:refs` so the node is only detached when the last one closes."
|
|
[]
|
|
(let [{:keys [status node]} @preview-sprite]
|
|
(when (and (globals/browser?) (= :ready status) (some? node))
|
|
(when-let [body-el (unchecked-get globals/document "body")]
|
|
(dom/append-child! body-el node))
|
|
(swap! preview-sprite update :refs inc)
|
|
node)))
|
|
|
|
(defn detach-preview-sprite!
|
|
"Remove the sprite node injected by `attach-preview-sprite!` from the DOM when
|
|
the last open dropdown closes. The cached node and `:ids` stay, so reopening
|
|
re-attaches without a refetch or re-parse."
|
|
[node]
|
|
(let [new-state (swap! preview-sprite update :refs #(max 0 (dec %)))]
|
|
(when (zero? (:refs new-state))
|
|
(dom/remove! node))))
|
|
|
|
(defn- add-font-css
|
|
"Creates a style element and attaches it to the dom."
|
|
[id css]
|
|
(let [node (dom/create-element "style")]
|
|
(dom/set-attribute! node "id" id)
|
|
(dom/set-html! node css)
|
|
(when-let [head (unchecked-get globals/document "head")]
|
|
(dom/append-child! head node))))
|
|
|
|
;; --- LOADER: BUILTIN
|
|
|
|
(defmulti ^:private load-font :backend)
|
|
|
|
(defmethod load-font :default
|
|
[{:keys [backend ::on-failed] :as font}]
|
|
(log/wrn :msg "no implementation found for" :backend backend)
|
|
(when (fn? on-failed)
|
|
(on-failed (ex-info "unsupported font backend" {:backend backend}))))
|
|
|
|
(defmethod load-font :builtin
|
|
[{:keys [id ::on-loaded] :as font}]
|
|
(log/dbg :hint "load-font" :font-id id :backend "builtin")
|
|
(when (fn? on-loaded)
|
|
(on-loaded id)))
|
|
|
|
;; --- LOADER: GOOGLE
|
|
|
|
(defn- generate-gfonts-url
|
|
[{:keys [family variants]}]
|
|
(let [query (dm/str "family=" family ":"
|
|
(str/join "," (map :id variants))
|
|
"&display=block")]
|
|
(dm/str
|
|
(-> cf/public-uri
|
|
(u/join "internal/gfonts/css")
|
|
(assoc :query query)))))
|
|
|
|
(defn- process-gfont-css
|
|
[css]
|
|
(cfnt/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font")))
|
|
|
|
(defn- request-gfont-css
|
|
[url]
|
|
(->> (http/send! {:method :get :uri url :mode :cors :response-type :text})
|
|
(rx/map :body)))
|
|
|
|
(defn- fetch-gfont-css
|
|
[url]
|
|
(->> (request-gfont-css url)
|
|
(rx/catch (fn [cause]
|
|
;; Keep CSS streams alive when a font cannot load.
|
|
(log/wrn :hint "cannot find the font" :cause cause)
|
|
(rx/empty)))))
|
|
|
|
(defmethod load-font :google
|
|
[{:keys [id ::on-loaded ::on-failed] :as font}]
|
|
(when (globals/browser?)
|
|
(log/dbg :hint "load-font" :font-id id :backend "google")
|
|
(let [url (generate-gfonts-url font)]
|
|
;; Keep raw errors so the loader can use its fallback.
|
|
(->> (request-gfont-css url)
|
|
(rx/map process-gfont-css)
|
|
(rx/tap #(on-loaded id))
|
|
(rx/subs! (partial add-font-css id)
|
|
#(when (fn? on-failed) (on-failed %))))
|
|
nil)))
|
|
|
|
;; --- LOADER: CUSTOM
|
|
|
|
(def font-face-template
|
|
"@font-face {
|
|
font-family: '%(family)s';
|
|
font-style: %(style)s;
|
|
font-weight: %(weight)s;
|
|
font-display: block;
|
|
src: url(%(uri)s) format('woff');
|
|
}")
|
|
|
|
(defn- asset-id->uri
|
|
[asset-id]
|
|
(-> cf/public-uri
|
|
(u/join "assets/by-id/" asset-id)
|
|
(str)))
|
|
|
|
(defn generate-custom-font-variant-css
|
|
[family variant]
|
|
(str/fmt font-face-template
|
|
{:family family
|
|
:style (:style variant)
|
|
:weight (:weight variant)
|
|
:uri (asset-id->uri (::woff1-file-id variant))}))
|
|
|
|
(defn- generate-custom-font-css
|
|
[{:keys [family variants] :as font}]
|
|
(->> variants
|
|
(map #(generate-custom-font-variant-css family %))
|
|
(str/join "\n")))
|
|
|
|
(defmethod load-font :custom
|
|
[{:keys [id ::on-loaded] :as font}]
|
|
(when (globals/browser?)
|
|
(log/dbg :hint "load-font" :font-id id :backend "custom")
|
|
(let [css (generate-custom-font-css font)]
|
|
(add-font-css id css)
|
|
(when (fn? on-loaded)
|
|
(on-loaded)))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; LOAD API
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn ensure-loaded!
|
|
([font-id] (ensure-loaded! font-id nil))
|
|
([font-id variant-id]
|
|
(log/dbg :action "try-ensure-loaded!" :font-id font-id :variant-id variant-id)
|
|
(if-not (globals/browser?)
|
|
;; If we are in the worker environment, we just mark it as loaded
|
|
;; without really loading it.
|
|
(do
|
|
(swap! loaded-hints conj {:font-id font-id :font-variant-id variant-id})
|
|
(p/resolved font-id))
|
|
|
|
(let [font (get @fontsdb font-id)]
|
|
(cond
|
|
(nil? font)
|
|
(p/resolved font-id)
|
|
|
|
;; Font already loaded, we just continue
|
|
(contains? @loaded font-id)
|
|
(p/resolved font-id)
|
|
|
|
;; Font is currently downloading. We attach the caller to the promise
|
|
(contains? @loading font-id)
|
|
(get @loading font-id)
|
|
|
|
;; First caller, we create the promise and then wait
|
|
:else
|
|
(let [settle! (fn [resolve loaded?]
|
|
;; Defer cleanup until a synchronous load is cached.
|
|
(tm/schedule
|
|
#(do
|
|
(when loaded?
|
|
(swap! loaded conj font-id))
|
|
(swap! loading dissoc font-id)
|
|
(resolve font-id))))
|
|
|
|
on-load (fn [resolve]
|
|
(settle! resolve true))
|
|
|
|
on-failed
|
|
(fn [resolve cause]
|
|
(log/wrn :hint "font load failed; using fallback"
|
|
:font-id font-id
|
|
:cause cause)
|
|
(settle! resolve false))
|
|
|
|
load-p (-> (p/create
|
|
(fn [resolve _]
|
|
(-> font
|
|
(assoc ::on-loaded (partial on-load resolve))
|
|
(assoc ::on-failed (partial on-failed resolve))
|
|
(load-font))))
|
|
;; We need to wait for the font to be loaded
|
|
(p/then (partial p/delay 120)))]
|
|
|
|
(swap! loading assoc font-id load-p)
|
|
load-p))))))
|
|
|
|
(defn ready
|
|
[cb]
|
|
(let [fonts (obj/get js/document "fonts")]
|
|
(p/then (obj/get fonts "ready") cb)))
|
|
|
|
(defn get-default-variant
|
|
[{:keys [variants]}]
|
|
(or (d/seek #(or (= (:id %) "regular")
|
|
(= (:name %) "regular")) variants)
|
|
(first variants)))
|
|
|
|
(defn get-variant
|
|
[{:keys [variants] :as font} font-variant-id]
|
|
(or (d/seek #(= (:id %) font-variant-id) variants)
|
|
(get-default-variant font)))
|
|
|
|
(defn find-variant
|
|
[{:keys [variants] :as font} variant-data]
|
|
(let [props (keys variant-data)]
|
|
(d/seek #(= (select-keys % props) variant-data) variants)))
|
|
|
|
(defn find-closest-variant
|
|
"Find the closest font weight variant in `font` for `target-weight` with optional `target-style` match.
|
|
When exactly between two weights, choose the higher one.
|
|
|
|
The algorithm lives in `app.common.fonts` so the headless exporter resolves the
|
|
same variant for the same text."
|
|
[font target-weight target-style]
|
|
(cfnt/closest-variant (:variants font []) target-weight target-style))
|
|
|
|
;; Font embedding functions
|
|
(defn get-node-fonts
|
|
"Extracts the fonts used by some node"
|
|
[node]
|
|
(let [nodes (.from js/Array (dom/query-all node "[style*=font]"))
|
|
result (.reduce nodes (fn [obj node]
|
|
(let [style (.-style node)
|
|
font-family (.-fontFamily style)
|
|
[_ font] (first
|
|
(filter (fn [[_ {:keys [id family]}]]
|
|
(or (= family font-family)
|
|
(= id font-family)))
|
|
@fontsdb))
|
|
font-id (:id font)
|
|
font-variant (get-variant font (.-fontVariant style))
|
|
font-variant-id (:id font-variant)]
|
|
(obj/set!
|
|
obj
|
|
(dm/str font-id ":" font-variant-id)
|
|
{:font-id font-id
|
|
:font-variant-id font-variant-id})))
|
|
#js {})]
|
|
(.values js/Object result)))
|
|
|
|
(defn get-content-fonts
|
|
"Extracts the fonts used by the content of a text shape"
|
|
[content]
|
|
(->> (txt/node-seq content)
|
|
(filter txt/is-text-node?)
|
|
(reduce
|
|
(fn [result {:keys [font-id] :as node}]
|
|
(let [current-font
|
|
(if (some? font-id)
|
|
(select-keys node [:font-id :font-variant-id :font-weight :font-style])
|
|
(select-keys txt/default-typography [:font-id :font-variant-id :font-weight :font-style]))]
|
|
(conj result current-font)))
|
|
#{})))
|
|
|
|
(defn fetch-font-css
|
|
"Given a font and the variant-id, retrieves the fontface CSS"
|
|
[{:keys [font-id font-variant-id]
|
|
:or {font-variant-id "regular"}}]
|
|
(let [{:keys [backend family] :as font} (get @fontsdb font-id)]
|
|
(cond
|
|
(nil? font)
|
|
(rx/empty)
|
|
|
|
(= :google backend)
|
|
(let [variant (get-variant font font-variant-id)]
|
|
(->> (rx/of (generate-gfonts-url {:family family :variants [variant]}))
|
|
(rx/mapcat fetch-gfont-css)
|
|
(rx/map process-gfont-css)))
|
|
|
|
(= :custom backend)
|
|
(let [variant (get-variant font font-variant-id)
|
|
result (generate-custom-font-variant-css family variant)]
|
|
(rx/of result))
|
|
|
|
:else
|
|
(let [{:keys [weight style suffix]} (get-variant font font-variant-id)
|
|
suffix (or suffix font-variant-id)
|
|
params {:uri (str (u/join cf/public-uri (str "fonts/" family "-" suffix ".woff")))
|
|
:family family
|
|
:style style
|
|
:weight weight}]
|
|
(rx/of (str/fmt font-face-template params))))))
|
|
|
|
(defn extract-fontface-urls
|
|
"Parses the CSS and retrieves the font urls"
|
|
[^string css]
|
|
(->> (re-seq #"url\(([^)]+)\)" css)
|
|
(mapv second)))
|
|
|
|
(defn render-font-styles
|
|
[font-refs]
|
|
(->> (rx/from font-refs)
|
|
(rx/mapcat fetch-font-css)
|
|
(rx/reduce (fn [acc css] (dm/str acc "\n" css)) "")))
|
|
|
|
(defonce font-styles (js/Map.))
|
|
|
|
(defn get-font-style-id
|
|
[{:keys [font-id font-variant-id]
|
|
:or {font-variant-id "regular"}}]
|
|
(dm/fmt "%:%" font-id font-variant-id))
|
|
|
|
(defn get-font-styles-by-font-ref
|
|
[font-ref]
|
|
(let [id (get-font-style-id font-ref)]
|
|
(if (.has font-styles id)
|
|
(rx/of (.get font-styles id))
|
|
(->> (rx/of font-ref)
|
|
(rx/mapcat fetch-font-css)
|
|
(rx/tap (fn [css] (.set font-styles id css)))))))
|
|
|
|
(defn render-font-styles-cached
|
|
[font-refs]
|
|
(->> (rx/from font-refs)
|
|
(rx/merge-map get-font-styles-by-font-ref)
|
|
(rx/reduce (fn [acc css] (dm/str acc "\n" css)) "")))
|