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

This commit is contained in:
Elena Torro 2026-07-29 10:31:10 +02:00
parent c80f6e374c
commit 1f510ae22c
58 changed files with 538 additions and 407 deletions

2
.gitignore vendored
View File

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

View File

@ -12,6 +12,10 @@ export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
# 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.
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+
# 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
@ -73,21 +77,6 @@ export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com"
export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console
# Headless WASM export: render `:is-wasm` exports via the in-process Skia/WASM
# pipeline (no browser). Reads the render-wasm artifact from PENPOT_WASM_DIR,
# defaulting to ../frontend/resources/public/js (render-wasm.js/.wasm) — which
# is where render-wasm/build leaves it in devenv. The docker image ships the
# artifact inside the exporter bundle and points PENPOT_WASM_DIR at it.
#
# PENPOT_INTERNAL_URI must be set with it: the devenv public-uri is https with
# a self-signed cert that undici rejects. Port 3450 is the same backend over
# plain HTTP, and follows PENPOT_PUBLIC_HTTP_PORT. Leave it unset otherwise —
# the exporter falls back to public-uri, which the Playwright backends want.
# export PENPOT_WASM_HEADLESS=true
# export PENPOT_INTERNAL_URI=http://localhost:3450
# export PENPOT_WASM_DIR=../frontend/resources/public/js
# export PENPOT_WASM_IMAGE_CACHE_MB=256
export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-Djdk.attach.allowAttachSelf \
@ -112,5 +101,3 @@ function setup_minio() {
mc alias set penpot-s3/ "${PENPOT_OBJECTS_STORAGE_S3_ENDPOINT}" minioadmin minioadmin -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"}
environ/environ {:mvn/version "1.2.0"}}
:paths ["src" "vendor" "target/classes"]
:paths ["src" "vendor" "resources" "target/classes"]
:aliases
{:dev
{:extra-deps

View File

@ -178,6 +178,10 @@
:stroke-path
:stroke-per-side
;; Exporter only: uses render-wasm for export instead of browser
;; renderer.
:wasm-export
:custom-shortcuts})
(def all-flags

View File

@ -0,0 +1,19 @@
# `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, plus the google fonts catalog (baked from
`common/resources/fonts/gfonts.*.json`).
The workspace drives it from `app.render-wasm.*`, the headless exporter from
`app.wasm.*` — same code underneath, so the two cannot drift.
`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
(ns app.render-wasm.api.props
(ns app.common.render-wasm.api.props
"Browser-free WASM shape property setters, shared by the workspace render
orchestrator (`app.render-wasm.api`) and the headless exporter
(`app.wasm.serialize`).
@ -15,15 +15,15 @@
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
(:require
[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.impl :as types.fills.impl]
[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]))
[app.common.types.path :as path]))
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))

View File

@ -4,7 +4,7 @@
;;
;; 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.
This module provides a single WASM call to set all base shape properties,
@ -13,11 +13,11 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[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]))
[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]))
;; 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,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.fallback-fonts
(ns app.common.render-wasm.fallback-fonts
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
which (google) fallback fonts cover them. Pure data + pure fns — no browser
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the

View File

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

View File

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

View File

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

View File

@ -4,11 +4,11 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.mem
(ns app.common.render-wasm.mem
(:require
[app.common.buffer :as buf]
[app.render-wasm.helpers :as h]
[app.render-wasm.wasm :as wasm]))
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.wasm :as wasm]))
(defn ->offset-32
"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
(ns app.render-wasm.mem.heap32
(ns app.common.render-wasm.mem.heap32
"A memory write helpers that uses 32 bits addressed offsets."
(:require
[app.common.data.macros :as dm]

View File

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

View File

@ -4,7 +4,7 @@
;;
;; 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
shape into the WASM design state.
@ -24,8 +24,8 @@
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
dispatching per changed key through the same underlying `props` setters."
(:require
[app.render-wasm.api.props :as props]
[app.render-wasm.api.shapes :as shapes]))
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.api.shapes :as shapes]))
(defn serialize-shape!
"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
(ns app.render-wasm.serializers
(ns app.common.render-wasm.serializers
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[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.shape-tree :as ctst]
[app.common.uuid :as uuid]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]
[cuerdas.core :as str]))
(defn u8

View File

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

View File

@ -4,23 +4,24 @@
;;
;; 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
state. The binary layout ([num-spans][paragraph attrs][span attrs][text]) is
identical for the workspace and the headless exporter — only *font resolution*
differs (the workspace uses the loaded fonts DB; the exporter uses its gfonts
catalog + custom variants). So the byte-writing lives here and font resolution
is injected via the `opts` map passed to `write-shape-text!`.
identical for the workspace and the headless exporter, and so is the font-id
-> uuid mapping (`gf/font-id->uuid`). Only *variant* resolution differs —
the workspace has a loaded fonts DB, the exporter does not — so that part is
injected via the `opts` map passed to `write-shape-text!`.
Fully portable (no store/DOM/React), so it runs under Node too."
(:require
[app.common.data :as d]
[app.common.render-wasm.gfonts :as gf]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.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]))
(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
shape via `_set_shape_text_content`.
`opts` injects host-specific font resolution:
- `:normalize-font-id` (string font-id -> wasm uuid) — required in practice,
`opts` injects host-specific font handling:
- `:normalize-font-id` (string font-id -> wasm uuid) defaults to the shared
`gf/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
fonts DB (workspace); default to identity (the exporter resolves variants
differently / not at all)."
[spans paragraph text {:keys [normalize-font-id normalize-paragraph normalize-span]
:or {normalize-font-id identity
:or {normalize-font-id gf/font-id->uuid
normalize-paragraph identity
normalize-span (fn [span _paragraph] span)}}]
(let [paragraph (normalize-paragraph paragraph)

View File

@ -4,8 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.wasm
(:require ["./api/shared.js" :as shared]))
(ns app.common.render-wasm.wasm)
(defonce internal-frame-id nil)
(defonce internal-frame-type 0)
@ -65,41 +64,19 @@
(set! gl-context nil)
(set! context-initialized? false))
(defonce serializers
#js {:raster-format shared/RasterFormat
:blur-type shared/RawBlurType
:blend-mode shared/RawBlendMode
:bool-type shared/RawBoolType
:font-style shared/RawFontStyle
:flex-direction shared/RawFlexDirection
:grid-direction shared/RawGridDirection
:grow-type shared/RawGrowType
:align-items shared/RawAlignItems
:align-self shared/RawAlignSelf
:align-content shared/RawAlignContent
:justify-items shared/RawJustifyItems
:justify-content shared/RawJustifyContent
:justify-self shared/RawJustifySelf
: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})
(defonce serializers nil)
(defn init-serializers!
"Binds the enum table produced by the `enums/serializers` macro."
[table]
(let [missing (array)]
(doseq [key (js/Object.keys table)]
(when (undefined? (unchecked-get table key))
(.push missing key)))
(when (pos? (alength missing))
(throw (ex-info "stale or incomplete render-wasm shared.js"
{:missing (vec missing)})))
(set! serializers table)))

