♻️ Move shared font and resources utils out of render_wasm

This commit is contained in:
Elena Torro 2026-08-05 10:22:50 +02:00
parent 25c6bff3a9
commit 4971cb9667
12 changed files with 188 additions and 213 deletions

View File

@ -2,12 +2,17 @@
The host-agnostic ClojureScript side of the render-wasm binary protocol: byte
layouts, memory helpers and serializers that turn Penpot shapes into the buffers
`render-wasm` consumes, plus the google fonts catalog (baked from
`common/resources/fonts/gfonts.*.json`).
`render-wasm` consumes.
The workspace drives it from `app.render-wasm.*`, the headless exporter from
`app.wasm.*` — same code underneath, so the two cannot drift.
Font knowledge is *not* here even though both hosts need it for rendering: it is
not specific to the wasm backend, so the google fonts catalog (baked from
`common/resources/fonts/gfonts.*.json`), the bundled builtin family and the
emoji/script fallback tables live in `app.common.types.font.*`. Likewise the
image-id enumeration lives in `app.common.types.shape.images`.
`shared.js` is not here: it is a per-build artifact, so each host compiles
against the copy from its own render-wasm build and passes it to
`wasm/init-serializers!` (see `app.render-wasm.api.enums`, `app.wasm.enums`).

View File

