🔧 Add exporter headless backend (#10875)

*  Add headless wasm render backend to the exporter

* ♻️ Move render-wasm bridge to common and split wasm builds

* 🔧 Upload builtin font variants in the wasm exporter

* ♻️ Move shared font and resources utils out of render_wasm

*  Fetch only the exported roots in the wasm exporter

*  Bound save_layer rects in the vector export path
This commit is contained in:
Elena Torró 2026-08-06 16:13:06 +02:00 committed by GitHub
parent a76401596e
commit 38b990ef90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 1475 additions and 320 deletions

2
.gitignore vendored
View File

@ -58,6 +58,8 @@ opencode.json
/docker/images/bundle* /docker/images/bundle*
/exporter/target /exporter/target
/exporter/.shadow-cljs /exporter/.shadow-cljs
/exporter/resources/wasm/
/exporter/src/app/wasm/shared.js
/frontend/.storybook/preview-body.html /frontend/.storybook/preview-body.html
/frontend/.storybook/preview-head.html /frontend/.storybook/preview-head.html
/frontend/playwright-report/ /frontend/playwright-report/

View File

@ -13,6 +13,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
# PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by # PENPOT_DATABASE_*, PENPOT_REDIS_URI, PENPOT_OBJECTS_STORAGE_*, AWS_*) is owned by
# docker/devenv/defaults.env and injected via the main service's env block. # docker/devenv/defaults.env and injected via the main service's env block.
if [ -f /home/selfsigned.crt ]; then
export NODE_EXTRA_CA_CERTS=/home/selfsigned.crt;
fi
# Background worker flag is per-instance. Defaults to enabled (ws0); ws1+ # Background worker flag is per-instance. Defaults to enabled (ws0); ws1+
# overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only # overlays set PENPOT_BACKEND_WORKER=false so scheduled and async tasks only
# run on ws0, keeping notification Pub/Sub bound to a single Valkey. See # run on ws0, keeping notification Pub/Sub bound to a single Valkey. See
@ -101,5 +105,3 @@ function setup_minio() {
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -q
mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q mc mb "penpot-s3/${PENPOT_OBJECTS_STORAGE_S3_BUCKET}" -p -q
} }

View File

@ -55,7 +55,7 @@
io.aviso/pretty {:mvn/version "1.4.4"} io.aviso/pretty {:mvn/version "1.4.4"}
environ/environ {:mvn/version "1.2.0"}} environ/environ {:mvn/version "1.2.0"}}
:paths ["src" "vendor" "target/classes"] :paths ["src" "vendor" "resources" "target/classes"]
:aliases :aliases
{:dev {:dev
{:extra-deps {:extra-deps

View File

@ -178,6 +178,9 @@
:stroke-path :stroke-path
:stroke-per-side :stroke-per-side
;; Exporter only: uses render-wasm for export instead of browser
;; renderer.
:wasm-export
:custom-shortcuts :custom-shortcuts
:remote-media-processing}) :remote-media-processing})

View File