View File

@ -5,8 +5,7 @@ ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/imagick/bin:$PATH \
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers \
PENPOT_WASM_DIR=/opt/penpot/exporter/js
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers
RUN set -ex; \
apt-get -qq update; \

View File

@ -1,9 +1,6 @@
{:paths ["src" "vendor" "resources" "test"]
:deps
{penpot/common {:local/root "../common"}
;; For the CLJS side of the render-wasm binary protocol (`app.render-wasm.*`),
;; shared with the workspace so the two cannot drift.
penpot/frontend {:local/root "../frontend"}
org.clojure/clojure {:mvn/version "1.12.5"}
binaryage/devtools {:mvn/version "1.0.7"}
metosin/reitit-core {:mvn/version "0.10.1"}

View File

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

View File

@ -8,18 +8,10 @@ export NODE_ENV=production;
corepack enable;
corepack install || exit 1;
pnpm install || exit 1;
# The exporter compiles against render-wasm's `shared.js` and ships its `.wasm`
# in the bundle, so build it here rather than depending on some other module's
# build having run first — same as `frontend/scripts/build` does.
pnpm run build:wasm;
# Both are produced by `render-wasm/build` and are git-ignored. Verify they
# arrived where this build expects: `shared.js` is a compile dependency, so a
# path drift would surface as an opaque dependency trace from `pnpm run build`
# below rather than the message here.
WASM_SRC="../frontend/resources/public/js";
WASM_SHARED="../frontend/common/src/app/render_wasm_common/api/shared.js";
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;
@ -37,9 +29,8 @@ cp pnpm-workspace.yaml target/;
cp package.json target/;
touch target/pnpm-workspace.yaml;
# Ship the artifact in the bundle; the docker image points PENPOT_WASM_DIR here.
mkdir -p target/js;
cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/js/;
mkdir -p target/$WASM_SRC;
cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/$WASM_SRC/;
cat <<EOF | tee target/setup
#/usr/bin/env bash

View File

@ -42,12 +42,7 @@
[:redis-uri {:optional true} :string]
[:tempdir {:optional true} :string]
[:browser-pool-max {:optional true} ::sm/int]
[:browser-pool-min {:optional true} ::sm/int]
;; `:wasm-dir` points at the built render-wasm artifact (render-wasm.js/.wasm).
[:wasm-headless {:optional true} :boolean]
[:wasm-dir {:optional true} :string]
;; Byte budget for the WASM image cache; LRU-evicted between requests.
[:wasm-image-cache-mb {:optional true} ::sm/int]])
[:browser-pool-min {:optional true} ::sm/int]])
(def ^:private decode-config
(sm/decoder schema:config sm/string-transformer))

