Add headless wasm render backend to the exporter

This commit is contained in:
Elena Torro 2026-07-28 09:40:52 +02:00
parent b72536d312
commit c80f6e374c
13 changed files with 940 additions and 9 deletions

View File

@ -73,6 +73,21 @@ 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 \

View File

@ -5,7 +5,8 @@ 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
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers \
PENPOT_WASM_DIR=/opt/penpot/exporter/js
RUN set -ex; \
apt-get -qq update; \

View File

@ -1,6 +1,9 @@
{: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,6 +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": "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,6 +8,25 @@ 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";
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
# Build the application
@ -18,6 +37,10 @@ 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/;
cat <<EOF | tee target/setup
#/usr/bin/env bash
set -e;

View File

@ -42,7 +42,12 @@
[:redis-uri {:optional true} :string]
[:tempdir {:optional true} :string]
[:browser-pool-max {:optional true} ::sm/int]
[:browser-pool-min {: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]])
(def ^:private decode-config
(sm/decoder schema:config sm/string-transformer))

View File

@ -12,6 +12,7 @@
[app.config :as cf]
[app.http :as http]
[app.redis :as redis]
[app.wasm :as wasm]
[promesa.core :as p]))
(enable-console-print!)
@ -23,6 +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)
(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!
(bwr/init)
(redis/init)

View File

@ -7,10 +7,13 @@
(ns app.renderer
"Common renderer interface."
(:require
[app.common.logging :as l]
[app.common.spec :as us]
[app.config :as cf]
[app.renderer.bitmap :as rb]
[app.renderer.pdf :as rp]
[app.renderer.svg :as rs]
[app.renderer.wasm :as rw]
[cljs.spec.alpha :as s]))
(s/def ::name ::us/string)
@ -36,13 +39,26 @@
:opt-un [::is-wasm]))
(defn render
[{:keys [type] :as params} on-object]
[{:keys [type is-wasm] :as params} on-object]
(us/verify ::render-params params)
(us/verify fn? 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)))
;; 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))]
(when is-wasm
(l/info :hint "render"
:type type
:wasm-headless (boolean (cf/get :wasm-headless))
: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,460 @@
;; 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. Drop-in
alternative to the Playwright backends, selected from `app.renderer`.
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]
;; 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.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]))
;; --- 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), same
;; as the Playwright backends: in a deployment the exporter reaches the backend
;; over the container network, not the public ingress.
(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 page's `objects` map from the backend via the `get-page` RPC,
using the same auth the exporter uses elsewhere (management key + bearer)."
[{:keys [file-id page-id share-id token]}]
(let [headers (rpc-headers token)
;; share-id is an OPTIONAL uuid on the backend; it must be omitted when
;; absent, not sent as nil (nil fails the uuid schema).
body (t/encode-str (cond-> {:file-id file-id
:page-id page-id}
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))
(->> (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 `app.wasm.gfonts` catalog; builtin falls back to
;; the bundled default.
(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-asset-bytes
"Downloads a stored asset (font TTF) by id, returning a promise of an
ArrayBuffer (or nil)."
[asset-id {:keys [token]}]
(let [headers (asset-headers token)
uri (internal-uri (str "assets/by-id/" asset-id))]
(->> (fetch! uri #js {:method "GET" :headers headers})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(p/resolved nil))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: font asset fetch failed"
:asset-id (str asset-id) :uri uri
:detail (explain cause) :cause cause)
(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`)."
[ttf-url]
(let [proxy (internal-uri "internal/gfonts/font/")]
(str/replace ttf-url "https://fonts.gstatic.com/s/" proxy)))
(defn- fetch-gfont-bytes
"Downloads a google font TTF through the local gfonts proxy."
[ttf-url]
(let [uri (gfont-proxy-url ttf-url)]
(->> (fetch! uri #js {:method "GET"})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.arrayBuffer resp)
(p/resolved nil))))
(p/merr (fn [cause]
(l/warn :hint "wasm render: gfont fetch failed"
:url uri :detail (explain cause) :cause cause)
(p/resolved nil))))))
(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; google catalog after that; nil when nothing matches."
[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))]
(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)]
(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 fbf/contains-emoji? texts))
langs (reduce fbf/collect-used-languages #{} texts)]
(distinct
(cond-> (fbf/add-noto-fonts [] langs)
emoji? (fbf/add-emoji-font)))))
(defonce ^:private fallback-font-bytes* (atom {}))
(defn- fetch-fallback-font-bytes
"Downloads (and caches for the process lifetime) one fallback font's TTF.
Keyed 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]}]
(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))]
(if ttf-url
(->> (fetch-gfont-bytes ttf-url)
(p/fmap (fn [buf]
(when buf (swap! fallback-font-bytes* assoc cache-key buf))
buf)))
(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 (gfonts/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.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)))
(->> 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)
;; 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"
: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))))

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

@ -0,0 +1,257 @@
;; 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.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]
[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)
(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"))
(defn image-cache-mb
"Byte budget (MB) the image store is trimmed to between requests."
[]
(cf/get :wasm-image-cache-mb 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,43 @@
;; 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

@ -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.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.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,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.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."
(: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)))
(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."
[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
{:normalize-font-id normalize-font-id}))))))
(h/call wasm/internal-module "_update_shape_text_layout")))