From 7806390f14402bfef6a0f0dddd4127aa2615dba4 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 28 Jul 2026 09:40:52 +0200 Subject: [PATCH] :sparkles: Add headless wasm render backend to the exporter --- backend/scripts/_env | 15 + docker/images/Dockerfile.exporter | 3 +- exporter/deps.edn | 3 + exporter/package.json | 1 + exporter/scripts/build | 23 ++ exporter/src/app/config.cljs | 7 +- exporter/src/app/core.cljs | 7 + exporter/src/app/renderer.cljs | 30 +- exporter/src/app/renderer/wasm.cljs | 460 +++++++++++++++++++++++++++ exporter/src/app/wasm.cljs | 257 +++++++++++++++ exporter/src/app/wasm/gfonts.cljs | 43 +++ exporter/src/app/wasm/serialize.cljs | 46 +++ exporter/src/app/wasm/text.cljs | 54 ++++ 13 files changed, 940 insertions(+), 9 deletions(-) create mode 100644 exporter/src/app/renderer/wasm.cljs create mode 100644 exporter/src/app/wasm.cljs create mode 100644 exporter/src/app/wasm/gfonts.cljs create mode 100644 exporter/src/app/wasm/serialize.cljs create mode 100644 exporter/src/app/wasm/text.cljs diff --git a/backend/scripts/_env b/backend/scripts/_env index 04dcf1a724..fa6d0ebe84 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -77,6 +77,21 @@ export PENPOT_USER_FEEDBACK_DESTINATION="support@example.com" export PENPOT_ADMIN_CONSOLE_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 \ diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 7c0b1a14ff..853b1ca3cf 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -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; \ diff --git a/exporter/deps.edn b/exporter/deps.edn index 9495142719..5197dd2173 100644 --- a/exporter/deps.edn +++ b/exporter/deps.edn @@ -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"} diff --git a/exporter/package.json b/exporter/package.json index 83518eabee..77ed6b42c1 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -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/", diff --git a/exporter/scripts/build b/exporter/scripts/build index 40eba8f44c..87b407c18b 100755 --- a/exporter/scripts/build +++ b/exporter/scripts/build @@ -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 < 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)))) diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs new file mode 100644 index 0000000000..b092109202 --- /dev/null +++ b/exporter/src/app/wasm.cljs @@ -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 :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)))) diff --git a/exporter/src/app/wasm/gfonts.cljs b/exporter/src/app/wasm/gfonts.cljs new file mode 100644 index 0000000000..1ef5b7dd51 --- /dev/null +++ b/exporter/src/app/wasm/gfonts.cljs @@ -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-` 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-` 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)))) diff --git a/exporter/src/app/wasm/serialize.cljs b/exporter/src/app/wasm/serialize.cljs new file mode 100644 index 0000000000..5bcd67fc1d --- /dev/null +++ b/exporter/src/app/wasm/serialize.cljs @@ -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))) diff --git a/exporter/src/app/wasm/text.cljs b/exporter/src/app/wasm/text.cljs new file mode 100644 index 0000000000..4399a0af89 --- /dev/null +++ b/exporter/src/app/wasm/text.cljs @@ -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")))