@ -4,8 +4,9 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.fonts (ns app.common.fonts
"A fonts loading macros." "A fonts loading macros."
(:require (:require
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[clojure.data.json :as json] [clojure.data.json :as json]
@ -47,6 +48,3 @@
(let [data (slurp (io/resource path)) (let [data (slurp (io/resource path))
data (json/read-str data)] data (json/read-str data)]
`~(mapv parse-gfont (get data "items")))) `~(mapv parse-gfont (get data "items"))))

View File

@ -4,13 +4,159 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.fallback-fonts (ns app.common.fonts
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and "Host-agnostic font knowledge shared by every renderer: the google catalog
which (google) fallback fonts cover them. Pure data + pure fns — no browser baked at compile time from `common/resources/fonts/gfonts.*.json`, the
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the font-id/uuid mapping, weight/style variant resolution, and the noto fallback
headless exporter (`app.renderer.wasm`) compute the SAME fallback set from fonts a text's scripts and emoji need. Also the one family bundled with the
the same source. Anything a host must fetch/upload for text to render frontend, which is not a google font but resolves by the same rules.
belongs here, not in host code.")
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.fonts :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 (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]") #"(?:\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

@ -0,0 +1,24 @@
# `app.common.render-wasm.*`
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.
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.fonts`. 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`).
## Rules for anything added here
**Nothing here may depend on a browser (no DOM, no WebGL, no app state) or on
`frontend/src`.** Dependencies are `app.common.*` and this subtree only. It also
has to run under plain Node — a `js/document` here breaks the exporter.

View File

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.api.props (ns app.common.render-wasm.api.props
"Browser-free WASM shape property setters, shared by the workspace render "Browser-free WASM shape property setters, shared by the workspace render
orchestrator (`app.render-wasm.api`) and the headless exporter orchestrator (`app.render-wasm.api`) and the headless exporter
(`app.wasm.serialize`). (`app.wasm.serialize`).
@ -15,15 +15,15 @@
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`." data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
(:require (:require
[app.common.math :as mth] [app.common.math :as mth]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.mem.heap32 :as mem.h32]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills :as types.fills] [app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl] [app.common.types.fills.impl :as types.fills.impl]
[app.common.types.path :as path] [app.common.types.path :as path]))
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.mem.heap32 :as mem.h32]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]))
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024)) (def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))

View File

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.api.shapes (ns app.common.render-wasm.api.shapes
"Batched shape property serialization for improved WASM performance. "Batched shape property serialization for improved WASM performance.
This module provides a single WASM call to set all base shape properties, This module provides a single WASM call to set all base shape properties,
@ -13,11 +13,11 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.uuid :as uuid] [app.common.render-wasm.helpers :as h]
[app.render-wasm.helpers :as h] [app.common.render-wasm.mem :as mem]
[app.render-wasm.mem :as mem] [app.common.render-wasm.serializers :as sr]
[app.render-wasm.serializers :as sr] [app.common.render-wasm.wasm :as wasm]
[app.render-wasm.wasm :as wasm])) [app.common.uuid :as uuid]))
;; Binary layout constants matching Rust implementation: ;; Binary layout constants matching Rust implementation:
;; ;;

View File

@ -0,0 +1,54 @@
;; 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.enums
"Serializer enum table from `shared.js`")
(def ^:private serializer-exports
[["raster-format" "RasterFormat"]
["blur-type" "RawBlurType"]
["blend-mode" "RawBlendMode"]
["bool-type" "RawBoolType"]
["font-style" "RawFontStyle"]
["flex-direction" "RawFlexDirection"]
["grid-direction" "RawGridDirection"]
["grow-type" "RawGrowType"]
["align-items" "RawAlignItems"]
["align-self" "RawAlignSelf"]
["align-content" "RawAlignContent"]
["justify-items" "RawJustifyItems"]
["justify-content" "RawJustifyContent"]
["justify-self" "RawJustifySelf"]
["wrap-type" "RawWrapType"]
["grid-track-type" "RawGridTrackType"]
["shadow-style" "RawShadowStyle"]
["guide-kind" "RawGuideKind"]
["stroke-style" "RawStrokeStyle"]
["stroke-cap" "RawStrokeCap"]
["shape-type" "RawShapeType"]
["constraint-h" "RawConstraintH"]
["constraint-v" "RawConstraintV"]
["sizing" "RawSizing"]
["vertical-align" "RawVerticalAlign"]
["fill-data" "RawFillData"]
["text-align" "RawTextAlign"]
["text-direction" "RawTextDirection"]
["text-decoration" "RawTextDecoration"]
["text-transform" "RawTextTransform"]
["multiple-state" "MultipleState"]
["transform-entry-kind" "RawTransformEntryKind"]
["segment-data" "RawSegmentData"]
["stroke-linecap" "RawStrokeLineCap"]
["stroke-linejoin" "RawStrokeLineJoin"]
["fill-rule" "RawFillRule"]])
(defmacro serializers
[alias]
(let [alias (name alias)]
`(cljs.core/js-obj
~@(mapcat (fn [[key export]]
[key (symbol alias export)])
serializer-exports))))

View File

@ -4,8 +4,8 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.helpers (ns app.common.render-wasm.helpers
#?(:cljs (:require-macros [app.render-wasm.helpers])) #?(:cljs (:require-macros [app.common.render-wasm.helpers]))
(:require [app.common.data :as d])) (:require [app.common.data :as d]))
(def error-code (def error-code

View File

@ -4,11 +4,11 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.mem (ns app.common.render-wasm.mem
(:require (:require
[app.common.buffer :as buf] [app.common.buffer :as buf]
[app.render-wasm.helpers :as h] [app.common.render-wasm.helpers :as h]
[app.render-wasm.wasm :as wasm])) [app.common.render-wasm.wasm :as wasm]))
(defn ->offset-32 (defn ->offset-32
"Convert a 8-bit (1 byte) offset to a 32-bit (4 bytes) offset" "Convert a 8-bit (1 byte) offset to a 32-bit (4 bytes) offset"

View File

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.mem.heap32 (ns app.common.render-wasm.mem.heap32
"A memory write helpers that uses 32 bits addressed offsets." "A memory write helpers that uses 32 bits addressed offsets."
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]

View File

@ -4,7 +4,7 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.serialize-shape (ns app.common.render-wasm.serialize-shape
"Single source of truth for the host-independent part of serializing a whole "Single source of truth for the host-independent part of serializing a whole
shape into the WASM design state. shape into the WASM design state.
@ -24,8 +24,8 @@
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
dispatching per changed key through the same underlying `props` setters." dispatching per changed key through the same underlying `props` setters."
(:require (:require
[app.render-wasm.api.props :as props] [app.common.render-wasm.api.props :as props]
[app.render-wasm.api.shapes :as shapes])) [app.common.render-wasm.api.shapes :as shapes]))
(defn serialize-shape! (defn serialize-shape!
"Applies every host-independent WASM property of `shape`. `set-shape-base-props` "Applies every host-independent WASM property of `shape`. `set-shape-base-props`

View File

@ -4,16 +4,16 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.serializers (ns app.common.render-wasm.serializers
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as clr] [app.common.types.color :as clr]
[app.common.types.shape-tree :as ctst] [app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(defn u8 (defn u8

View File

@ -1,4 +1,4 @@
(ns app.render-wasm.serializers.color (ns app.common.render-wasm.serializers.color
(:require (:require
[app.common.math :as mth])) [app.common.math :as mth]))

View File

@ -4,23 +4,24 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.text-content (ns app.common.render-wasm.text-content
"Single source of truth for writing a text shape's content into the WASM design "Single source of truth for writing a text shape's content into the WASM design
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
identical for the workspace and the headless exporter — only *font resolution* identical for the workspace and the headless exporter, and so is the font-id
differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts -> uuid mapping (`cfnt/font-id->uuid`). Only *variant* resolution differs —
catalog + custom variants). So the byte-writing lives here and font resolution the workspace has a loaded fonts DB, the exporter does not — so that part is
is injected via the `opts` map passed to `write-shape-text!`. injected via the `opts` map passed to `write-shape-text!`.
Fully portable (no store/DOM/React), so it runs under Node too." Fully portable (no store/DOM/React), so it runs under Node too."
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.fonts :as cfnt]
[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.fills.impl :as types.fills.impl]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12) (def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
@ -169,13 +170,15 @@
"Writes one paragraph's spans + text into WASM and appends it to the current "Writes one paragraph's spans + text into WASM and appends it to the current
shape via `_set_shape_text_content`. shape via `_set_shape_text_content`.
`opts` injects host-specific font resolution: `opts` injects host-specific font handling:
- `:normalize-font-id` (string font-id -> wasm uuid) — required in practice, - `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared
`cfnt/font-id->uuid`, which is what both hosts want — a host only
overrides it if it keys its font store some other way,
- `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a - `:normalize-paragraph`/`:normalize-span` — font-variant normalization from a
fonts DB (workspace); default to identity (the exporter resolves variants fonts DB (workspace); default to identity (the exporter resolves variants
differently / not at all)." differently / not at all)."
[spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span] [spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span]
:or {normalize-font-id identity :or {normalize-font-id cfnt/font-id->uuid
normalize-paragraph identity normalize-paragraph identity
normalize-span (fn [span _paragraph] span)}}] normalize-span (fn [span _paragraph] span)}}]
(let [paragraph (normalize-paragraph paragraph) (let [paragraph (normalize-paragraph paragraph)

View File

@ -4,8 +4,7 @@
;; ;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.wasm (ns app.common.render-wasm.wasm)
(:require ["./api/shared.js" :as shared]))
(defonce internal-frame-id nil) (defonce internal-frame-id nil)
(defonce internal-frame-type 0) (defonce internal-frame-type 0)
@ -65,41 +64,19 @@
(set! gl-context nil) (set! gl-context nil)
(set! context-initialized? false)) (set! context-initialized? false))
(defonce serializers (defonce serializers nil)
#js {:raster-format shared/RasterFormat
:blur-type shared/RawBlurType (defn init-serializers!
:blend-mode shared/RawBlendMode "Binds the enum table produced by the `enums/serializers` macro."
:bool-type shared/RawBoolType [table]
:font-style shared/RawFontStyle (let [missing (array)]
:flex-direction shared/RawFlexDirection (doseq [key (js/Object.keys table)]
:grid-direction shared/RawGridDirection (when (undefined? (unchecked-get table key))
:grow-type shared/RawGrowType (.push missing key)))
:align-items shared/RawAlignItems
:align-self shared/RawAlignSelf (when (pos? (alength missing))
:align-content shared/RawAlignContent (throw (ex-info "stale or incomplete render-wasm shared.js"
:justify-items shared/RawJustifyItems {:missing (vec missing)})))
:justify-content shared/RawJustifyContent
:justify-self shared/RawJustifySelf (set! serializers table)))
:wrap-type shared/RawWrapType
:grid-track-type shared/RawGridTrackType
:shadow-style shared/RawShadowStyle
:guide-kind shared/RawGuideKind
:stroke-style shared/RawStrokeStyle
:stroke-cap shared/RawStrokeCap
:shape-type shared/RawShapeType
:constraint-h shared/RawConstraintH
:constraint-v shared/RawConstraintV
:sizing shared/RawSizing
:vertical-align shared/RawVerticalAlign
:fill-data shared/RawFillData
:text-align shared/RawTextAlign
:text-direction shared/RawTextDirection
:text-decoration shared/RawTextDecoration
:text-transform shared/RawTextTransform
:multiple-state shared/MultipleState
:transform-entry-kind shared/RawTransformEntryKind
:segment-data shared/RawSegmentData
:stroke-linecap shared/RawStrokeLineCap
:stroke-linejoin shared/RawStrokeLineJoin
:fill-rule shared/RawFillRule})

View File

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

View File

@ -33,6 +33,7 @@
"watch:app": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main", "watch:app": "pnpm run clear:shadow-cache && clojure -M:dev:shadow-cljs watch main",
"watch": "pnpm run watch:app", "watch": "pnpm run watch:app",
"build:app": "clojure -M:dev:shadow-cljs release main", "build:app": "clojure -M:dev:shadow-cljs release main",
"build:wasm": "../render-wasm/build export",
"build": "pnpm run clear:shadow-cache && pnpm run build:app", "build": "pnpm run clear:shadow-cache && pnpm run build:app",
"fmt": "cljfmt fix --parallel=true src/", "fmt": "cljfmt fix --parallel=true src/",
"check-fmt": "cljfmt check --parallel=true src/", "check-fmt": "cljfmt check --parallel=true src/",

View File

@ -8,6 +8,17 @@ export NODE_ENV=production;
corepack enable; corepack enable;
corepack install || exit 1; corepack install || exit 1;
pnpm install || exit 1; pnpm install || exit 1;
pnpm run build:wasm;
WASM_SRC="resources/wasm";
WASM_SHARED="src/app/wasm/shared.js";
if [ ! -f "$WASM_SRC/render-wasm.wasm" ] || [ ! -f "$WASM_SHARED" ]; then
echo "ERROR: the render-wasm build did not produce:" >&2;
echo " $WASM_SRC/render-wasm.wasm" >&2;
echo " $WASM_SHARED" >&2;
exit 1;
fi
rm -rf target rm -rf target
# Build the application # Build the application
@ -18,6 +29,9 @@ cp pnpm-workspace.yaml target/;
cp package.json target/; cp package.json target/;
touch target/pnpm-workspace.yaml; touch target/pnpm-workspace.yaml;
mkdir -p target/$WASM_SRC;
cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/$WASM_SRC/;
cat <<EOF | tee target/setup cat <<EOF | tee target/setup
#/usr/bin/env bash #/usr/bin/env bash
set -e; set -e;

View File

@ -12,6 +12,7 @@
[app.config :as cf] [app.config :as cf]
[app.http :as http] [app.http :as http]
[app.redis :as redis] [app.redis :as redis]
[app.wasm :as wasm]
[promesa.core :as p])) [promesa.core :as p]))
(enable-console-print!) (enable-console-print!)
@ -23,6 +24,12 @@
:public-uri (str (cf/get :public-uri)) :public-uri (str (cf/get :public-uri))
:internal-uri (str (cf/get-internal-uri)) :internal-uri (str (cf/get-internal-uri))
:version (:full cf/version)) :version (:full cf/version))
(when (contains? cf/flags :wasm-export)
(l/warn :msg "headless wasm export enabled (experimental)"
:hint (str "renders run in-process on a single shared wasm module, "
"one at a time; not recommended for busy instances")
:wasm-dir wasm/artifact-dir
:image-cache-mb wasm/image-cache-mb))
(p/do! (p/do!
(bwr/init) (bwr/init)
(redis/init) (redis/init)

View File

@ -7,10 +7,13 @@
(ns app.renderer (ns app.renderer
"Common renderer interface." "Common renderer interface."
(:require (:require
[app.common.logging :as l]
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cf]
[app.renderer.bitmap :as rb] [app.renderer.bitmap :as rb]
[app.renderer.pdf :as rp] [app.renderer.pdf :as rp]
[app.renderer.svg :as rs] [app.renderer.svg :as rs]
[app.renderer.wasm :as rw]
[cljs.spec.alpha :as s])) [cljs.spec.alpha :as s]))
(s/def ::name ::us/string) (s/def ::name ::us/string)
@ -36,13 +39,22 @@
:opt-un [::is-wasm])) :opt-un [::is-wasm]))
(defn render (defn render
[{:keys [type] :as params} on-object] [{:keys [type is-wasm] :as params} on-object]
(us/verify ::render-params params) (us/verify ::render-params params)
(us/verify fn? on-object) (us/verify fn? on-object)
(case type (let [wasm-export? (contains? cf/flags :wasm-export)
:png (rb/render params on-object) headless? (and is-wasm wasm-export? (not= :svg type))]
:jpeg (rb/render params on-object) (when is-wasm
:webp (rb/render params on-object) (l/info :hint "render"
:pdf (rp/render params on-object) :type type
:svg (rs/render params on-object))) :wasm-export wasm-export?
:backend (if headless? "wasm" "browser")))
(if headless?
(rw/render params on-object)
(case type
:png (rb/render params on-object)
:jpeg (rb/render params on-object)
:webp (rb/render params on-object)
:pdf (rp/render params on-object)
:svg (rs/render params on-object)))))

View File