View File

@ -24,12 +24,12 @@
:public-uri (str (cf/get :public-uri))
:internal-uri (str (cf/get-internal-uri))
:version (:full cf/version))
(when (cf/get :wasm-headless)
(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)))
:wasm-dir wasm/artifact-dir
:image-cache-mb wasm/image-cache-mb))
(p/do!
(bwr/init)
(redis/init)

View File

@ -42,16 +42,12 @@
[{:keys [type is-wasm] :as params} on-object]
(us/verify ::render-params params)
(us/verify fn? on-object)
;; Opt-in: `:is-wasm` exports render in-process when `:wasm-headless` is on.
;; Off by default, so existing behavior is unchanged. `:svg` stays on the
;; browser path regardless — it needs vector markup, not a raster.
(let [headless? (and is-wasm
(cf/get :wasm-headless)
(not= :svg type))]
(let [wasm-export? (contains? cf/flags :wasm-export)
headless? (and is-wasm wasm-export? (not= :svg type))]
(when is-wasm
(l/info :hint "render"
:type type
:wasm-headless (boolean (cf/get :wasm-headless))
:wasm-export wasm-export?
:backend (if headless? "wasm" "browser")))
(if headless?
(rw/render params on-object)

View File

@ -6,8 +6,7 @@
(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. Drop-in
alternative to the Playwright backends, selected from `app.renderer`.
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.
@ -26,6 +25,9 @@
[app.common.geom.point]
[app.common.geom.rect]
[app.common.logging :as l]
[app.common.render-wasm.fallback-fonts :as fbf]
[app.common.render-wasm.gfonts :as gf]
[app.common.render-wasm.resources :as resources]
[app.common.transit :as t]
[app.common.types.fills.impl]
[app.common.types.objects-map]
@ -34,12 +36,9 @@
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.resources :as resources]
[app.util.mime :as mime]
[app.util.shell :as sh]
[app.wasm :as wasm]
[app.wasm.gfonts :as gfonts]
[app.wasm.serialize :as serialize]
[cuerdas.core :as str]
[promesa.core :as p]))
@ -72,9 +71,8 @@
;; --- backend endpoints
;;
;; Every fetch targets the internal endpoint (falling back to public-uri), same
;; as the Playwright backends: in a deployment the exporter reaches the backend
;; over the container network, not the public ingress.
;; 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."
@ -146,9 +144,9 @@
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))
:uri uri
:file-id (str file-id)
:page-id (str page-id))
(->> (fetch! uri #js {:method "POST" :headers headers :body body})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
@ -169,7 +167,7 @@
;;
;; 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 `app.wasm.gfonts` catalog; builtin falls back to
;; google fonts through the shared `app.common.render-wasm.gfonts` catalog; builtin falls back to
;; the bundled default.
(defn- fetch-font-variants
@ -209,11 +207,10 @@
(p/resolved nil))))))
(defn- gfont-proxy-url
"Rewrites a gstatic ttf url to the local gfonts proxy (mirrors the browser's
`google-font-ttf-url`)."
"Rewrites a gstatic ttf url to the local gfonts proxy (same rewrite as the
browser's `google-font-ttf-url`)."
[ttf-url]
(let [proxy (internal-uri "internal/gfonts/font/")]
(str/replace ttf-url "https://fonts.gstatic.com/s/" proxy)))
(gf/gstatic->proxy-url ttf-url (internal-uri "internal/gfonts/font")))
(defn- fetch-gfont-bytes
"Downloads a google font TTF through the local gfonts proxy."
@ -247,7 +244,7 @@
(d/seek (fn [v] (= (:font-id v) font-uuid)) variants))]
(if-let [ttf-id (:ttf-file-id variant)]
(fetch-asset-bytes ttf-id params)
(if-let [gurl (gfonts/resolve-ttf-url font-uuid weight style)]
(if-let [gurl (gf/resolve-ttf-url font-uuid weight style)]
(fetch-gfont-bytes gurl)
(p/resolved nil))))))
@ -286,8 +283,8 @@
(let [cache-key [font-id weight style]]
(if-let [bytes (get @fallback-font-bytes* cache-key)]
(p/resolved bytes)
(let [font-uuid (gfonts/gfont-id->uuid font-id)
ttf-url (some-> font-uuid (gfonts/resolve-ttf-url weight style))]
(let [font-uuid (gf/gfont-id->uuid font-id)
ttf-url (some-> font-uuid (gf/resolve-ttf-url weight style))]
(if ttf-url
(->> (fetch-gfont-bytes ttf-url)
(p/fmap (fn [buf]
@ -299,7 +296,7 @@
[scene]
(->> (scene-fallback-fonts scene)
(map (fn [{:keys [font-id weight style is-emoji is-fallback] :as font}]
(if-let [font-uuid (gfonts/gfont-id->uuid font-id)]
(if-let [font-uuid (gf/gfont-id->uuid font-id)]
(->> (fetch-fallback-font-bytes font)
(p/fmap (fn [buf]
(if buf
@ -343,15 +340,15 @@
(defn- provision-images!
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.render-wasm.resources`). Unlike fonts,
text-span fills, enumerated by `app.common.render-wasm.resources`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
and repeated exports of a file reuse them."
[scene params]
(let [all-ids (resources/scene-image-ids scene)
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)))
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
@ -359,8 +356,8 @@
(if buf
(do
(l/dbg :hint "wasm render: image stored"
:media-id (str image-id)
:bytes (.-byteLength ^js buf))
: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))))))))
@ -385,15 +382,8 @@
:backend "skia-wasm"
:bytes (.-length bytes))
bytes)
;; The export type doubles as the encoder format: Skia encodes png/jpeg/webp
;; natively, so webp needs no imagemagick pass like the Playwright backend.
(wasm/render-shape-raster id scale type)))
;; NOTE: `prepare-exports` splits an export into partitions of 50 objects, each
;; arriving as its own request, so every partition re-fetches and re-serializes
;; the whole page scene. Acceptable while the module is a single shared instance;
;; revisit with module pooling.
(defn- render*
[{:keys [scale type objects] :as params} on-object]
(l/dbg :hint "wasm render: start"
@ -429,7 +419,7 @@
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))
: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`
@ -439,7 +429,7 @@
(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))]
(let [evicted (wasm/evict-images! wasm/image-cache-mb)]
(when (pos? evicted)
(l/info :hint "wasm render: evicted cached images" :count evicted)))
result))

View File

@ -18,12 +18,13 @@
["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]
[app.config :as cf]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]
;; Required for side effects: binds the generated enums.
[app.wasm.enums]
[promesa.core :as p]
[shadow.esm :refer [dynamic-import]]))
@ -37,17 +38,14 @@
;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32].
(def ^:private FONT-ENTRY-BYTES 24)
(defn artifact-dir
"Directory holding the built render-wasm artifact. The docker image points
`PENPOT_WASM_DIR` at the copy inside the exporter bundle; the default is
where `render-wasm/build` leaves it in devenv."
[]
(cf/get :wasm-dir "../frontend/resources/public/js"))
(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")
(defn image-cache-mb
(def image-cache-mb
"Byte budget (MB) the image store is trimmed to between requests."
[]
(cf/get :wasm-image-cache-mb 256))
256)
(defn- read-result-bytes
"Reads `len` bytes from the WASM heap starting at `offset`, copying them out
@ -63,7 +61,7 @@
Idempotent-ish: callers should hold the returned module."
([] (init! default-viewport-width default-viewport-height))
([width height]
(let [dir (artifact-dir)
(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)]

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

@ -1,43 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.gfonts
"Compile-time Google Fonts catalog for the headless exporter, built from the
same `gfonts.json` the browser uses via the `preload-gfonts` .clj macro (a
macro namespace, so requiring it does not pull in `app.main.fonts` itself).
`parse-gfont` assigns each font a `uuid/random` at macro-expansion time, so
this catalog MUST stay the single shared instance: `app.wasm.text` maps
`gfont-<slug>` to the uuid and `app.renderer.wasm` maps it back to a ttf url."
(:require-macros [app.main.fonts :refer [preload-gfonts]]))
(def ^:private 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 (build-stable) catalog uuid, or nil."
[gfont-id]
(:uuid (get by-id gfont-id)))
(defn resolve-ttf-url
"Given a google font uuid + numeric weight + style int (0 = normal, else
italic), returns the variant's gstatic ttf url (or nil if not a google font /
no variants). Degrades weight+style -> weight -> first variant."
[font-uuid weight style]
(when-let [font (get by-uuid font-uuid)]
(let [w (str weight)
s (if (zero? style) "normal" "italic")
variants (:variants font)
variant (or (some (fn [v] (when (and (= (:weight v) w) (= (:style v) s)) v)) variants)
(some (fn [v] (when (= (:weight v) w) v)) variants)
(first variants))]
(:ttf-url variant))))

View File

@ -14,10 +14,10 @@
Covers everything except svg-raw. Image bytes and fonts are provisioned
separately by `app.renderer.wasm`."
(:require
[app.render-wasm.api.props :as props]
[app.render-wasm.helpers :as h]
[app.render-wasm.serialize-shape :as serialize-shape]
[app.render-wasm.wasm :as wasm]
[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!

View File

@ -5,39 +5,21 @@
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.wasm.text
"Browser-free text-content serialization for the headless exporter. The
binary layout is shared via `app.render-wasm.text-content`; only font-id
resolution is local, since the exporter has no fonts DB."
"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.uuid :as uuid]
[app.render-wasm.helpers :as h]
[app.render-wasm.serializers :as sr]
[app.render-wasm.text-content :as tc]
[app.render-wasm.wasm :as wasm]
[app.wasm.gfonts :as gfonts]
[cuerdas.core :as str]))
(defn- normalize-font-id
"Maps a content font-id to its wasm uuid. The provisioning side keys on the
same uuid."
[font-id]
(try
(cond
(str/starts-with? font-id "gfont-")
(or (gfonts/gfont-id->uuid font-id) uuid/zero)
(str/includes? font-id "-")
(let [no-prefix (subs font-id (inc (str/index-of font-id "-")))]
(if (str/blank? no-prefix) uuid/zero (uuid/parse no-prefix)))
:else uuid/zero)
(catch :default _ uuid/zero)))
[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 is the shared `text-content/write-shape-text!`; only font-id
resolution is injected."
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")
@ -49,6 +31,5 @@
(let [spans (get paragraph :children)]
(when (seq spans)
(let [text (apply str (map :text spans))]
(tc/write-shape-text! spans paragraph text
{:normalize-font-id normalize-font-id}))))))
(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"]
:deps
{penpot/common
{;; Carries `app.common.render-wasm.*`, shared with the headless exporter.
penpot/common
{:local/root "../common"}
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:assets": "node ./scripts/build-storybook-assets.js",
"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:main": "clojure -M:dev:shadow-cljs release main 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
pushd ../render-wasm;
./build
./build frontend
popd
pushd ../mcp;

View File

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

View File

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

View File

@ -6,11 +6,11 @@
(ns app.main.fonts
"Fonts management and loading logic."
(:require-macros [app.main.fonts :refer [preload-gfonts]])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.common.render-wasm.gfonts :as gf]
[app.common.types.text :as txt]
[app.common.uri :as u]
[app.config :as cf]
@ -25,8 +25,7 @@
(log/set-level! :warn)
(def google-fonts
(preload-gfonts "fonts/gfonts.2025.11.28.json"))
(def google-fonts gf/catalog)
(def local-fonts
[{:id "sourcesanspro"
@ -266,8 +265,7 @@
(defn- process-gfont-css
[css]
(let [base (u/join cf/public-uri "internal/gfonts/font")]
(str/replace css "https://fonts.gstatic.com/s" (dm/str base))))
(gf/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font")))
(defn- fetch-gfont-css
[url]
@ -397,42 +395,12 @@
(defn find-closest-variant
"Find the closest font weight variant in `font` for `target-weight` with optional `target-style` match.
When exactly between two weights, choose the higher one."
When exactly between two weights, choose the higher one.
The algorithm lives in `app.common.render-wasm.gfonts` so the headless exporter resolves the
same variant for the same text."
[font target-weight target-style]
(when-let [target-weight (d/parse-integer target-weight)]
(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))))
(gf/closest-variant (:variants font []) target-weight target-style))
;; Font embedding functions
(defn get-node-fonts

View File

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

View File

@ -15,6 +15,14 @@
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[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.fills :as types.fills]
[app.common.types.path :as path]
@ -31,23 +39,17 @@
[app.main.router :as rt]
[app.main.store :as st]
[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.props :as props]
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
[app.render-wasm.deserializers :as dr]
[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.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.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.functions :as fns]

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

@ -9,14 +9,15 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as log]
[app.common.render-wasm.fallback-fonts :as fbf]
[app.common.render-wasm.gfonts :as gf]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.fonts :as fonts]
[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.timers :as tm]
[beicon.v2.core :as rx]
@ -39,23 +40,6 @@
(def ^:private default-line-height 1.2)
(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
@ -75,15 +59,6 @@
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
[font-uuid]
(if (= font-uuid uuid/zero)
@ -104,7 +79,7 @@
:google
font-id
:custom
(let [font-uuid (custom-font-id->uuid font-id)
(let [font-uuid (gf/font-id->uuid font-id)
matching-font (some (fn [[_ font]]
(and (= (:font-id font) font-uuid)
(= (str (:font-weight font)) (str font-weight))
@ -194,9 +169,8 @@
(defn- google-font-ttf-url
[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)]
(str/replace ttf-url "https://fonts.gstatic.com/s/" (u/join cf/public-uri "internal/gfonts/font/"))
nil)))
(when-let [ttf-url (:ttf-url variant)]
(gf/gstatic->proxy-url ttf-url (u/join cf/public-uri "internal/gfonts/font")))))
(defn- font-id->ttf-url
[font-id asset-id font-variant-id font-weight font-style]
@ -245,18 +219,6 @@
"italic" 1
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
[span paragraph]
(let [font-id (:font-id span)
@ -358,7 +320,7 @@
emoji? (get font :is-emoji false)
fallback? (get font :is-fallback false)
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 (gf/font-id->uuid font-id)
raw-weight (or (:weight font-data) font-weight-fallback)
weight (serialize-font-weight raw-weight)
style (cond

View File

@ -6,21 +6,20 @@
(ns app.render-wasm.api.texts
(:require
[app.render-wasm.api.fonts :as f]
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.text-content :as tc]))
[app.common.render-wasm.fallback-fonts :as fbf]
[app.common.render-wasm.text-content :as tc]
[app.render-wasm.api.fonts :as f]))
(defn write-shape-text
"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]
(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}))
;; Emoji/script detection lives in the host-agnostic
;; `app.render-wasm.fallback-fonts`; kept re-exported here for existing
;; `app.common.render-wasm.fallback-fonts`; kept re-exported here for existing
;; workspace callers.
(def contains-emoji? fbf/contains-emoji?)
(def collect-used-languages fbf/collect-used-languages)

View File

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

View File

@ -7,16 +7,18 @@
(ns app.render-wasm.text-editor
"Text editor WASM bindings"
(: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.text :as txt]
[app.common.uuid :as uuid]
[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.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.dom :as dom]))

View File

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

View File

@ -15,6 +15,9 @@
[app.common.json :as json]
[app.common.logging :as l]
[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.types.component :as ctk]
[app.common.types.components-list :as ctkl]
@ -36,9 +39,6 @@
[app.main.errors :as errors]
[app.main.repo :as rp]
[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.dom :as dom]
[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
in :fetching) and are permanently stuck with fallback-font layout metrics."
(: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.mem :as mem]
[app.render-wasm.wasm :as wasm]
[beicon.v2.core :as rx]
[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
to an inverted caret (white painted with a Difference blend)."
(: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]
[cljs.test :as t :include-macros true]))

View File

@ -42,3 +42,7 @@ opt-level = 3
lto = "fat"
strip = true
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.
### 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)

View File

@ -1,15 +1,25 @@
#!/usr/bin/env bash
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
export BUILD_MODE="release";
else
export BUILD_MODE=${1:-debug};
export BUILD_MODE=${BUILD_MODE:-debug};
fi
export BUILD_NAME="${BUILD_NAME:-render-wasm}"
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"}
# 256 MB of initial heap to perform less
@ -51,9 +61,21 @@ export EM_CACHE="/tmp/emsdk_cache";
export CARGO_PARAMS="${@:2}";
export CARGO_PROFILE_DIR="debug";
if [ "$BUILD_MODE" = "release" ]; then
export CARGO_PARAMS="--release $CARGO_PARAMS"
export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS"
case "$RENDER_TARGET" in
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
# TODO: Extra parameters that could be good to look into:
# -gseparate-dwarf
@ -62,6 +84,12 @@ else
export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS"
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 {
cargo clean;
}
@ -78,26 +106,48 @@ function build {
function copy_artifacts {
DEST=$1;
SRC="$CARGO_TARGET_DIR/$CARGO_BUILD_TARGET/$CARGO_PROFILE_DIR";
mkdir -p $DEST;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.js $DEST/$BUILD_NAME.js;
cp target/wasm32-unknown-emscripten/$BUILD_MODE/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
if [ -f target/wasm32-unknown-emscripten/$BUILD_MODE/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.js $DEST/$BUILD_NAME.js;
cp $SRC/render_wasm.wasm $DEST/$BUILD_NAME.wasm;
if [ -f $SRC/render_wasm.wasm.map ]; then
cp $SRC/render_wasm.wasm.map $DEST/$BUILD_NAME.wasm.map;
fi
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 \
--log-level=error \
--outfile=$DEST/worker/render.js \
--platform=neutral \
--format=iife \
--global-name=WasmModule;
# The worker bundle is a browser concern; the exporter imports the ESM
# module directly under Node.
if [ "$RENDER_TARGET" = "frontend" ]; then
pnpm exec esbuild $SRC/render_wasm.js \
--log-level=error \
--outfile=$DEST/worker/render.js \
--platform=neutral \
--format=iife \
--global-name=WasmModule;
fi
}
function copy_shared_artifact {
SHARED_FILE=$(find target/wasm32-unknown-emscripten -name render_wasm_shared.js | head -n 1);
cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js;
DEST=$1;
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
# 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
_SCRIPT_DIR=$(dirname $0);
pushd $_SCRIPT_DIR;
. ./_build_env
@ -11,8 +29,7 @@ set -ex;
setup;
build;
copy_artifacts "../frontend/resources/public/js";
copy_shared_artifact;
copy_target_artifacts;
exit $?;

View File

@ -8,7 +8,7 @@ if [[ "$1" == "--debug" ]]; then
set -x
fi
. ./_build_env
. ./_build_env frontend
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"}

View File

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

View File

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