@ -1,37 +0,0 @@
;; 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 INC Sucursal en España SL
(ns app.common.render-wasm.builtin-fonts
"Fonts bundled with the frontend, served from `<public-uri>/fonts/`. Shared so
the workspace and the exporter upload the same TTF for a given weight/style."
(:require
[app.common.render-wasm.gfonts :as gf]))
(def local-fonts
[{:id "sourcesanspro"
:name "Source Sans Pro"
:family "sourcesanspro"
:variants
[{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"}
{:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"}
{:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"}
{:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"}
{:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"}
{:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"}
{:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"}
{:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"}
{:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"}
{:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"}
{:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"}
{:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}])
(defn resolve-ttf-file
"Builtin TTF file name for `weight` and `style` (0 normal, 1 italic), by the
same nearest-weight rule as the google catalog."
[weight style]
(let [variants (:variants (first local-fonts))]
(:ttf-url (or (gf/closest-variant variants weight (if (zero? style) "normal" "italic"))
(first variants)))))

View File

@ -1,111 +0,0 @@
;; 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 INC Sucursal en España SL
(ns app.common.render-wasm.gfonts
(:require-macros [app.common.render-wasm.gfonts :refer [preload-gfonts]])
(:require
[app.common.data :as d]
[app.common.uuid :as uuid]
[cuerdas.core :as str]))
;; --- catalog
(def catalog
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(def ^:private by-id
(reduce (fn [m font] (assoc m (:id font) font)) {} catalog))
(def ^:private by-uuid
(reduce (fn [m font] (assoc m (:uuid font) font)) {} catalog))
(defn gfont-id->uuid
"Maps a `gfont-<slug>` id to its (compilation-stable) catalog uuid, or nil."
[gfont-id]
(:uuid (get by-id gfont-id)))
;; --- font-id -> wasm uuid
(def ^:private custom-prefix "custom-")
(def ^:private gfont-prefix "gfont-")
(defn font-id->uuid
"Maps a content font-id to the uuid WASM keys fonts by:
- `gfont-<slug>` -> the catalog uuid,
- `custom-<uuid>` -> that uuid,
- anything else (builtin, unknown, malformed) -> `uuid/zero`, which WASM
resolves to the default font."
[font-id]
(cond
(not (string? font-id))
uuid/zero
(str/starts-with? font-id gfont-prefix)
(or (gfont-id->uuid font-id) uuid/zero)
(str/starts-with? font-id custom-prefix)
(or (uuid/parse* (subs font-id (count custom-prefix))) uuid/zero)
:else
uuid/zero))
;; --- proxy urls
(def ^:private gstatic-prefix
"https://fonts.gstatic.com/s")
(defn gstatic->proxy-url
[s base]
(let [base (str/rtrim (str base) "/")]
(str/replace (str s) gstatic-prefix base)))
;; --- variant resolution
(defn closest-variant
[variants target-weight target-style]
(when-let [target-weight (d/parse-integer target-weight)]
(let [result
(reduce
(fn [closest-match variant]
(let [weight (d/parse-integer (:weight variant))
distance (abs (- target-weight weight))
matches-style? (= target-style (:style variant))
current {:variant variant
:weight weight
:distance distance}]
(cond
;; Exact match found
(and (zero? distance)
(if target-style matches-style? true))
(reduced current)
(nil? closest-match) current
;; Update best match if this variant is closer or equal distance but higher weight
(or (< distance (:distance closest-match))
(and (= distance (:distance closest-match))
(> weight (:weight closest-match))))
current
;; Same weight as the `closest-match` but the style matches `target-style`
(and (= weight (:weight closest-match)) matches-style?)
current
:else
closest-match)))
nil
variants)]
(:variant result))))
(defn resolve-ttf-url
[font-uuid weight style]
(when-let [font (get by-uuid font-uuid)]
(let [style (if (zero? style) "normal" "italic")
variants (:variants font)]
(:ttf-url (or (closest-variant variants weight style)
(first variants))))))

View File

@ -15,12 +15,12 @@
Fully portable (no store/DOM/React), so it runs under Node too."
(:require
[app.common.data :as d]
[app.common.render-wasm.gfonts :as gf]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.gfonts :as gf]
[app.common.uuid :as uuid]
[cuerdas.core :as str]))

View File

@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.render-wasm.gfonts
(ns app.common.types.gfonts
"A fonts loading macros."
(:require

View File

@ -4,13 +4,159 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.render-wasm.fallback-fonts
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
which (google) fallback fonts cover them. Pure data + pure fns — no browser
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the
headless exporter (`app.renderer.wasm`) compute the SAME fallback set from
the same source. Anything a host must fetch/upload for text to render
belongs here, not in host code.")
(ns app.common.types.gfonts
"Host-agnostic font knowledge shared by every renderer: the google catalog
baked at compile time from `common/resources/fonts/gfonts.*.json`, the
font-id/uuid mapping, weight/style variant resolution, and the noto fallback
fonts a text's scripts and emoji need. Also the one family bundled with the
frontend, which is not a google font but resolves by the same rules.
Pure data + pure fns — no browser or Node dependencies — so the workspace and
the headless exporter resolve the SAME fonts from the same source. Anything a
host must fetch or upload for text to render belongs here, not in host code."
(:require-macros [app.common.types.gfonts :refer [preload-gfonts]])
(:require
[app.common.data :as d]
[app.common.uuid :as uuid]
[cuerdas.core :as str]))
;; --- GOOGLE FONTS CATALOG
(def catalog
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(def ^:private by-id
(reduce (fn [m font] (assoc m (:id font) font)) {} catalog))
(def ^:private by-uuid
(reduce (fn [m font] (assoc m (:uuid font) font)) {} catalog))
(defn gfont-id->uuid
"Maps a `gfont-<slug>` id to its (compilation-stable) catalog uuid, or nil."
[gfont-id]
(:uuid (get by-id gfont-id)))
;; --- font-id -> wasm uuid
(def ^:private custom-prefix "custom-")
(def ^:private gfont-prefix "gfont-")
(defn font-id->backend
"Which source a content font-id comes from: `:google` for `gfont-<slug>`,
`:custom` for `custom-<uuid>`, `:builtin` for everything else (bundled
families, but also unknown or malformed ids — the same bucket
`font-id->uuid` maps to `uuid/zero`)."
[font-id]
(cond
(not (string? font-id)) :builtin
(str/starts-with? font-id gfont-prefix) :google
(str/starts-with? font-id custom-prefix) :custom
:else :builtin))
(defn font-id->uuid
"Maps a content font-id to the uuid WASM keys fonts by:
- `gfont-<slug>` -> the catalog uuid,
- `custom-<uuid>` -> that uuid,
- anything else (builtin, unknown, malformed) -> `uuid/zero`, which WASM
resolves to the default font."
[font-id]
(case (font-id->backend font-id)
:google (or (gfont-id->uuid font-id) uuid/zero)
:custom (or (uuid/parse* (subs font-id (count custom-prefix))) uuid/zero)
uuid/zero))
;; --- proxy urls
(def ^:private gstatic-prefix
"https://fonts.gstatic.com/s")
(defn gstatic->proxy-url
[s base]
(let [base (str/rtrim (str base) "/")]
(str/replace (str s) gstatic-prefix base)))
;; --- variant resolution
(defn closest-variant
[variants target-weight target-style]
(when-let [target-weight (d/parse-integer target-weight)]
(let [result
(reduce
(fn [closest-match variant]
(let [weight (d/parse-integer (:weight variant))
distance (abs (- target-weight weight))
matches-style? (= target-style (:style variant))
current {:variant variant
:weight weight
:distance distance}]
(cond
;; Exact match found
(and (zero? distance)
(if target-style matches-style? true))
(reduced current)
(nil? closest-match) current
;; Update best match if this variant is closer or equal distance but higher weight
(or (< distance (:distance closest-match))
(and (= distance (:distance closest-match))
(> weight (:weight closest-match))))
current
;; Same weight as the `closest-match` but the style matches `target-style`
(and (= weight (:weight closest-match)) matches-style?)
current
:else
closest-match)))
nil
variants)]
(:variant result))))
(defn resolve-ttf-url
[font-uuid weight style]
(when-let [font (get by-uuid font-uuid)]
(let [style (if (zero? style) "normal" "italic")
variants (:variants font)]
(:ttf-url (or (closest-variant variants weight style)
(first variants))))))
;; --- BUILTIN FONTS
;;
;; Bundled with the frontend, served from `<public-uri>/fonts/`. Shared so the
;; workspace and the exporter upload the same TTF for a given weight/style.
(def local-fonts
[{:id "sourcesanspro"
:name "Source Sans Pro"
:family "sourcesanspro"
:variants
[{:id "200" :name "200" :weight "200" :style "normal" :suffix "extralight" :ttf-url "sourcesanspro-extralight.ttf"}
{:id "200italic" :name "200 Italic" :weight "200" :style "italic" :suffix "extralightitalic" :ttf-url "sourcesanspro-extralightitalic.ttf"}
{:id "300" :name "300" :weight "300" :style "normal" :suffix "light" :ttf-url "sourcesanspro-light.ttf"}
{:id "300italic" :name "300 Italic" :weight "300" :style "italic" :suffix "lightitalic" :ttf-url "sourcesanspro-lightitalic.ttf"}
{:id "regular" :name "400" :weight "400" :style "normal" :ttf-url "sourcesanspro-regular.ttf"}
{:id "italic" :name "400 Italic" :weight "400" :style "italic" :ttf-url "sourcesanspro-italic.ttf"}
{:id "600" :name "600" :weight "600" :style "normal" :suffix "semibold" :ttf-url "sourcesanspro-semibold.ttf"}
{:id "600italic" :name "600 Italic" :weight "600" :style "italic" :suffix "semibolditalic" :ttf-url "sourcesanspro-semibolditalic.ttf"}
{:id "bold" :name "700" :weight "700" :style "normal" :ttf-url "sourcesanspro-bold.ttf"}
{:id "bolditalic" :name "700 Italic" :weight "700" :style "italic" :ttf-url "sourcesanspro-bolditalic.ttf"}
{:id "black" :name "900" :weight "900" :style "normal" :ttf-url "sourcesanspro-black.ttf"}
{:id "blackitalic" :name "900 Italic" :weight "900" :style "italic" :ttf-url "sourcesanspro-blackitalic.ttf"}]}])
(defn resolve-ttf-file
"Builtin TTF file name for `weight` and `style` (0 normal, 1 italic), by the
same nearest-weight rule as the google catalog."
[weight style]
(let [variants (:variants (first local-fonts))]
(:ttf-url (or (closest-variant variants weight (if (zero? style) "normal" "italic"))
(first variants)))))
;; --- FALLBACK FONTS
;;
;; Which scripts/emoji a text uses and which (google) fallback fonts cover them.
(def ^:private emoji-pattern
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")

View File

@ -4,12 +4,12 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.common.render-wasm.resources
(ns app.common.types.shape.images
"Host-agnostic enumeration of the external resources a scene needs to
render: which image bytes its shapes reference. Pure data walking — no
browser or Node dependencies — so the workspace and the headless exporter
derive the same set from the same source (sibling of
`app.common.render-wasm.fallback-fonts`, which does the same for fonts)."
derive the same set from the same source (counterpart of
`app.common.types.gfonts`, which does the same for fonts)."
(:require
[app.common.types.fills :as types.fills]))

View File

@ -25,15 +25,13 @@
[app.common.geom.point]
[app.common.geom.rect]
[app.common.logging :as l]
[app.common.render-wasm.builtin-fonts :as bfonts]
[app.common.render-wasm.fallback-fonts :as fbf]
[app.common.render-wasm.gfonts :as gf]
[app.common.render-wasm.resources :as resources]
[app.common.transit :as t]
[app.common.types.fills.impl]
[app.common.types.gfonts :as gf]
[app.common.types.objects-map]
[app.common.types.path.impl]
[app.common.types.shape]
[app.common.types.shape.images :as images]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
@ -168,8 +166,8 @@
;;
;; The text serializer keeps each font's real uuid, so `wasm/fonts-for-shape`
;; reports it. Custom (team) fonts resolve through the file's font variants,
;; google fonts through the shared `app.common.render-wasm.gfonts` catalog; builtin fonts
;; through `app.common.render-wasm.builtin-fonts` + the frontend's static `/fonts/`.
;; google fonts through the shared `app.common.types.gfonts` catalog; builtin
;; fonts through its bundled family + the frontend's static `/fonts/`.
(defn- fetch-font-variants
"Team (custom) font variants for the file, or nil — a failure here degrades
@ -253,7 +251,7 @@
(fetch-asset-bytes (:ttf-file-id variant) params)
(= uuid/zero font-uuid)
(fetch-builtin-font-bytes (bfonts/resolve-ttf-file weight style))
(fetch-builtin-font-bytes (gf/resolve-ttf-file weight style))
:else
(if-let [gurl (gf/resolve-ttf-url font-uuid weight style)]
@ -278,11 +276,11 @@
:let [text (:text node)]
:when (string? text)]
text)
emoji? (boolean (some fbf/contains-emoji? texts))
langs (reduce fbf/collect-used-languages #{} texts)]
emoji? (boolean (some gf/contains-emoji? texts))
langs (reduce gf/collect-used-languages #{} texts)]
(distinct
(cond-> (fbf/add-noto-fonts [] langs)
emoji? (fbf/add-emoji-font)))))
(cond-> (gf/add-noto-fonts [] langs)
emoji? (gf/add-emoji-font)))))
(defn- fetch-fallback-font-bytes
"Downloads one fallback font's TTF. Cached by the whole variant, not just
@ -341,11 +339,11 @@
(defn- provision-images!
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.render-wasm.resources`). Unlike fonts,
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
and repeated exports of a file reuse them."
[scene params]
(let [all-ids (resources/scene-image-ids scene)
(let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)

View File

@ -10,8 +10,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.common.render-wasm.builtin-fonts :as bfonts]
[app.common.render-wasm.gfonts :as gf]
[app.common.types.gfonts :as gf]
[app.common.types.text :as txt]
[app.common.uri :as u]
[app.config :as cf]
@ -26,10 +25,6 @@
(log/set-level! :warn)
(def google-fonts gf/catalog)
(def local-fonts bfonts/local-fonts)
(defonce fontsdb (l/atom {}))
(defonce fonts (l/atom []))
@ -49,10 +44,10 @@
fonts (map #(assoc % :backend backend) fonts)]
(merge db (d/index-by :id fonts))))))
(register! :builtin local-fonts)
(register! :builtin gf/local-fonts)
(when (contains? cf/flags :google-fonts-provider)
(register! :google google-fonts))
(register! :google gf/catalog))
(defn get-font-data [id]
(get @fontsdb id))
@ -382,7 +377,7 @@
"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.render-wasm.gfonts` so the headless exporter resolves the
The algorithm lives in `app.common.types.gfonts` so the headless exporter resolves the
same variant for the same text."
[font target-weight target-style]
(gf/closest-variant (:variants font []) target-weight target-style))

View File

@ -25,6 +25,7 @@
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
[app.common.types.gfonts :as gf]
[app.common.types.path :as path]
[app.common.types.path.impl :as path.impl]
[app.common.types.shape.layout :as ctl]
@ -1289,8 +1290,8 @@
langs)
(let [text (apply str (map :text spans))
emoji? (if emoji? emoji? (t/contains-emoji? text))
langs (t/collect-used-languages langs text)]
emoji? (if emoji? emoji? (gf/contains-emoji? text))
langs (gf/collect-used-languages langs text)]
;; FIXME: this should probably be somewhere else
(when fallback-fonts-only? (t/write-shape-text spans paragraph text))
@ -1301,8 +1302,8 @@
(let [updated-fonts
(-> #{}
(cond-> ^boolean emoji? (f/add-emoji-font))
(f/add-noto-fonts langs))
(cond-> ^boolean emoji? (gf/add-emoji-font))
(gf/add-noto-fonts langs))
fallback-fonts (filter #(get % :is-fallback) updated-fonts)]
(if fallback-fonts-only? updated-fonts fallback-fonts))))))

View File

@ -9,10 +9,9 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.common.render-wasm.fallback-fonts :as fbf]
[app.common.render-wasm.gfonts :as gf]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.gfonts :as gf]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
@ -40,16 +39,6 @@
(def ^:private default-line-height 1.2)
(def ^:private default-letter-spacing 0.0)
(defn- font-backend
[font-id]
(cond
(str/starts-with? font-id "gfont-")
:google
(str/starts-with? font-id "custom-")
:custom
:else
:builtin))
(defn- font-db-data
[font-id font-variant-id font-weight-fallback font-style-fallback]
(let [font (fonts/get-font-data font-id)
@ -75,7 +64,7 @@
"regular")))
(defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style]
(case (font-backend font-id)
(case (gf/font-id->backend font-id)
:google
font-id
:custom
@ -174,7 +163,7 @@
(defn- font-id->ttf-url
[font-id asset-id font-variant-id font-weight font-style]
(case (font-backend font-id)
(case (gf/font-id->backend font-id)
:google
(google-font-ttf-url font-id font-variant-id font-weight font-style)
:custom
@ -377,7 +366,3 @@
(defn store-fonts
[fonts]
(keep (fn [font] (store-font font)) fonts))
(def add-emoji-font fbf/add-emoji-font)
(def noto-fonts fbf/noto-fonts)
(def add-noto-fonts fbf/add-noto-fonts)

View File

@ -6,7 +6,6 @@
(ns app.render-wasm.api.texts
(:require
[app.common.render-wasm.fallback-fonts :as fbf]
[app.common.render-wasm.text-content :as tc]
[app.render-wasm.api.fonts :as f]))
@ -17,9 +16,3 @@
(tc/write-shape-text! spans paragraph text
{:normalize-paragraph f/normalize-paragraph-font
:normalize-span f/normalize-span-font}))
;; Emoji/script detection lives in the host-agnostic
;; `app.common.render-wasm.fallback-fonts`; kept re-exported here for existing
;; workspace callers.
(def contains-emoji? fbf/contains-emoji?)
(def collect-used-languages fbf/collect-used-languages)