@ -0,0 +1,451 @@
;; 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.renderer.wasm
"Headless renderer backend: renders exports with the render-wasm Skia
pipeline in this Node process, with no browser and no WebGL.
Per request: fetch scene (get-page RPC) -> serialize -> provision fonts and
images -> relayout text with the real fonts -> render each object.
One shared WASM design state, so requests are serialized one at a time.
Handles png/jpeg/webp (Skia encodes all three) and pdf; `:svg` stays on the
browser path."
(:require
["node:fs" :as fs]
["undici" :as http]
[app.common.data :as d]
[app.common.fonts :as cfnt]
;; Required for side effects: these register the transit read handlers and
;; deftype impls the `get-page` response is decoded into.
[app.common.geom.matrix]
[app.common.geom.point]
[app.common.geom.rect]
[app.common.logging :as l]
[app.common.transit :as t]
[app.common.types.fills.impl]
[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]
[app.util.mime :as mime]
[app.util.shell :as sh]
[app.wasm :as wasm]
[app.wasm.serialize :as serialize]
[cuerdas.core :as str]
[promesa.core :as p]))
;; --- module lifecycle (one shared, lazily-initialized instance)
(defonce ^:private module* (atom nil))
(defn- ensure-module!
[]
(or @module*
(reset! module* (wasm/init!))))
;; --- serialized access to the shared module
;;
;; `handle-multiple-export` fans out partitions concurrently, but there is one
;; design state and one global mem buffer, so their serialize/render/alloc must
;; not interleave.
(defonce ^:private queue (atom (p/resolved nil)))
(defn- enqueue!
"Runs `thunk` (0-arg, returns a promise) only after all previously enqueued
work has settled. Returns `thunk`'s promise. A task's failure is isolated:
it doesn't break the chain for the next task."
[thunk]
(let [result (p/handle @queue (fn [_ _] (thunk)))]
(reset! queue (p/handle result (fn [_ _] nil)))
result))
;; --- backend endpoints
;;
;; Every fetch targets the internal endpoint (falling back to public-uri),
;; in a deployment the exporter reaches the backend over the container network
(defn- internal-uri
"Absolute URI for `path` on the internal (backend) endpoint."
[path]
(-> (cf/get-internal-uri)
(u/ensure-path-slash)
(u/join path)
(str)))
(defn- error-detail
"Node's fetch reports every transport failure as a bare `TypeError: fetch
failed`; the actual reason (TLS rejection, DNS, ECONNREFUSED) is buried in a
nested `cause` chain that the logger does not print. Flattens the chain into
one readable string."
[cause]
(->> (iterate (fn [^js e] (unchecked-get e "cause")) cause)
(take-while some?)
(take 5)
(map (fn [^js e]
(let [code (unchecked-get e "code")
msg (or (unchecked-get e "message") (str e))]
(if code (str code ": " msg) msg))))
(str/join " <- ")))
(defn- fetch!
"`undici/fetch` that fails with an ex-info carrying the target uri and the
unwrapped cause chain, so a failed request says what actually went wrong and
against which endpoint."
[uri opts]
(->> (p/do (http/fetch uri opts))
(p/merr (fn [cause]
(p/rejected (ex-info "http fetch failed"
{:uri uri :detail (error-detail cause)}
cause))))))
(defn- explain
"Log-friendly reason for `cause`: the detail `fetch!` already attached, or a
freshly unwrapped chain for anything else (WASM aborts, decode errors)."
[cause]
(or (:detail (ex-data cause))
(error-detail cause)))
(defn- rpc-headers
"Auth headers for backend RPC calls (management key + bearer)."
[token]
#js {"Content-Type" "application/transit+json"
"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (str "Bearer " token)})
(defn- asset-headers
"Auth headers for `/assets/*`. Cookie, not Bearer: those endpoints redirect to
a presigned S3/minio URL, and a Bearer header makes S3 400 (\"multiple
authentication types\")."
[token]
#js {"X-Shared-Key" (str "exporter " cf/management-key)
"Cookie" (str "auth-token=" token)})
;; --- shape bundle fetch (backend RPC)
(defn- fetch-objects
"Fetches the exported roots and their children from the backend via the
`get-page` RPC (`:object-id`, as the browser render path does), using the
same auth the exporter uses elsewhere (management key + bearer)."
[{:keys [file-id page-id share-id token objects]}]
(let [headers (rpc-headers token)
root-ids (into #{} (map :id) objects)
body (t/encode-str (cond-> {:file-id file-id
:page-id page-id}
(seq root-ids) (assoc :object-id root-ids)
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-page")]
(l/dbg :hint "wasm render: get-page"
:uri uri
:file-id (str file-id)
:page-id (str page-id)
:roots (count root-ids))
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(->> (.text resp)
(p/mcat (fn [resp-body]
(l/error :hint "wasm render: get-page failed"
:uri uri
:status (.-status resp)
:body resp-body)
(p/rejected (ex-info "get-page failed"
{:status (.-status resp)
:body resp-body}))))))))
(p/fmap t/decode-str)
(p/fmap :objects))))
;; --- font resolution
;;
;; 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.fonts` 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
to fallback fonts, it does not fail the export."
[{:keys [file-id share-id token]}]
(let [headers (rpc-headers token)
body (t/encode-str (cond-> {:file-id file-id}
share-id (assoc :share-id share-id)))
uri (internal-uri "api/rpc/command/get-font-variants")]
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
(p/resolved nil))))
(p/fmap (fn [s] (when s (t/decode-str s))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: get-font-variants failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- fetch-ttf-bytes
"Downloads a TTF, returning a promise of an ArrayBuffer (or nil). A failure
here degrades to fallback fonts, it does not fail the export."
([uri] (fetch-ttf-bytes uri #js {:method "GET"}))
([uri opts]
(->> (fetch! uri opts)
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(p/resolved nil))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: font fetch failed"
:uri uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
;; TTF bytes cached for the process lifetime, keyed by whatever identifies the
;; variant (a gfont id+weight+style, a builtin file name).
(defonce ^:private font-bytes* (atom {}))
(defn- cached-ttf-bytes
[cache-key fetch-fn]
(if-let [bytes (get @font-bytes* cache-key)]
(p/resolved bytes)
(->> (fetch-fn)
(p/fmap (fn [buf]
(when buf (swap! font-bytes* assoc cache-key buf))
buf)))))
(defn- fetch-asset-bytes
[asset-id {:keys [token]}]
(fetch-ttf-bytes (internal-uri (str "assets/by-id/" asset-id))
#js {:method "GET" :headers (asset-headers token)}))
(defn- fetch-gfont-bytes
[ttf-url]
(fetch-ttf-bytes (cfnt/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font"))))
(defn- fetch-builtin-font-bytes
[ttf-file]
(cached-ttf-bytes ttf-file #(fetch-ttf-bytes (internal-uri (str "fonts/" ttf-file)))))
(defn- make-resolve-font
"Builds a `resolve-font` fn (family map -> promise of TTF bytes). Custom
variants first, matching uuid+weight+style then degrading to uuid+weight then
uuid; the bundled fonts for `uuid/zero`, which is what `font-id->uuid` maps
every builtin family to; google catalog otherwise."
[variants params]
(fn [{:keys [id weight style]}]
(let [font-uuid (uuid/from-unsigned-parts (aget id 0) (aget id 1) (aget id 2) (aget id 3))
style-str (if (zero? style) "normal" "italic")
variant (or (d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)
(= (name (:font-style v)) style-str)))
variants)
(d/seek (fn [v] (and (= (:font-id v) font-uuid)
(= (:font-weight v) weight)))
variants)
(d/seek (fn [v] (= (:font-id v) font-uuid)) variants))]
(cond
(:ttf-file-id variant)
(fetch-asset-bytes (:ttf-file-id variant) params)
(= uuid/zero font-uuid)
(fetch-builtin-font-bytes (cfnt/resolve-ttf-file weight style))
:else
(if-let [gurl (cfnt/resolve-ttf-url font-uuid weight style)]
(fetch-gfont-bytes gurl)
(p/resolved nil))))))
;; --- fallback fonts (emoji + per-script noto fonts)
;;
;; Emoji and non-latin scripts render through fallback families, not through
;; any span's font family, so `wasm/fonts-for-shape` never reports them and the
;; provisioning above never uploads them. Must run per request, since
;; `clear-fonts!` empties the store; the TTF bytes stay cached per process.
(defn- scene-fallback-fonts
"Fallback font descriptors needed by the scene's text. Deduped because
several languages map to one noto family and provisioning is concurrent —
otherwise they all miss the byte cache at once and refetch the same TTF."
[scene]
(let [texts (for [shape (vals scene)
:when (= :text (:type shape))
node (or (some->> (:content shape) (tree-seq :children :children)) [])
:let [text (:text node)]
:when (string? text)]
text)
emoji? (boolean (some cfnt/contains-emoji? texts))
langs (reduce cfnt/collect-used-languages #{} texts)]
(distinct
(cond-> (cfnt/add-noto-fonts [] langs)
emoji? (cfnt/add-emoji-font)))))
(defn- fetch-fallback-font-bytes
"Downloads one fallback font's TTF. Cached by the whole variant, not just
`font-id`: `resolve-ttf-url` picks a different TTF per weight/style, so a
font-id-only key would serve the first downloaded variant for every other one."
[{:keys [font-id weight style]}]
(if-let [ttf-url (some-> (cfnt/gfont-id->uuid font-id) (cfnt/resolve-ttf-url weight style))]
(cached-ttf-bytes [font-id weight style] #(fetch-gfont-bytes ttf-url))
(p/resolved nil)))
(defn- provision-fallback-fonts!
[scene]
(->> (scene-fallback-fonts scene)
(map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}]
(if-let [font-uuid (cfnt/gfont-id->uuid font-id)]
(->> (fetch-fallback-font-bytes font)
(p/fmap (fn [buf]
(if buf
(wasm/store-font! {:id (uuid/get-u32 font-uuid)
:weight weight
:style style
:emoji? (boolean is-emoji)
:fallback? (boolean is-fallback)}
buf)
(l/warn :hint "wasm render: fallback font unavailable"
:font-id font-id)))))
(p/resolved nil))))
(p/all)))
;; --- image resolution
;;
;; Image fills reference file-media ids; the encoded bytes go straight to
;; `_store_image` (Skia decodes, no WebGL), keyed by media uuid so this happens
;; once per request rather than per rendered object.
(defn- fetch-file-media-bytes
"Downloads an image fill's encoded bytes by file-media id."
[media-id {:keys [token]}]
(let [headers (asset-headers token)
uri (internal-uri (str "assets/by-file-media-id/" media-id))]
(->> (fetch! uri #js {:method "GET" :headers headers})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(do
(l/warn :hint "wasm render: image fetch non-200"
:media-id (str media-id)
:uri uri
:status (.-status resp))
(p/resolved nil)))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: image fetch failed"
:media-id (str media-id) :uri uri
:detail (explain cause) :cause cause)
(p/resolved nil))))))
(defn- provision-images!
"Fetches and stores every image the scene references (shape, stroke and
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 (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
(p/fmap (fn [buf]
(if buf
(do
(l/dbg :hint "wasm render: image stored"
:media-id (str image-id)
:bytes (.-byteLength ^js buf))
(wasm/store-image! image-id buf))
(l/warn :hint "wasm render: image unavailable"
:media-id (str image-id))))))))
(p/all))))
(defn- relayout-text!
"Recomputes layout for every text shape, once the real fonts are provisioned
(serialize-time layout used the fallback)."
[scene]
(doseq [shape (vals scene)
:when (= :text (:type shape))]
(wasm/update-text-layout! (:id shape))))
;; --- render
(defn- render-object-bytes
[type id scale]
(if (= :pdf type)
(let [bytes (wasm/render-shape-pdf id scale)]
(l/dbg :hint "PDF generated via Skia (render-wasm headless)"
:object-id (str id)
:backend "skia-wasm"
:bytes (.-length bytes))
bytes)
(wasm/render-shape-raster id scale type)))
(defn- render*
[{:keys [scale type objects] :as params} on-object]
(l/dbg :hint "wasm render: start"
:type type
:scale scale
:objects (count objects)
:file-id (str (:file-id params))
:page-id (str (:page-id params)))
(->> (ensure-module!)
(p/mcat (fn [_] (fetch-objects params)))
(p/mcat (fn [scene]
(l/dbg :hint "wasm render: scene fetched" :shapes (count scene))
(serialize/serialize-scene! scene)
(l/dbg :hint "wasm render: scene serialized")
;; So fonts from a previous request don't leak into this one.
(wasm/clear-fonts!)
(->> (p/all [(fetch-font-variants params)
(provision-images! scene params)
(provision-fallback-fonts! scene)])
(p/mcat
(fn [[variants _]]
(let [resolve-font (make-resolve-font (or variants []) params)]
;; Before rendering, so the relayout below sees real
;; font metrics. Deduped across objects: a partition
;; sharing one family downloads its TTF once.
(wasm/provision-fonts! (map :id objects) resolve-font))))
(p/mcat
(fn [_]
(relayout-text! scene)
(p/run
(fn [{:keys [id] :as object}]
(let [bytes (render-object-bytes type id scale)
path (sh/tempfile :prefix "penpot.tmp.wasm."
:suffix (mime/get-extension type))]
(l/dbg :hint "wasm render: object rendered"
:object-id (str id) :bytes (.-length bytes))
(fs/writeFileSync path bytes)
;; `on-object` returns a plain value (zip append) or
;; a promise (single export's file move); `p/do`
;; normalizes both to a thenable.
(p/do (on-object (assoc object :path path)))))
objects))))))
(p/fmap (fn [result]
;; After the request, never mid-render, so an image can't
;; disappear under a running export.
(let [evicted (wasm/evict-images! wasm/image-cache-mb)]
(when (pos? evicted)
(l/info :hint "wasm render: evicted cached images" :count evicted)))
result))
(p/merr (fn [cause]
(l/error :hint "wasm render: failed"
:detail (explain cause)
:internal-uri (str (cf/get-internal-uri))
:cause cause)
;; A panic can leave the mem buffer allocated or the instance
;; aborted; drop it so the next request rebuilds a fresh one.
(reset! module* nil)
(p/rejected cause)))))
(defn render
"Public entry. `enqueue!` keeps concurrent exports off each other's toes on
the shared WASM instance."
[params on-object]
(enqueue! (fn [] (render* params on-object))))

255
exporter/src/app/wasm.cljs Normal file
View File

@ -0,0 +1,255 @@
;; 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.wasm
"Headless driver for the render-wasm module under Node: the GPU-free
counterpart of `app.render-wasm.api`. Loads the emscripten artifact, boots it
via `init_headless`, and exposes font provisioning + shape rendering.
Serialization is reused from the portable render-wasm leaves, so this
namespace owns only the Node runtime and the headless render calls.
Requires render-wasm built with `-sENVIRONMENT=web,node`."
(:require
["node:fs" :as fs]
["node:path" :as path]
[app.common.data :as d]
[app.common.logging :as l]
[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.uuid :as uuid]
;; Required for side effects: binds the generated enums.
[app.wasm.enums]
[promesa.core :as p]
[shadow.esm :refer [dynamic-import]]))
(def ^:private default-viewport-width 1920)
(def ^:private default-viewport-height 1080)
;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32].
(def ^:private RASTER-HEADER-BYTES 12)
;; render_shape_pdf result header: [len u32] only.
(def ^:private PDF-HEADER-BYTES 4)
;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32].
(def ^:private FONT-ENTRY-BYTES 24)
(def artifact-dir
"Built render-wasm artifact, relative to the process working directory. Same
path in devenv and inside the bundle, so it is a constant."
"resources/wasm")
(def image-cache-mb
"Byte budget (MB) the image store is trimmed to between requests."
256)
(defn- read-result-bytes
"Reads `len` bytes from the WASM heap starting at `offset`, copying them out
(via `.slice`) before the buffer is freed."
[offset len]
(.slice (mem/get-heap-u8) offset (+ offset len)))
;; --- MODULE LIFECYCLE
(defn init!
"Loads the render-wasm artifact under Node and boots it headless. Sets the
shared `wasm/internal-module` so the portable serialization leaves work.
Idempotent-ish: callers should hold the returned module."
([] (init! default-viewport-width default-viewport-height))
([width height]
(let [dir artifact-dir
js-path (path/resolve dir "render-wasm.js")
wasm-path (path/resolve dir "render-wasm.wasm")
wasm-bytes (fs/readFileSync wasm-path)]
(l/info :hint "loading render-wasm (headless)" :js js-path)
;; shadow-cljs :esm — use its dynamic-import helper (raw `js/import`
;; compiles to an undefined `import$`).
(->> (dynamic-import (str "file://" js-path))
(p/mcat
(fn [mod]
(let [factory (unchecked-get mod "default")]
(factory
#js {;; Bypass the web fetch loader: instantiate from local bytes.
:instantiateWasm
(fn [imports success]
(-> (js/WebAssembly.instantiate wasm-bytes imports)
(.then (fn [result] (success (.-instance result)))))
#js {})
:locateFile (fn [p] (path/resolve dir p))
:printErr (fn [s] (l/warn :wasm s))}))))
(p/fmap
(fn [module]
(set! wasm/internal-module module)
(h/call module "_init_headless" width height)
(set! wasm/context-initialized? true)
(l/info :hint "render-wasm headless module ready" :width width :height height)
module))))))
;; --- FONT PROVISIONING (on demand, mirrors the browser)
(defn fonts-for-shape
"Returns the distinct font families needed to render the subtree rooted at
`shape-id` as a vector of {:id <uuid-u32x4> :weight :style}. Equivalent to
the browser's `get-content-fonts`, but read from the loaded WASM tree."
[shape-id]
(let [module wasm/internal-module
buf (uuid/get-u32 shape-id) ;; resolved from app.render-wasm leaves
offset (h/call module "_get_fonts_for_shape"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3))
heap32 (mem/get-heap-u32)
n (aget heap32 (mem/->offset-32 offset))
;; `vec` must stay eager: it reads the result buffer, and the
;; `mem/free` below invalidates these offsets.
entries (vec
(for [i (range n)]
(let [base (+ offset 4 (* i FONT-ENTRY-BYTES))
u32 (fn [o] (aget heap32 (mem/->offset-32 (+ base o))))]
{:id #js [(u32 0) (u32 4) (u32 8) (u32 12)]
:weight (u32 16)
:style (u32 20)})))]
(mem/free)
entries))
(defn- font-key
"Value key for a family map. Its `:id` is a JS array, so the map itself can't
be compared by value."
[{:keys [id weight style]}]
[(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style])
(defn fonts-for-shapes
"Distinct font families needed by every subtree in `shape-ids`. Objects in a
partition overwhelmingly share families, so deduping here means one download
and one `_store_font` per family rather than one per object."
[shape-ids]
(into [] (comp (mapcat fonts-for-shape)
(d/distinct-xf font-key))
shape-ids))
(defn store-font!
"Uploads one font's TTF bytes into the WASM font store, keyed by the family
(uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer.
Does NOT call `mem/free` — `store_font` (and likewise `store_image` below)
releases the global buffer itself on the Rust side. Freeing again here would
drop a buffer a later writer already owns."
[{:keys [id weight style emoji? fallback?]} font-bytes]
(let [module wasm/internal-module
size (.-byteLength font-bytes)
ptr (h/call module "_alloc_bytes" size)
heap (mem/get-heap-u8)]
(.set heap (js/Uint8Array. font-bytes) ptr)
(h/call module "_store_font"
(aget id 0) (aget id 1) (aget id 2) (aget id 3)
weight style (boolean emoji?) (boolean fallback?))))
(defn clear-fonts!
"Resets the WASM font store. Must be called once per render request because
the shared module would otherwise accumulate fonts across requests."
[]
(h/call wasm/internal-module "_clear_fonts"))
(defn update-text-layout!
"Recomputes a text shape's layout with the currently provisioned fonts. Text is
laid out at serialize time using the fallback font (real fonts aren't uploaded
yet), so this must run again after `provision-fonts!` or glyph metrics/line
breaks are wrong."
[shape-id]
(let [buf (uuid/get-u32 shape-id)]
(h/call wasm/internal-module "_update_shape_text_layout_for"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3))))
(defn image-cached?
"True when the module's image store already holds this image (full size).
The store is NOT reset between requests, so previously provisioned images
can be reused instead of refetched."
[image-id]
(let [buf (uuid/get-u32 image-id)]
(not (zero? (h/call wasm/internal-module "_is_image_cached"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
false)))))
(defn store-image!
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
[shape uuid 16][image uuid 16][is_thumbnail u32][encoded bytes]. Images are
keyed by image uuid, so the shape uuid is left zero. `image-bytes` is an
ArrayBuffer/Buffer/Uint8Array."
[image-id image-bytes]
(let [module wasm/internal-module
img-u8 (js/Uint8Array. image-bytes)
size (.-byteLength img-u8)
total (+ 36 size)
ptr (h/call module "_alloc_bytes" total)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))
quart (uuid/get-u32 image-id)]
;; shape uuid [0..16) = 0 (images are keyed by image uuid only)
(.setUint32 dview (+ ptr 0) 0 true)
(.setUint32 dview (+ ptr 4) 0 true)
(.setUint32 dview (+ ptr 8) 0 true)
(.setUint32 dview (+ ptr 12) 0 true)
;; image uuid [16..32) — 4 LE u32 (matches common `buffer/write-uuid`, which
;; the fill path uses, so it hashes to the same key the fill references)
(.setUint32 dview (+ ptr 16) (aget quart 0) true)
(.setUint32 dview (+ ptr 20) (aget quart 1) true)
(.setUint32 dview (+ ptr 24) (aget quart 2) true)
(.setUint32 dview (+ ptr 28) (aget quart 3) true)
;; is_thumbnail [32..36) = 0
(.setUint32 dview (+ ptr 32) 0 true)
;; encoded bytes [36..)
(.set heap img-u8 (+ ptr 36))
(h/call module "_store_image")))
(defn evict-images!
"Evicts least-recently-used images until the store retains at most `max-mb`
megabytes. Returns the number evicted."
[max-mb]
(h/call wasm/internal-module "_evict_images_to_budget" max-mb))
(defn provision-fonts!
"Resolves and uploads every font needed by `shape-ids`, each family fetched
once. `resolve-font` is an injected fn of the family map -> promise of TTF
bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom
assets / backend) out of the driver."
[shape-ids resolve-font]
(->> (fonts-for-shapes shape-ids)
(map (fn [family]
(->> (resolve-font family)
(p/fmap (fn [bytes] (when bytes (store-font! family bytes)))))))
(p/all)))
;; --- RENDER
(defn- read-render-result
"Copies the encoded payload out of a `_render_shape_*` result buffer and frees
it. `header-bytes` is the size of the header preceding the payload."
[offset header-bytes]
(let [heap32 (mem/get-heap-u32)
len (aget heap32 (mem/->offset-32 offset))
bytes (read-result-bytes (+ offset header-bytes) len)]
(mem/free)
bytes))
(defn render-shape-raster
"Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU
surface. `format` is :png, :jpeg or :webp; jpeg is flattened onto white on
the Rust side, since it has no alpha channel."
[shape-id scale format]
(let [buf (uuid/get-u32 shape-id)]
(-> (h/call wasm/internal-module "_render_shape_raster"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
scale (sr/translate-raster-format format))
(read-render-result RASTER-HEADER-BYTES))))
(defn render-shape-pdf
"Renders the shape subtree to PDF bytes (Uint8Array)."
[shape-id scale]
(let [buf (uuid/get-u32 shape-id)]
(-> (h/call wasm/internal-module "_render_shape_pdf"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
scale)
(read-render-result PDF-HEADER-BYTES))))

View File

@ -0,0 +1,19 @@
;; 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.wasm.enums
"Binds this build's generated enums into the shared bridge.
`shared.js` is emitted next to this file by `render-wasm/build export` and is
not committed. Requiring this namespace is what makes
`app.common.render-wasm.wasm/serializers` usable."
(:require
["./shared.js" :as shared]
[app.common.render-wasm.wasm :as wasm])
(:require-macros
[app.common.render-wasm.enums :as enums]))
(wasm/init-serializers! (enums/serializers shared))

View File

@ -0,0 +1,46 @@
;; 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.wasm.serialize
"Browser-free shape serialization for the headless exporter: the counterpart
of `app.render-wasm.api/set-object`, which cannot be reused directly because
its namespace pulls React/DOM/store. Only the call sequencing lives here —
every byte layout comes from the shared serializers, so the bytes sent to
WASM are the editor's.
Covers everything except svg-raw. Image bytes and fonts are provisioned
separately by `app.renderer.wasm`."
(:require
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.serialize-shape :as serialize-shape]
[app.common.render-wasm.wasm :as wasm]
[app.wasm.text :as text]))
(defn set-shape!
"Serializes a single shape into the WASM design state. The host-independent
properties (base props, children, blur, shadows, svg-attrs, mask, bool-type,
path geometry, grow-type) go through the shared `serialize-shape!` — the same
code the workspace's `set-object` uses, so the two can't drift. Only the
host-specific parts are handled here: fills/strokes (image bytes are provisioned
separately) and text content (fonts provisioned separately)."
[shape]
(let [type (get shape :type)]
(serialize-shape/serialize-shape! shape)
(props/write-shape-fills! (get shape :fills))
(when-not (= type :group)
(props/write-shape-strokes! (get shape :strokes)))
(when (= type :text)
(text/set-shape-text! (get shape :content)))))
(defn serialize-scene!
"Loads every shape of an `objects` map into the WASM design state. Resets the
shapes pool first so repeated exports don't accumulate into the shared
state. Order is irrelevant: shapes reference each other by id and the tree
is resolved at render time."
[objects]
(h/call wasm/internal-module "_init_shapes_pool" (count objects))
(run! set-shape! (vals objects)))

View File

@ -0,0 +1,35 @@
;; 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.wasm.text
"Browser-free text-content serialization for the headless exporter. Only the
paragraph walk is local: the binary layout and the font-id -> uuid mapping
both come from `app.common.render-wasm.text-content`."
(:require
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.text-content :as tc]
[app.common.render-wasm.wasm :as wasm]))
(defn set-shape-text!
"Serializes a text shape's content into the current WASM shape. Mirrors the
editor's sequence: clear -> vertical-align -> append each paragraph -> layout.
Byte writing and font resolution are the shared
`text-content/write-shape-text!` defaults; the exporter has no fonts DB, so
it injects no variant normalization."
[content]
(when content
(h/call wasm/internal-module "_clear_shape_text")
(h/call wasm/internal-module "_set_shape_vertical_align"
(sr/translate-vertical-align (get content :vertical-align)))
(let [paragraph-set (first (get content :children))
paragraphs (get paragraph-set :children)]
(doseq [paragraph paragraphs]
(let [spans (get paragraph :children)]
(when (seq spans)
(let [text (apply str (map :text spans))]
(tc/write-shape-text! spans paragraph text {}))))))
(h/call wasm/internal-module "_update_shape_text_layout")))

View File

@ -1,6 +1,7 @@
{:paths ["src" "vendor" "resources" "test"] {:paths ["src" "vendor" "resources" "test"]
:deps :deps
{penpot/common {;; Carries `app.common.render-wasm.*`, shared with the headless exporter.
penpot/common
{:local/root "../common"} {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.2"} org.clojure/clojure {:mvn/version "1.12.2"}

View File

@ -19,7 +19,7 @@
"build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build", "build:storybook": "(cd packages/ui && pnpm run build) && pnpm run build:storybook:assets && pnpm run build:storybook:cljs && storybook build",
"build:storybook:assets": "node ./scripts/build-storybook-assets.js", "build:storybook:assets": "node ./scripts/build-storybook-assets.js",
"build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook", "build:storybook:cljs": "clojure -M:dev:shadow-cljs compile storybook",
"build:wasm": "../render-wasm/build", "build:wasm": "../render-wasm/build frontend",
"build:app:libs": "node ./scripts/build-libs.js", "build:app:libs": "node ./scripts/build-libs.js",
"build:app:main": "clojure -M:dev:shadow-cljs release main worker", "build:app:main": "clojure -M:dev:shadow-cljs release main worker",
"build:app:worker": "clojure -M:dev:shadow-cljs release worker", "build:app:worker": "clojure -M:dev:shadow-cljs release worker",

View File

@ -30,7 +30,7 @@ mkdir -p target/dist;
# Build render wasm binary # Build render wasm binary
pushd ../render-wasm; pushd ../render-wasm;
./build ./build frontend
popd popd
pushd ../mcp; pushd ../mcp;

View File

@ -68,7 +68,7 @@ function slug(value) {
} }
async function findGfontsJson() { async function findGfontsJson() {
const dir = "resources/fonts"; const dir = "../common/resources/fonts";
const entries = await fs.readdir(dir); const entries = await fs.readdir(dir);
const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort(); const matches = entries.filter((f) => /^gfonts\..*\.json$/.test(f)).sort();
if (matches.length === 0) { if (matches.length === 0) {

View File

@ -18,6 +18,7 @@
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.path-names :as cpn] [app.common.path-names :as cpn]
[app.common.render-wasm.wasm :as wasm-state]
[app.common.transit :as t] [app.common.transit :as t]
[app.common.types.component :as ctc] [app.common.types.component :as ctc]
[app.common.types.components-list :as ctkl] [app.common.types.components-list :as ctkl]
@ -77,7 +78,6 @@
[app.plugins.register :as preg] [app.plugins.register :as preg]
[app.render-wasm :as wasm] [app.render-wasm :as wasm]
[app.render-wasm.api :as wasm.api] [app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm-state]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.globals :as ug] [app.util.globals :as ug]
[app.util.http :as http] [app.util.http :as http]

View File

@ -6,10 +6,10 @@
(ns app.main.fonts (ns app.main.fonts
"Fonts management and loading logic." "Fonts management and loading logic."
(:require-macros [app.main.fonts :refer [preload-gfonts]])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.fonts :as cfnt]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.uri :as u] [app.common.uri :as u]
@ -25,27 +25,6 @@
(log/set-level! :warn) (log/set-level! :warn)
(def google-fonts
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(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"}]}])
(defonce fontsdb (l/atom {})) (defonce fontsdb (l/atom {}))
(defonce fonts (l/atom [])) (defonce fonts (l/atom []))
@ -65,10 +44,10 @@
fonts (map #(assoc % :backend backend) fonts)] fonts (map #(assoc % :backend backend) fonts)]
(merge db (d/index-by :id fonts)))))) (merge db (d/index-by :id fonts))))))
(register! :builtin local-fonts) (register! :builtin cfnt/local-fonts)
(when (contains? cf/flags :google-fonts-provider) (when (contains? cf/flags :google-fonts-provider)
(register! :google google-fonts)) (register! :google cfnt/catalog))
(defn get-font-data [id] (defn get-font-data [id]
(get @fontsdb id)) (get @fontsdb id))
@ -266,8 +245,7 @@
(defn- process-gfont-css (defn- process-gfont-css
[css] [css]
(let [base (u/join cf/public-uri "internal/gfonts/font")] (cfnt/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font")))
(str/replace css "https://fonts.gstatic.com/s" (dm/str base))))
(defn- fetch-gfont-css (defn- fetch-gfont-css
[url] [url]
@ -397,42 +375,12 @@
(defn find-closest-variant (defn find-closest-variant
"Find the closest font weight variant in `font` for `target-weight` with optional `target-style` match. "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." 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] [font target-weight target-style]
(when-let [target-weight (d/parse-integer target-weight)] (cfnt/closest-variant (:variants font []) target-weight target-style))
(let [variants (:variants font [])
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))))
;; Font embedding functions ;; Font embedding functions
(defn get-node-fonts (defn get-node-fonts

View File

@ -9,8 +9,8 @@
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.render-wasm.wasm :as wasm]
[app.render-wasm.api :as wasm.api] [app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.timers :as ts] [app.util.timers :as ts]
[app.util.webapi :as webapi] [app.util.webapi :as webapi]

View File

@ -13,8 +13,17 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.files.focus :as cpf] [app.common.files.focus :as cpf]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.fonts :as cfnt]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.math :as mth] [app.common.math :as mth]
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.mem.heap32 :as mem.h32]
[app.common.render-wasm.serialize-shape :as serialize-shape]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as clr] [app.common.types.color :as clr]
[app.common.types.fills :as types.fills] [app.common.types.fills :as types.fills]
[app.common.types.path :as path] [app.common.types.path :as path]
@ -31,23 +40,17 @@
[app.main.router :as rt] [app.main.router :as rt]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.shapes.text] [app.main.ui.shapes.text]
;; Required for side effects: binds the generated enums.
[app.render-wasm.api.enums]
[app.render-wasm.api.fonts :as f] [app.render-wasm.api.fonts :as f]
[app.render-wasm.api.props :as props]
[app.render-wasm.api.texts :as t] [app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl] [app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr] [app.render-wasm.deserializers :as dr]
[app.render-wasm.gesture :as wasm-gesture] [app.render-wasm.gesture :as wasm-gesture]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.mem.heap32 :as mem.h32]
[app.render-wasm.performance :as perf] [app.render-wasm.performance :as perf]
[app.render-wasm.rulers-state :as rulers-state] [app.render-wasm.rulers-state :as rulers-state]
[app.render-wasm.serialize-shape :as serialize-shape]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters] [app.render-wasm.svg-filters :as svg-filters]
[app.render-wasm.text-editor :as text-editor] [app.render-wasm.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.functions :as fns] [app.util.functions :as fns]
@ -1295,8 +1298,8 @@
langs) langs)
(let [text (apply str (map :text spans)) (let [text (apply str (map :text spans))
emoji? (if emoji? emoji? (t/contains-emoji? text)) emoji? (if emoji? emoji? (cfnt/contains-emoji? text))
langs (t/collect-used-languages langs text)] langs (cfnt/collect-used-languages langs text)]
;; FIXME: this should probably be somewhere else ;; FIXME: this should probably be somewhere else
(when fallback-fonts-only? (t/write-shape-text spans paragraph text)) (when fallback-fonts-only? (t/write-shape-text spans paragraph text))
@ -1307,8 +1310,8 @@
(let [updated-fonts (let [updated-fonts
(-> #{} (-> #{}
(cond-> ^boolean emoji? (f/add-emoji-font)) (cond-> ^boolean emoji? (cfnt/add-emoji-font))
(f/add-noto-fonts langs)) (cfnt/add-noto-fonts langs))
fallback-fonts (filter #(get % :is-fallback) updated-fonts)] fallback-fonts (filter #(get % :is-fallback) updated-fonts)]
(if fallback-fonts-only? updated-fonts fallback-fonts)))))) (if fallback-fonts-only? updated-fonts fallback-fonts))))))

View File

@ -0,0 +1,19 @@
;; 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.render-wasm.api.enums
"Binds this build's generated enums into the shared bridge.
`shared.js` is emitted next to this file by `render-wasm/build frontend` and
is not committed. Requiring this namespace is what makes
`app.common.render-wasm.wasm/serializers` usable."
(:require
["./shared.js" :as shared]
[app.common.render-wasm.wasm :as wasm])
(:require-macros
[app.common.render-wasm.enums :as enums]))
(wasm/init-serializers! (enums/serializers shared))

View File

@ -8,15 +8,15 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.fonts :as cfnt]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.main.fonts :as fonts] [app.main.fonts :as fonts]
[app.main.store :as st] [app.main.store :as st]
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.helpers :as h]
[app.render-wasm.wasm :as wasm]
[app.util.http :as http] [app.util.http :as http]
[app.util.timers :as tm] [app.util.timers :as tm]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
@ -39,33 +39,6 @@
(def ^:private default-line-height 1.2) (def ^:private default-line-height 1.2)
(def ^:private default-letter-spacing 0.0) (def ^:private default-letter-spacing 0.0)
(defn- google-font-id->uuid
"Returns the UUID for a Google Font ID. Uses uuid/zero as fallback when the
font is not found in fontsdb. uuid/zero maps to the default font (Source
Sans Pro) in WASM.
A font id may not exist for different reasons:
- the gfonts.json catalog was updated and fonts were renamed or removed,
- the file was imported from another Penpot instance with different fonts,
..."
[font-id]
(let [font (fonts/get-font-data font-id)
result (:uuid font)]
(or result uuid/zero)))
(defn- custom-font-id->uuid
[font-id]
(uuid/uuid (subs font-id (inc (str/index-of font-id "-")))))
(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 (defn- font-db-data
[font-id font-variant-id font-weight-fallback font-style-fallback] [font-id font-variant-id font-weight-fallback font-style-fallback]
(let [font (fonts/get-font-data font-id) (let [font (fonts/get-font-data font-id)
@ -75,15 +48,6 @@
variant variant
closest-variant))) closest-variant)))
(defn- font-id->uuid [font-id]
(case (font-backend font-id)
:google
(google-font-id->uuid font-id)
:custom
(custom-font-id->uuid font-id)
:builtin
uuid/zero))
(defn uuid->font-id (defn uuid->font-id
[font-uuid] [font-uuid]
(if (= font-uuid uuid/zero) (if (= font-uuid uuid/zero)
@ -100,11 +64,11 @@
"regular"))) "regular")))
(defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style] (defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style]
(case (font-backend font-id) (case (cfnt/font-id->backend font-id)
:google :google
font-id font-id
:custom :custom
(let [font-uuid (custom-font-id->uuid font-id) (let [font-uuid (cfnt/font-id->uuid font-id)
matching-font (some (fn [[_ font]] matching-font (some (fn [[_ font]]
(and (= (:font-id font) font-uuid) (and (= (:font-id font) font-uuid)
(= (str (:font-weight font)) (str font-weight)) (= (str (:font-weight font)) (str font-weight))
@ -194,13 +158,12 @@
(defn- google-font-ttf-url (defn- google-font-ttf-url
[font-id font-variant-id font-weight font-style] [font-id font-variant-id font-weight font-style]
(let [variant (font-db-data font-id font-variant-id font-weight font-style)] (let [variant (font-db-data font-id font-variant-id font-weight font-style)]
(if-let [ttf-url (:ttf-url variant)] (when-let [ttf-url (:ttf-url variant)]
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/")) (cfnt/gstatic->proxy-url ttf-url (u/join cf/public-uri "internal/gfonts/font")))))
nil)))
(defn- font-id->ttf-url (defn- font-id->ttf-url
[font-id asset-id font-variant-id font-weight font-style] [font-id asset-id font-variant-id font-weight font-style]
(case (font-backend font-id) (case (cfnt/font-id->backend font-id)
:google :google
(google-font-ttf-url font-id font-variant-id font-weight font-style) (google-font-ttf-url font-id font-variant-id font-weight font-style)
:custom :custom
@ -245,18 +208,6 @@
"italic" 1 "italic" 1
0)) 0))
(defn normalize-font-id
[font-id]
(try
(if ^boolean (str/starts-with? font-id "gfont-")
(google-font-id->uuid font-id)
(let [no-prefix (subs font-id (inc (str/index-of font-id "-")))]
(if (or (nil? no-prefix) (not (string? no-prefix)) (str/blank? no-prefix))
uuid/zero
(uuid/parse no-prefix))))
(catch :default _e
uuid/zero)))
(defn normalize-span-font (defn normalize-span-font
[span paragraph] [span paragraph]
(let [font-id (:font-id span) (let [font-id (:font-id span)
@ -358,7 +309,7 @@
emoji? (get font :is-emoji false) emoji? (get font :is-emoji false)
fallback? (get font :is-fallback false) fallback? (get font :is-fallback false)
font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback) font-data (font-db-data font-id normalized-variant-id font-weight-fallback font-style-fallback)
wasm-id (font-id->uuid font-id) wasm-id (cfnt/font-id->uuid font-id)
raw-weight (or (:weight font-data) font-weight-fallback) raw-weight (or (:weight font-data) font-weight-fallback)
weight (serialize-font-weight raw-weight) weight (serialize-font-weight raw-weight)
style (cond style (cond
@ -415,7 +366,3 @@
(defn store-fonts (defn store-fonts
[fonts] [fonts]
(keep (fn [font] (store-font font)) 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,21 +6,13 @@
(ns app.render-wasm.api.texts (ns app.render-wasm.api.texts
(:require (:require
[app.render-wasm.api.fonts :as f] [app.common.render-wasm.text-content :as tc]
[app.render-wasm.fallback-fonts :as fbf] [app.render-wasm.api.fonts :as f]))
[app.render-wasm.text-content :as tc]))
(defn write-shape-text (defn write-shape-text
"Workspace text serialization: the byte writing is shared via "Workspace text serialization: the byte writing is shared via
`app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)." `app.common.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
[spans paragraph text] [spans paragraph text]
(tc/write-shape-text! spans paragraph text (tc/write-shape-text! spans paragraph text
{:normalize-font-id f/normalize-font-id {:normalize-paragraph f/normalize-paragraph-font
:normalize-paragraph f/normalize-paragraph-font
:normalize-span f/normalize-span-font})) :normalize-span f/normalize-span-font}))
;; Emoji/script detection lives in the host-agnostic
;; `app.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)

View File

@ -8,7 +8,7 @@
"WebGL utilities for pixel capture and rendering" "WebGL utilities for pixel capture and rendering"
(:require (:require
[app.common.logging :as log] [app.common.logging :as log]
[app.render-wasm.wasm :as wasm] [app.common.render-wasm.wasm :as wasm]
[promesa.core :as p])) [promesa.core :as p]))
(defn get-webgl-context (defn get-webgl-context

View File

@ -7,16 +7,18 @@
(ns app.render-wasm.text-editor (ns app.render-wasm.text-editor
"Text editor WASM bindings" "Text editor WASM bindings"
(:require (:require
[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.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills.impl :as types.fills.impl] [app.common.types.fills.impl :as types.fills.impl]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.fonts :as main-fonts] [app.main.fonts :as main-fonts]
;; Required for side effects: binds the generated enums.
[app.render-wasm.api.enums]
[app.render-wasm.api.fonts :as fonts] [app.render-wasm.api.fonts :as fonts]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]
[app.util.color :as uc] [app.util.color :as uc]
[app.util.dom :as dom])) [app.util.dom :as dom]))

View File

@ -11,6 +11,7 @@
[app.common.geom.rect :as grc] [app.common.geom.rect :as grc]
[app.common.geom.shapes.bounds :as gsb] [app.common.geom.shapes.bounds :as gsb]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.color :as cc] [app.common.types.color :as cc]
[app.common.uri :as u] [app.common.uri :as u]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
@ -18,7 +19,6 @@
[app.main.fonts :as fonts] [app.main.fonts :as fonts]
[app.main.render :as render] [app.main.render :as render]
[app.render-wasm.api :as wasm.api] [app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm]
[app.util.http :as http] [app.util.http :as http]
[app.worker.impl :as impl] [app.worker.impl :as impl]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]

View File

@ -15,6 +15,9 @@
[app.common.json :as json] [app.common.json :as json]
[app.common.logging :as l] [app.common.logging :as l]
[app.common.pprint :as pp] [app.common.pprint :as pp]
[app.common.render-wasm.helpers :as wasm.h]
[app.common.render-wasm.mem :as wasm.mem]
[app.common.render-wasm.wasm :as wasm]
[app.common.transit :as t] [app.common.transit :as t]
[app.common.types.component :as ctk] [app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl] [app.common.types.components-list :as ctkl]
@ -36,9 +39,6 @@
[app.main.errors :as errors] [app.main.errors :as errors]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.store :as st] [app.main.store :as st]
[app.render-wasm.helpers :as wasm.h]
[app.render-wasm.mem :as wasm.mem]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.http :as http] [app.util.http :as http]

View File

@ -15,9 +15,9 @@
font URL get no callback (fetch-font returns nil when the URL is already font URL get no callback (fetch-font returns nil when the URL is already
in :fetching) and are permanently stuck with fallback-font layout metrics." in :fetching) and are permanently stuck with fallback-font layout metrics."
(:require (:require
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.wasm :as wasm]
[app.render-wasm.api :as wasm.api] [app.render-wasm.api :as wasm.api]
[app.render-wasm.mem :as mem]
[app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cljs.test :as t :include-macros true])) [cljs.test :as t :include-macros true]))

View File

@ -11,7 +11,7 @@
anything else (no fill, gradient, image fills, mixed selection) it falls back anything else (no fill, gradient, image fills, mixed selection) it falls back
to an inverted caret (white painted with a Difference blend)." to an inverted caret (white painted with a Difference blend)."
(:require (:require
[app.render-wasm.serializers.color :as sr-clr] [app.common.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.text-editor :as text-editor] [app.render-wasm.text-editor :as text-editor]
[cljs.test :as t :include-macros true])) [cljs.test :as t :include-macros true]))

View File

@ -42,3 +42,7 @@ opt-level = 3
lto = "fat" lto = "fat"
strip = true strip = true
codegen-units = 1 codegen-units = 1
[profile.size]
inherits = "release"
opt-level = "z"

View File

@ -29,6 +29,34 @@ You can also use `./watch` to run the build on every change.
The build script will compile the project and copy the `.js` and `.wasm` files to their correct location within the frontend app. The build script will compile the project and copy the `.js` and `.wasm` files to their correct location within the frontend app.
### Render targets
The same Rust source produces two artifacts, which differ only in compiler
options:
| Target | Tuned for | Cargo profile | Consumed by |
| ---------- | --------- | ----------------- | ------------------------------ |
| `frontend` | speed | `release` (`-O3`) | `frontend/resources/public/js` |
| `export` | size | `size` (`-Oz`) | `exporter/resources/wasm` |
```sh
./build # both targets, frontend first
./build frontend # workspace / viewer renderer
./build export # headless exporter renderer
```
`./watch` still follows a single target (`frontend` unless you pass one),
since watching both would rebuild twice on every keystroke.
Each target keeps its own `CARGO_TARGET_DIR` (`target/<target>`), so switching
between them does not invalidate the other's cache. Set `BUILD_MODE=release`
(or `NODE_ENV=production`) for an optimized build; the default is `debug`.
Each target writes its own generated `shared.js` (the enum discriminants the
CLJS side compiles against) next to the code that imports it — respectively
`frontend/src/app/render_wasm/api/shared.js` and
`exporter/src/app/wasm/shared.js`. Neither build writes to the other's paths.
![Architecture overview](docs/images/architecture_schema.png) ![Architecture overview](docs/images/architecture_schema.png)

View File

@ -1,15 +1,25 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export VERSION_TAG=${VERSION:-develop}; export VERSION_TAG=${VERSION:-develop};
export RENDER_TARGET="${RENDER_TARGET:-${1:-frontend}}";
case "$RENDER_TARGET" in
frontend|export) ;;
*)
echo "ERROR: unknown render target '$RENDER_TARGET' (expected 'frontend' or 'export')" >&2;
exit 1;
;;
esac
if [ "$NODE_ENV" = "production" ]; then if [ "$NODE_ENV" = "production" ]; then
export BUILD_MODE="release"; export BUILD_MODE="release";
else else
export BUILD_MODE=${1:-debug}; export BUILD_MODE=${BUILD_MODE:-debug};
fi fi
export BUILD_NAME="${BUILD_NAME:-render-wasm}" export BUILD_NAME="${BUILD_NAME:-render-wasm}"
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
export CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"target/$RENDER_TARGET"};
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}
# 256 MB of initial heap to perform less # 256 MB of initial heap to perform less
@ -51,9 +61,21 @@ export EM_CACHE="/tmp/emsdk_cache";
export CARGO_PARAMS="${@:2}"; export CARGO_PARAMS="${@:2}";
export CARGO_PROFILE_DIR="debug";
if [ "$BUILD_MODE" = "release" ]; then if [ "$BUILD_MODE" = "release" ]; then
export CARGO_PARAMS="--release $CARGO_PARAMS" case "$RENDER_TARGET" in
export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS" frontend)
export CARGO_PARAMS="--release $CARGO_PARAMS";
export CARGO_PROFILE_DIR="release";
export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS";
;;
export)
export CARGO_PARAMS="--profile size $CARGO_PARAMS";
export CARGO_PROFILE_DIR="size";
export EMCC_CFLAGS="-Oz -sASSERTIONS=0 $EMCC_CFLAGS";
;;
esac
else else
# TODO: Extra parameters that could be good to look into: # TODO: Extra parameters that could be good to look into:
# -gseparate-dwarf # -gseparate-dwarf
@ -62,6 +84,12 @@ else
export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS" export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS"
fi fi
export FRONTEND_DEST="../frontend/resources/public/js";
export EXPORT_DEST="../exporter/resources/wasm";
export FRONTEND_SHARED_DEST="../frontend/src/app/render_wasm/api/shared.js";
export EXPORT_SHARED_DEST="../exporter/src/app/wasm/shared.js";
function clean { function clean {
cargo clean; cargo clean;
} }
@ -78,26 +106,48 @@ function build {
function copy_artifacts { function copy_artifacts {
DEST=$1; DEST=$1;
SRC="$CARGO_TARGET_DIR/$CARGO_BUILD_TARGET/$CARGO_PROFILE_DIR";
mkdir -p $DEST; mkdir -p $DEST;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js; cp $SRC/render_wasm.js $DEST/$BUILD_NAME.js;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm; cp $SRC/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map ]; then if [ -f $SRC/render_wasm.wasm.map ]; then
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map; cp $SRC/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map;
fi fi
sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js; sed -i "s/render_wasm.wasm/$BUILD_NAME.wasm?version=$VERSION_TAG/g" $DEST/$BUILD_NAME.js;
pnpm exec esbuild target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js \ # The worker bundle is a browser concern; the exporter imports the ESM
--log-level=error \ # module directly under Node.
--outfile=$DEST/worker/render.js \ if [ "$RENDER_TARGET" = "frontend" ]; then
--platform=neutral \ pnpm exec esbuild $SRC/render_wasm.js \
--format=iife \ --log-level=error \
--global-name=WasmModule; --outfile=$DEST/worker/render.js \
--platform=neutral \
--format=iife \
--global-name=WasmModule;
fi
} }
function copy_shared_artifact { function copy_shared_artifact {
SHARED_FILE=$(find target/wasm32-unknown-emscripten -name render_wasm_shared.js | head -n 1); DEST=$1;
cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js; SHARED_FILE=$(find $CARGO_TARGET_DIR/$CARGO_BUILD_TARGET -name render_wasm_shared.js | head -n 1);
cp $SHARED_FILE $DEST;
}
# Copies whatever the current RENDER_TARGET produced to where that target's
# consumer reads it.
function copy_target_artifacts {
case "$RENDER_TARGET" in
frontend)
copy_artifacts "$FRONTEND_DEST";
copy_shared_artifact "$FRONTEND_SHARED_DEST";
;;
export)
copy_artifacts "$EXPORT_DEST";
copy_shared_artifact "$EXPORT_SHARED_DEST";
;;
esac
} }

View File

@ -1,8 +1,26 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Usage: ./build [frontend|export] [extra cargo params...]
#
# With no target, builds both. Set BUILD_MODE=release (or NODE_ENV=production)
# for an optimized build. See `_build_env` for what each target changes.
_SCRIPT_DIR=$(dirname $0);
# Each target needs its own `_build_env`, so re-enter per target.
case "${1:-}" in
frontend|export)
;;
*)
for _target in frontend export; do
"$_SCRIPT_DIR/build" "$_target" "$@" || exit $?;
done
exit 0;
;;
esac
EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh
_SCRIPT_DIR=$(dirname $0);
pushd $_SCRIPT_DIR; pushd $_SCRIPT_DIR;
. ./_build_env . ./_build_env
@ -11,8 +29,7 @@ set -ex;
setup; setup;
build; build;
copy_artifacts "../frontend/resources/public/js"; copy_target_artifacts;
copy_shared_artifact;
exit $?; exit $?;

View File

@ -8,7 +8,7 @@ if [[ "$1" == "--debug" ]]; then
set -x set -x
fi fi
. ./_build_env . ./_build_env frontend
export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"};
export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"}

View File

@ -329,15 +329,21 @@ fn render_text_on_canvas(
layer_opacity: Option<f32>, layer_opacity: Option<f32>,
overlay_emoji: bool, overlay_emoji: bool,
) { ) {
let layer_bounds = shape.layer_bounds();
if let Some(blur_filter) = blur { if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default(); let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone()); blur_paint.set_image_filter(blur_filter.clone());
let blur_layer = SaveLayerRec::default().paint(&blur_paint); let blur_layer = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blur_paint);
canvas.save_layer(&blur_layer); canvas.save_layer(&blur_layer);
} }
if let Some(shadow_paint) = shadow { if let Some(shadow_paint) = shadow {
let layer_rec = SaveLayerRec::default().paint(shadow_paint); let layer_rec = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(shadow_paint);
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
draw_text( draw_text(
canvas, canvas,
@ -351,7 +357,9 @@ fn render_text_on_canvas(
if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) {
let mut layer_paint = Paint::default(); let mut layer_paint = Paint::default();
layer_paint.set_image_filter(erode); layer_paint.set_image_filter(erode);
let layer_rec = SaveLayerRec::default().paint(&layer_paint); let layer_rec = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&layer_paint);
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
draw_text( draw_text(
canvas, canvas,
@ -582,7 +590,10 @@ fn draw_decoration_stroke(
skia::BlendMode::SrcOut skia::BlendMode::SrcOut
}; };
canvas.save_layer(&SaveLayerRec::default()); let outset = stroke_paint.stroke_width().max(0.0);
let layer_bounds = bar.with_outset((outset, outset));
canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
let mut mask_paint = Paint::default(); let mut mask_paint = Paint::default();
mask_paint.set_color(skia::Color::BLACK); mask_paint.set_color(skia::Color::BLACK);
mask_paint.set_anti_alias(true); mask_paint.set_anti_alias(true);
@ -590,7 +601,11 @@ fn draw_decoration_stroke(
let mut blend_paint = Paint::default(); let mut blend_paint = Paint::default();
blend_paint.set_blend_mode(blend); blend_paint.set_blend_mode(blend);
canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint)); canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blend_paint),
);
canvas.draw_rect(bar, stroke_paint); canvas.draw_rect(bar, stroke_paint);
canvas.restore(); canvas.restore();
canvas.restore(); canvas.restore();
@ -705,7 +720,12 @@ pub fn render_emoji_overlay(
if let Some(blur_filter) = blur { if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default(); let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone()); blur_paint.set_image_filter(blur_filter.clone());
canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); let layer_bounds = shape.layer_bounds();
canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blur_paint),
);
} }
for (emoji_para, deco_para) in emoji_layout for (emoji_para, deco_para) in emoji_layout
@ -728,13 +748,17 @@ fn draw_text(
layer_opacity: Option<f32>, layer_opacity: Option<f32>,
overlay_emoji: bool, overlay_emoji: bool,
) { ) {
let layer_bounds = shape.layer_bounds();
if let Some(opacity) = layer_opacity { if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default(); let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity); opacity_paint.set_alpha_f(opacity);
let layer_rec = SaveLayerRec::default().paint(&opacity_paint); let layer_rec = SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&opacity_paint);
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
} else { } else {
canvas.save_layer(&SaveLayerRec::default()); canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
} }
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji);
@ -759,27 +783,41 @@ fn render_masked_stroke_on_canvas(
blur: Option<&ImageFilter>, blur: Option<&ImageFilter>,
layer_opacity: Option<f32>, layer_opacity: Option<f32>,
) { ) {
let layer_bounds = shape.layer_bounds();
if let Some(blur_filter) = blur { if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default(); let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone()); blur_paint.set_image_filter(blur_filter.clone());
canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&blur_paint),
);
} }
if let Some(opacity) = layer_opacity { if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default(); let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity); opacity_paint.set_alpha_f(opacity);
canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&opacity_paint),
);
} }
canvas.save_layer(&SaveLayerRec::default()); canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
canvas.save_layer(&SaveLayerRec::default()); canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds));
paint_text(canvas, shape, mask_builders); paint_text(canvas, shape, mask_builders);
let mut stroke_paint = Paint::default(); let mut stroke_paint = Paint::default();
stroke_paint.set_blend_mode(stroke_mask_blend); stroke_paint.set_blend_mode(stroke_mask_blend);
canvas.save_layer(&SaveLayerRec::default().paint(&stroke_paint)); canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&stroke_paint),
);
paint_text(canvas, shape, stroke_builders); paint_text(canvas, shape, stroke_builders);
@ -789,7 +827,11 @@ fn render_masked_stroke_on_canvas(
if let Some(fill_builders) = fill_builders { if let Some(fill_builders) = fill_builders {
let mut dst_over_paint = Paint::default(); let mut dst_over_paint = Paint::default();
dst_over_paint.set_blend_mode(skia::BlendMode::DstOver); dst_over_paint.set_blend_mode(skia::BlendMode::DstOver);
canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint)); canvas.save_layer(
&SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&dst_over_paint),
);
paint_text(canvas, shape, fill_builders); paint_text(canvas, shape, fill_builders);

View File

@ -79,11 +79,14 @@ impl ShapeRenderer for VectorRenderer<'_> {
} }
fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> { fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> {
let layer_bounds = shape.layer_bounds();
for shadow in shape.drop_shadows_visible() { for shadow in shape.drop_shadows_visible() {
if let Some(filter) = shadow.get_drop_shadow_filter() { if let Some(filter) = shadow.get_drop_shadow_filter() {
let mut paint = Paint::default(); let mut paint = Paint::default();
paint.set_image_filter(filter); paint.set_image_filter(filter);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); let layer_rec = skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&paint);
self.canvas.save_layer(&layer_rec); self.canvas.save_layer(&layer_rec);
let mut fill_paint = Paint::default(); let mut fill_paint = Paint::default();
fill_paint.set_anti_alias(true); fill_paint.set_anti_alias(true);
@ -99,10 +102,14 @@ impl ShapeRenderer for VectorRenderer<'_> {
if !shape.has_fills() { if !shape.has_fills() {
return Ok(()); return Ok(());
} }
let layer_bounds = shape.layer_bounds();
for shadow in shape.inner_shadows_visible() { for shadow in shape.inner_shadows_visible() {
let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref()); let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref());
self.canvas self.canvas.save_layer(
.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); &skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&paint),
);
let mut fill_paint = Paint::default(); let mut fill_paint = Paint::default();
fill_paint.set_anti_alias(true); fill_paint.set_anti_alias(true);
fill_paint.set_color(skia::Color::BLACK); fill_paint.set_color(skia::Color::BLACK);
@ -161,9 +168,13 @@ impl ShapeRenderer for VectorRenderer<'_> {
}) })
.collect(); .collect();
let layer_bounds = shape.layer_bounds();
for shadow_paint in &drop_shadows { for shadow_paint in &drop_shadows {
self.canvas self.canvas.save_layer(
.save_layer(&skia::canvas::SaveLayerRec::default().paint(shadow_paint)); &skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(shadow_paint),
);
text::render_overlay_emoji( text::render_overlay_emoji(
self.canvas, self.canvas,
@ -331,7 +342,10 @@ impl ShapeRenderer for VectorRenderer<'_> {
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
let mut paint = Paint::default(); let mut paint = Paint::default();
paint.set_image_filter(filter); paint.set_image_filter(filter);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); let layer_bounds = shape.layer_bounds();
let layer_rec = skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&paint);
self.canvas.save_layer(&layer_rec); self.canvas.save_layer(&layer_rec);
true true
} else { } else {
@ -715,7 +729,10 @@ fn render_group(
} }
} }
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); let layer_bounds = element.extrect(tree, scale);
let layer_rec = skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&paint);
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
} }
@ -726,7 +743,12 @@ fn render_group(
// as content, then re-draw the mask silhouette (the group's first child) // as content, then re-draw the mask silhouette (the group's first child)
// with DstIn to clip everything to it. // with DstIn to clip everything to it.
let paint = Paint::default(); let paint = Paint::default();
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); let subtree_bounds = element.extrect(tree, scale);
canvas.save_layer(
&skia::canvas::SaveLayerRec::default()
.bounds(&subtree_bounds)
.paint(&paint),
);
for child_id in &children { for child_id in &children {
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
@ -735,7 +757,11 @@ fn render_group(
if let Some(mask_id) = element.mask_id() { if let Some(mask_id) = element.mask_id() {
let mut mask_paint = Paint::default(); let mut mask_paint = Paint::default();
mask_paint.set_blend_mode(skia::BlendMode::DstIn); mask_paint.set_blend_mode(skia::BlendMode::DstIn);
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint)); canvas.save_layer(
&skia::canvas::SaveLayerRec::default()
.bounds(&subtree_bounds)
.paint(&mask_paint),
);
render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?; render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?;
canvas.restore(); // mask layer canvas.restore(); // mask layer
} }
@ -797,7 +823,10 @@ fn render_frame(
} }
} }
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); let layer_bounds = element.extrect(tree, scale);
let layer_rec = skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&paint);
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
} }
@ -857,13 +886,18 @@ fn render_container_drop_shadows(
draw_fills: bool, draw_fills: bool,
opts: &TreeOpts, opts: &TreeOpts,
) -> Result<()> { ) -> Result<()> {
let subtree_bounds = element.extrect(tree, scale);
for shadow in element.drop_shadows_visible() { for shadow in element.drop_shadows_visible() {
let Some(filter) = shadow.get_drop_shadow_filter() else { let Some(filter) = shadow.get_drop_shadow_filter() else {
continue; continue;
}; };
let mut paint = Paint::default(); let mut paint = Paint::default();
paint.set_image_filter(filter); paint.set_image_filter(filter);
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint)); canvas.save_layer(
&skia::canvas::SaveLayerRec::default()
.bounds(&subtree_bounds)
.paint(&paint),
);
if draw_fills && !element.fills.is_empty() { if draw_fills && !element.fills.is_empty() {
let mut renderer = VectorRenderer::new(canvas, shared, scale); let mut renderer = VectorRenderer::new(canvas, shared, scale);
@ -902,7 +936,10 @@ fn render_leaf(
let mut paint = Paint::default(); let mut paint = Paint::default();
paint.set_blend_mode(element.blend_mode().into()); paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity()); paint.set_alpha_f(element.opacity());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); let layer_bounds = element.layer_bounds();
let layer_rec = skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
.paint(&paint);
canvas.save_layer(&layer_rec); canvas.save_layer(&layer_rec);
} }
@ -1101,7 +1138,8 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint
} }
StrokeKind::Outer => { StrokeKind::Outer => {
canvas.save(); canvas.save();
canvas.save_layer(&skia::canvas::SaveLayerRec::default()); let layer_bounds = shape.layer_bounds();
canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds));
draw_shape_geometry(canvas, shape, paint); draw_shape_geometry(canvas, shape, paint);
let mut clear_paint = Paint::default(); let mut clear_paint = Paint::default();
clear_paint.set_blend_mode(skia::BlendMode::Clear); clear_paint.set_blend_mode(skia::BlendMode::Clear);
@ -1134,7 +1172,8 @@ fn draw_image_stroke(
let container = shape.selrect; let container = shape.selrect;
canvas.save(); canvas.save();
canvas.save_layer(&skia::canvas::SaveLayerRec::default()); let layer_bounds = shape.layer_bounds();
canvas.save_layer(&skia::canvas::SaveLayerRec::default().bounds(&layer_bounds));
// Opaque stroke silhouette; the SrcIn image draw below fills it. // Opaque stroke silhouette; the SrcIn image draw below fills it.
draw_stroke_geometry(canvas, scale, shape, stroke, true); draw_stroke_geometry(canvas, scale, shape, stroke, true);

View File

@ -1070,7 +1070,7 @@ impl Shape {
extrect extrect
} }
fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect { fn own_extrect_bounds(&self) -> Bounds {
let shape = self; let shape = self;
let max_stroke = Stroke::max_bounds_width(shape.strokes.iter(), shape.is_open()); let max_stroke = Stroke::max_bounds_width(shape.strokes.iter(), shape.is_open());
@ -1096,6 +1096,19 @@ impl Shape {
bounds = self.apply_stroke_bounds(bounds, max_stroke); bounds = self.apply_stroke_bounds(bounds, max_stroke);
bounds = self.apply_shadow_bounds(bounds); bounds = self.apply_shadow_bounds(bounds);
bounds = self.apply_blur_bounds(bounds); bounds = self.apply_blur_bounds(bounds);
bounds
}
/// Bound for a `SaveLayerRec` wrapping this shape's own drawing, in
/// untransformed space (callers concatenate [`Self::centered_transform`]
/// first). Includes shadow/blur margins, so it is also a valid input bound
/// for a layer whose paint carries an image filter.
pub fn layer_bounds(&self) -> math::Rect {
self.own_extrect_bounds().to_rect()
}
fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
let mut bounds = self.own_extrect_bounds();
bounds = self.apply_children_bounds(bounds, shapes_pool, scale); bounds = self.apply_children_bounds(bounds, shapes_pool, scale);
bounds = self.apply_children_blur(bounds, shapes_pool); bounds = self.apply_children_blur(bounds, shapes_pool);

View File

@ -7,7 +7,7 @@ export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"};
_SCRIPT_DIR=$(dirname $0); _SCRIPT_DIR=$(dirname $0);
pushd $_SCRIPT_DIR; pushd $_SCRIPT_DIR;
. ./_build_env . ./_build_env frontend
cargo test --bin render_wasm -- --show-output cargo test --bin render_wasm -- --show-output

View File

@ -1,5 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Usage: ./watch [frontend|export]
_SCRIPT_DIR=$(dirname $0); _SCRIPT_DIR=$(dirname $0);
pushd $_SCRIPT_DIR; pushd $_SCRIPT_DIR;
@ -7,8 +9,7 @@ pushd $_SCRIPT_DIR;
set -x set -x
build; build;
copy_artifacts "../frontend/resources/public/js"; copy_target_artifacts;
copy_shared_artifact;
pushd $_SCRIPT_DIR; pushd $_SCRIPT_DIR;
@ -16,7 +17,7 @@ cargo watch \
--why \ --why \
-i "_tmp*" \ -i "_tmp*" \
-x "build $CARGO_PARAMS" \ -x "build $CARGO_PARAMS" \
-s "./build" \ -s "./build $RENDER_TARGET" \
-s "echo 'DONE\n'"; -s "echo 'DONE\n'";
popd popd