Support multiple page PDF export over headless exporter

This commit is contained in:
Elena Torro 2026-06-29 18:23:50 +02:00
parent 54bef496f1
commit 2878d8d0f3
49 changed files with 2410 additions and 684 deletions

View File

@ -72,6 +72,12 @@ 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).
export PENPOT_WASM_HEADLESS=true
# export PENPOT_WASM_DIR=../frontend/resources/public/js
export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \
-Djdk.attach.allowAttachSelf \

View File

@ -1,9 +1,12 @@
{:paths ["src" "vendor" "resources" "test"]
{:paths ["src" "vendor" "resources" "test" "../frontend/src" "../frontend/resources"]
:deps
{penpot/common {:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.2"}
binaryage/devtools {:mvn/version "1.0.7"}
metosin/reitit-core {:mvn/version "0.9.1"}
;; Needed by the shared render-wasm font namespaces reused from ../frontend/src
;; (the font db uses an okulary atom so the editor keeps its reactivity).
funcool/okulary {:mvn/version "2022.04.11-16"}
}
:aliases
{:outdated

View File

@ -1,5 +1,10 @@
{:deps {:aliases [:dev]}
:source-paths ["src" "vendor" "../common"]
;; ../frontend/src is on the path so the exporter can reuse the *portable*
;; render-wasm serialization leaves (app.render-wasm.{wasm,mem,helpers,
;; serializers,api.shapes,...}). shadow-cljs compiles only the required
;; dependency graph, so the browser-coupled namespaces (api, api.webgl,
;; api.fonts) are never pulled in — the workspace build is unaffected.
:source-paths ["src" "vendor" "../common" "../frontend/src"]
:builds
{:main

View File

@ -40,7 +40,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]
;; Headless WASM export (no browser). When true, `:is-wasm` exports render
;; via the in-process Skia/WASM pipeline. `:wasm-dir` points at the built
;; render-wasm artifact (render-wasm.js/.wasm).
[:wasm-headless {:optional true} :boolean]
[:wasm-dir {:optional true} :string]])
(def ^:private decode-config
(sm/decoder schema:config sm/string-transformer))

View File

@ -48,7 +48,7 @@
(handle-export exchange (assoc params :exports exports))))
(defn handle-export
[{:keys [:request/auth-token] :as exchange} {:keys [exports name profile-id is-wasm] :as params}]
[{:keys [:request/auth-token :request/auth-scheme] :as exchange} {:keys [exports name profile-id is-wasm] :as params}]
(let [topic (str profile-id)
file-id (-> exports first :file-id)
@ -95,14 +95,14 @@
procs
(->> (seq exports)
(map #(rd/render (assoc % :is-wasm is-wasm) on-object)))]
(map #(rd/render (assoc % :is-wasm is-wasm :token-scheme auth-scheme) on-object)))]
(->> (p/all procs)
(p/fmap (fn [] @result-cache))
(p/mcat (partial join-pdf file-id))
(p/mcat (partial move-file resource))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/mcat (partial rsc/upload-resource auth-token auth-scheme))
(p/mcat (fn [resource]
(->> (sh/stat (:path resource))
(p/fmap #(merge resource %)))))

View File

@ -62,15 +62,18 @@
(handle-multiple-export exchange (assoc params :exports exports)))))
(defn- handle-single-export
[{:keys [:request/auth-token] :as exchange} {:keys [export name skip-children is-wasm] :as params}]
[{:keys [:request/auth-token :request/auth-scheme] :as exchange} {:keys [export name skip-children is-wasm] :as params}]
(let [resource (rsc/create (:type export) (or name (:name export)))
export (assoc export :skip-children skip-children :is-wasm (boolean is-wasm))]
export (assoc export
:skip-children skip-children
:is-wasm (boolean is-wasm)
:token-scheme auth-scheme)]
(->> (rd/render export
(fn [{:keys [path] :as object}]
(sh/move! path (:path resource))))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/mcat (partial rsc/upload-resource auth-token auth-scheme))
(p/fmap (fn [resource]
(dissoc resource :path)))
(p/fmap (fn [resource]
@ -81,7 +84,7 @@
(p/rejected cause))))))
(defn- handle-multiple-export
[{:keys [:request/auth-token] :as exchange} {:keys [exports wait profile-id name is-wasm] :as params}]
[{:keys [:request/auth-token :request/auth-scheme] :as exchange} {:keys [exports wait profile-id name is-wasm] :as params}]
(let [resource (rsc/create :zip (or name (-> exports first :name)))
total (count exports)
topic (str profile-id)
@ -112,11 +115,11 @@
(rsc/add-to-zip zip path (str/replace filename sanitize-file-regex "_")))
proc (->> exports
(map (fn [export] (rd/render (assoc export :is-wasm (boolean is-wasm)) append)))
(map (fn [export] (rd/render (assoc export :is-wasm (boolean is-wasm) :token-scheme auth-scheme) append)))
(p/all)
(p/mcat (fn [_] (rsc/close-zip zip)))
(p/fmap (constantly resource))
(p/mcat (partial rsc/upload-resource auth-token))
(p/mcat (partial rsc/upload-resource auth-token auth-scheme))
(p/fmap (fn [resource]
(let [data {:type :export-update
:name (:name resource)

View File

@ -7,7 +7,7 @@
(ns app.handlers.resources
"Temporal resources management."
(:require
["archiver" :as arc]
["archiver" :refer [ZipArchive]]
["node:fs" :as fs]
["node:fs/promises" :as fsp]
["node:path" :as path]
@ -17,6 +17,7 @@
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.util.auth :as auth]
[app.util.mime :as mime]
[app.util.shell :as sh]
[cljs.core :as c]
@ -41,7 +42,7 @@
(defn create-zip
[& {:keys [resource on-complete on-progress on-error]}]
(let [^js zip (new arc/ZipArchive)
(let [^js zip (new ZipArchive #js {})
^js out (fs/createWriteStream (:path resource))
on-complete (or on-complete (constantly nil))
progress (atom 0)]
@ -65,15 +66,17 @@
(.finalize ^js zip))))
(defn upload-resource
[auth-token resource]
(->> (fsp/readFile (:path resource))
([auth-token resource]
(upload-resource auth-token :bearer resource))
([auth-token auth-scheme resource]
(->> (fsp/readFile (:path resource))
(p/fmap (fn [buffer]
(new js/Blob #js [buffer] #js {:type (:mtype resource)})))
(p/mcat (fn [blob]
(let [fdata (new http/FormData)
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (str "Bearer " auth-token)}
"Authorization" (auth/header-value auth-token auth-scheme)}
request #js {:headers headers
:method "POST"
@ -95,4 +98,4 @@
(.text response))))
(p/fmap t/decode-str)
(p/fmap (fn [result]
(merge resource (dissoc result :id))))))
(merge resource (dissoc result :id)))))))

View File

@ -120,10 +120,17 @@
(p/catch (fn [cause] (on-error cause exchange))))))
(defn- wrap-auth
"Resolves the auth token from the session cookie and records its scheme
(`:request/auth-scheme` is always `:cookie` here) so downstream backend
calls can forward it appropriately. Header-based API authentication is
deliberately NOT accepted on this branch — external access to /api/export
ships separately (export-api branch)."
[handler cookie-name]
(fn [{:keys [:request/cookies] :as exchange}]
(let [token (.get ^js cookies cookie-name)]
(handler (cond-> exchange token (assoc :request/auth-token token))))))
(handler (cond-> exchange
(not (str/blank? token)) (-> (assoc :request/auth-token token)
(assoc :request/auth-scheme :cookie)))))))
(defn- wrap-health
"Add /healthz entry point intercept."

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,24 @@
: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 headless path: when an export is flagged `:is-wasm` AND the
;; `:wasm-headless` config is enabled, render with the in-process Skia/WASM
;; pipeline (no browser). Off by default, so existing behavior is unchanged.
(let [headless? (and is-wasm (cf/get :wasm-headless))]
(l/info :hint "render"
:type type
:is-wasm (boolean is-wasm)
: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,438 @@
;; 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 running in this Node process, with no browser and no WebGL.
Drop-in alternative to `app.renderer.bitmap`/`pdf` (the Playwright
backends), selected from `app.renderer` when an export is flagged
headless. Pipeline per request:
init module (once) -> fetch shape bundle (backend RPC)
-> serialize scene
-> provision resources (team/google fonts + fallback fonts + images,
enumerated by the host-agnostic app.render-wasm.{resources,fallback-fonts})
-> relayout text (real font metrics) -> render each object -> tempfile
NOTE (current slice): single shared WASM design state, so requests are
serialized one at a time (no pooling yet). PNG + PDF are wired; jpeg/webp
still need the PNG->format conversion that `bitmap` does."
(:require
["node:fs" :as fs]
["undici" :as http]
;; Loaded for side effects: these namespaces register the transit
;; read-handlers for penpot file-data types (#penpot/shape, objects-map,
;; point, matrix, rect, path, fills). Without them, `get-page` responses
;; decode shapes into opaque TaggedValues with nil :id.
[app.common.geom.matrix]
[app.common.geom.point]
[app.common.geom.rect]
[app.common.types.fills.impl]
[app.common.types.objects-map]
[app.common.types.path.impl]
[app.common.types.shape]
[app.common.data :as d]
[app.common.logging :as l]
[app.common.transit :as t]
[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.auth :as auth]
[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!))))
;; --- serialize access to the single shared WASM module ---
;;
;; There is one shared WASM instance (one design state, one global mem buffer),
;; but `handle-multiple-export` fans out partitions concurrently (`p/all`). This
;; promise queue runs module-touching work one task at a time so concurrent
;; exports can't interleave their serialize/render/alloc on the shared state.
(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))
;; --- 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 token-scheme]}]
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"Content-Type" "application/transit+json"
"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (auth/header-value token token-scheme)}
;; 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 (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/rpc/command/get-page")
(str))]
(l/info :hint "wasm render: get-page"
:uri uri
:file-id (str file-id)
:page-id (str page-id)
:auth-scheme (or token-scheme :bearer)
:token-len (count (str token)))
(->> (http/fetch uri #js {:method "POST" :headers headers :body body :dispatcher agent})
(p/mcat (fn [^js resp]
(if (= 200 (.-status resp))
(.text resp)
;; Surface the backend's actual error body so we can tell
;; auth (401) from bad params (400/404).
(->> (.text resp)
(p/mcat (fn [resp-body]
(l/error :hint "wasm render: get-page failed"
: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
;;
;; Custom (team) fonts: the exporter's text serializer keeps their real font
;; uuid, so `wasm/fonts-for-shape` reports it. We fetch the file's team font
;; variants once (get-font-variants RPC), map uuid+weight+style -> ttf asset id,
;; and download the TTF from `assets/by-id/<id>`. Google/builtin fonts still fall
;; back to the bundled default (they need the gfonts catalog — a later slice).
(defn- fetch-font-variants
"Fetches the file's team (custom) font variants via the get-font-variants RPC.
Returns a promise of the variant vector (or nil on failure — fonts then just
fall back, they don't fail the export)."
[{:keys [file-id share-id token token-scheme]}]
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"Content-Type" "application/transit+json"
"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (auth/header-value token token-scheme)}
body (t/encode-str (cond-> {:file-id file-id}
share-id (assoc :share-id share-id)))
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "api/rpc/command/get-font-variants")
(str))]
(->> (http/fetch uri #js {:method "POST" :headers headers :body body :dispatcher agent})
(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" :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 token-scheme]}]
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers (clj->js (merge {"X-Shared-Key" (str "exporter " cf/management-key)}
(auth/asset-headers token token-scheme)))
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join (str "assets/by-id/" asset-id))
(str))]
(->> (http/fetch uri #js {:method "GET" :headers headers :dispatcher agent})
(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) :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 (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join "internal/gfonts/font/")
(str))]
(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 [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
uri (gfont-proxy-url ttf-url)]
(->> (http/fetch uri #js {:method "GET" :dispatcher agent})
(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 :cause cause)
(p/resolved nil))))))
(defn- make-resolve-font
"Builds a `resolve-font` fn (family map -> promise of TTF bytes). Tries the
file's custom (team) font variants first — matching uuid + weight + style,
degrading to uuid+weight then uuid — then the shared google catalog. Returns
nil for fonts not found (builtin falls back to the bundled default)."
[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 in the wasm
;; font store, not through any span's font family — so `wasm/fonts-for-shape`
;; never reports them and the per-object provisioning above never uploads
;; them. The browser workspace uploads them as a side effect of serializing
;; text (`add-emoji-font` / `add-noto-fonts`), which is why client-side single
;; exports show emoji/CJK while headless exports dropped them. Which fonts a
;; scene needs is computed by the host-agnostic
;; `app.render-wasm.fallback-fonts` (same data the browser uses).
;; `clear-fonts!` resets the store every request, so this must run per
;; request; the TTF bytes are cached per font for the process lifetime.
(defn- scene-fallback-fonts
"Fallback font descriptors needed by the scene's text content."
[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)]
(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."
[{:keys [font-id weight style]}]
(if-let [bytes (get @fallback-font-bytes* font-id)]
(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 font-id 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. We collect them from the scene, fetch
;; the encoded bytes from `assets/by-file-media-id/<id>`, and hand them to
;; `_store_image` (Skia decodes them; no WebGL). Keyed by media uuid, so this is
;; done 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 token-scheme]}]
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers (clj->js (merge {"X-Shared-Key" (str "exporter " cf/management-key)}
(auth/asset-headers token token-scheme)))
uri (-> (cf/get :public-uri)
(u/ensure-path-slash)
(u/join (str "assets/by-file-media-id/" media-id))
(str))]
(->> (http/fetch uri #js {:method "GET" :headers headers :dispatcher agent})
(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 :cause cause)
(p/resolved nil))))))
(defn- provision-images!
"Fetches and stores every image the scene references — shape fills, stroke
image fills, and text-span image fills (enumerated by the host-agnostic
`app.render-wasm.resources`, same source the workspace uses). Images the
module already holds are skipped: unlike fonts, the image store is not
reset per request, so repeated exports of the same file reuse them.
NOTE: that also means the store grows with every distinct image the
process ever exports; if exporter memory becomes a problem, add an
eviction policy on the Rust side rather than clearing per request."
[scene params]
(let [all-ids (resources/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/info :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/info :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 in the scene. Called after fonts are
provisioned so metrics use the real fonts (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]
(case type
:pdf (let [bytes (wasm/render-shape-pdf id scale)]
(l/info :hint "PDF generated via Skia (render-wasm headless)"
:object-id (str id)
:backend "skia-wasm"
:bytes (.-length bytes))
bytes)
(wasm/render-shape-raster id scale)))
(defn- render*
[{:keys [scale type objects] :as params} on-object]
(l/info :hint "wasm render: start" :type type :scale scale :objects (count objects))
(->> (ensure-module!)
(p/mcat (fn [_]
(l/info :hint "wasm render: module ready, fetching scene"
:file-id (str (:file-id params)) :page-id (str (:page-id params)))
(fetch-objects params)))
(p/mcat (fn [scene]
(let [sample (first (vals scene))]
(l/info :hint "wasm render: scene fetched"
:shapes (count scene)
:map? (map? scene)
:first-key (str (first (keys scene)))
:sample-id (str (:id sample))
:sample-type (str (:type sample))
:sample-keys (pr-str (when (map? sample) (vec (keys sample))))))
(serialize/serialize-scene! scene)
(l/info :hint "wasm render: scene serialized")
;; Reset the shared module's font store so fonts from a previous
;; request don't accumulate / leak into this one.
(wasm/clear-fonts!)
;; Fetch the file's custom (team) font variants once, provision
;; every referenced image once, then resolve/provision fonts per
;; rendered object.
(->> (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)]
;; Provision every object's fonts BEFORE rendering, so
;; the text relayout below sees real font metrics.
(p/all (map (fn [{:keys [id]}]
(wasm/provision-fonts! id resolve-font))
objects)))))
(p/mcat
(fn [_]
;; Serialize-time layout used the fallback font (fonts
;; weren't uploaded yet); recompute now that they are, or
;; text metrics/line breaks are wrong.
(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/info :hint "wasm render: object rendered"
:object-id (str id) :bytes (.-length bytes))
(fs/writeFileSync path bytes)
;; `on-object` may return a plain value (zip append
;; returns the archiver instance) or a promise (single
;; export's file move); `p/do` normalizes both to a
;; thenable so `p/mcat` doesn't throw "expected thenable".
(p/do (on-object (assoc object :path path)))))
objects))))))
(p/merr (fn [cause]
(l/error :hint "wasm render: failed" :cause cause)
;; A panic/abort can leave the shared module's buffer allocated
;; or the wasm instance aborted; drop it so the next request
;; rebuilds a fresh module instead of inheriting the bad state.
(reset! module* nil)
(p/rejected cause)))))
(defn render
"Public entry. Serializes module access through `enqueue!` so concurrent
exports (multi-partition zip) run one at a time on the shared WASM instance."
[params on-object]
(enqueue! (fn [] (render* params on-object))))

View File

@ -0,0 +1,30 @@
;; 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.util.auth
"Helpers for forwarding the caller's auth to the backend.")
(defn header-value
"Builds the `Authorization` header value to forward to the backend for a
given token + scheme. Penpot access tokens use the `Token` scheme; session
tokens (incl. those resolved from a cookie) use `Bearer`. Defaults to
`Bearer` so existing callers are unaffected."
([token] (header-value token :bearer))
([token scheme]
(str (if (= scheme :token) "Token " "Bearer ") token)))
(defn asset-headers
"Auth headers for fetching `/assets/*` URLs. Those are served through the
storage backend: nginx follows an internal redirect to a *presigned* S3/minio
URL and forwards the client's headers, so a `Bearer` Authorization header
makes S3 reject the request with 'multiple authentication types' (400).
Session tokens are therefore forwarded the way the browser sends them — as
the auth cookie — and access tokens keep the `Token` scheme, which S3 does
not parse as an authentication type."
[token scheme]
(if (= scheme :token)
{"Authorization" (str "Token " token)}
{"Cookie" (str "auth-token=" token)}))

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

@ -0,0 +1,229 @@
;; 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.
This is the GPU-free counterpart of the browser's
`app.render-wasm.api`: it loads the emscripten artifact in the Node
process (no WebGL), boots it via `init_headless`, and exposes the
minimal surface the exporter needs — font provisioning and shape
rendering to PNG/PDF bytes.
The shape *serialization* is reused from the portable render-wasm leaves
(`app.render-wasm.{wasm,mem,helpers,serializers,api.shapes,...}`); this
namespace only owns the Node runtime + the headless render/export calls.
The CLJS↔WASM binary protocol therefore stays identical to the editor —
no second implementation, no drift.
Requires render-wasm built with `-sENVIRONMENT=web,node`."
(:require
["node:fs" :as fs]
["node:path" :as path]
[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.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)
;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32].
(def ^:private FONT-ENTRY-BYTES 24)
(defn- artifact-dir
[]
(cf/get :wasm-dir "../frontend/resources/public/js"))
(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))]
(let [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 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."
[{: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 provision-fonts!
"Resolves and uploads every font needed by `shape-id`. `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-id resolve-font]
(->> (fonts-for-shape shape-id)
(map (fn [family]
(->> (resolve-font family)
(p/fmap (fn [bytes] (when bytes (store-font! family bytes)))))))
(p/all)))
;; --- RENDER
(defn- render-call
[fn-name shape-id scale]
(let [module wasm/internal-module
buf (uuid/get-u32 shape-id)
offset (h/call module fn-name
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
scale)
heap32 (mem/get-heap-u32)
len (aget heap32 (mem/->offset-32 offset))
bytes (read-result-bytes (+ offset RASTER-HEADER-BYTES) len)]
(mem/free)
bytes))
(defn render-shape-raster
"Renders the shape subtree to PNG bytes (Uint8Array) on a CPU surface."
[shape-id scale]
(render-call "_render_shape_raster" shape-id scale))
(defn render-shape-pdf
"Renders the shape subtree to PDF bytes (Uint8Array)."
[shape-id scale]
;; PDF result header is [len u32] only (no w/h); handled separately.
(let [module wasm/internal-module
buf (uuid/get-u32 shape-id)
offset (h/call module "_render_shape_pdf"
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
scale)
heap32 (mem/get-heap-u32)
len (aget heap32 (mem/->offset-32 offset))
bytes (read-result-bytes (+ offset 4) len)]
(mem/free)
bytes))

View File

@ -0,0 +1,45 @@
;; 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.
Reuses the same `gfonts.json` + parser the browser uses, via the portable
`preload-gfonts` macro (a `.clj` macro — it does NOT pull the browser-coupled
`app.main.fonts` cljs runtime). `parse-gfont` assigns each font a `uuid/random`
at macro-expansion time, so the catalog MUST be defined once here and shared:
text serialization (`app.wasm.text`) maps `gfont-<slug>` -> this uuid, and font
provisioning (`app.renderer.wasm`) maps the same uuid back to the variant's
ttf url. Consistency only holds because both read this single instance."
(: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,57 @@
;; 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.
This is the headless counterpart of the browser orchestrator
`app.render-wasm.api/set-object`. It cannot reuse that fn directly (its
namespace pulls React/DOM/store), so it owns the *call sequencing* here —
but every byte layout is reused from the shared, identical sources:
- `app.render-wasm.api.shapes/set-shape-base-props` (the scalar struct),
- `app.common.types.fills` / `…fills.impl` (fills & stroke fills),
- `app.render-wasm.{mem,serializers,helpers}` leaves.
So the bytes sent to WASM are the same as the editor's; only the (simple)
orchestration lives here.
COVERAGE: base props (incl. corners/opacity/transform/clip/constraints),
children, fills (solid/gradient/image), strokes (solid/gradient/image),
layer + background blur, shadows, masks, path/bool geometry, text. Image
BYTES and fonts are provisioned separately by `app.renderer.wasm`.
NOT YET: svg-raw (needs faithful static markup — see step 3d notes)."
(: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,58 @@
;; 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 text layout is shared with the workspace via
`app.render-wasm.text-content`; only *font-id resolution* is local here,
because the exporter has no fonts DB: custom fonts keep their uuid
(`custom-<uuid>`), google fonts (`gfont-<slug>`) map via the compile-time
`app.wasm.gfonts` catalog, and builtin falls back to the default (uuid/zero)."
(: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")))

View File

@ -179,6 +179,15 @@
(and (wasm-export-enabled? state)
(contains? wasm-export-types (:type export))))
(defn- request-simple-export-wasm
[export]
(ptk/reify ::request-simple-export-wasm
ptk/EffectEvent
(effect [_ _ _]
(case (:type export)
:pdf (wasm.exports/export-pdf export)
(wasm.exports/export-image export)))))
(defn request-simple-export
[{:keys [export]}]
(ptk/reify ::request-simple-export
@ -191,11 +200,7 @@
ptk/WatchEvent
(watch [_ state _]
(if (use-wasm-export? state export)
(do
(case (:type export)
:pdf (wasm.exports/export-pdf export)
(wasm.exports/export-image export))
(rx/empty))
(rx/of (request-simple-export-wasm export))
(let [profile-id (:profile-id state)
params {:exports [export]
:profile-id profile-id

View File

@ -665,7 +665,7 @@
(mf/use-fn
(mf/deps frames)
(fn [_]
(st/emit! (de/show-workspace-export-frames-dialog (reverse frames)))))
(st/emit! (de/show-workspace-export-frames-dialog frames))))
on-export-frames-key-down
(mf/use-fn

View File

@ -14,7 +14,6 @@
[app.common.files.focus :as cpf]
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.types.color :as clr]
[app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl]
@ -33,6 +32,7 @@
[app.main.store :as st]
[app.main.ui.shapes.text]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.api.props :as props]
[app.render-wasm.api.shapes :as shapes]
[app.render-wasm.api.texts :as t]
[app.render-wasm.api.webgl :as webgl]
@ -43,6 +43,7 @@
[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]
@ -251,8 +252,6 @@
(def ^:const GRID-LAYOUT-COLUMN-U8-SIZE 8)
(def ^:const GRID-LAYOUT-CELL-U8-SIZE 36)
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def ^:const DEBOUNCE_DELAY_MS 100)
(defonce ^:private view-interaction-active? (atom false))
@ -633,7 +632,7 @@
(defn set-masked
[masked]
(h/call wasm/internal-module "_set_shape_masked_group" masked))
(props/set-masked masked))
(defn set-shape-selrect
[selrect]
@ -728,10 +727,6 @@
(perf/end-measure "set-shape-children")
nil)
(defn- get-string-length
[string]
(+ (count string) 1))
(defn- get-texture-id-for-gl-object
"Registers a WebGL texture with Emscripten's GL object system and returns its ID"
@ -832,124 +827,49 @@
(defn set-shape-fills
[shape-id fills thumbnail?]
(if (empty? fills)
(h/call wasm/internal-module "_clear_shape_fills")
(let [fills (types.fills/coerce fills)
image-ids (types.fills/get-image-ids fills)
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
heap (mem/get-heap-u32)]
;; write fills to the heap
(types.fills/write-to fills heap offset)
;; send fills to wasm
(h/call wasm/internal-module "_set_shape_fills")
;; load images for image fills if not cached
(keep (fn [id]
(let [buffer (uuid/get-u32 id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
image-ids))))
;; Record write is shared with the headless exporter; the image fetch below is
;; browser-only (WebGL textures).
(when-let [fills (props/write-shape-fills! fills)]
(keep (fn [id]
(let [buffer (uuid/get-u32 id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills))))
(defn set-shape-strokes
[shape-id strokes thumbnail?]
(h/call wasm/internal-module "_clear_shape_strokes")
(keep (fn [stroke]
(when-not (:hidden stroke)
(let [opacity (or (:stroke-opacity stroke) 1.0)
color (:stroke-color stroke)
gradient (:stroke-color-gradient stroke)
image (:stroke-image stroke)
width (:stroke-width stroke)
align (:stroke-alignment stroke)
style (-> stroke :stroke-style sr/translate-stroke-style)
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
;; Sentinel -1 means "unset" on the Rust side — keeps the
;; FFI signature flat while letting the renderer fall back
;; to its default dash pattern when no override is stored.
dash (or (:stroke-dash stroke) -1)
gap (or (:stroke-gap stroke) -1)
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
(case align
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
(cond
(some? gradient)
(do
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
(h/call wasm/internal-module "_add_shape_stroke_fill")
nil)
(some? image)
(let [image-id (get image :id)
buffer (uuid/get-u32 image-id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0) (aget buffer 1)
(aget buffer 2) (aget buffer 3)
thumbnail?)]
(types.fills.impl/write-image-fill offset dview opacity image)
(h/call wasm/internal-module "_add_shape_stroke_fill")
(when (== cached-image? 0)
(fetch-image shape-id image-id thumbnail?)))
(some? color)
(do
(types.fills.impl/write-solid-fill offset dview opacity color)
(h/call wasm/internal-module "_add_shape_stroke_fill")
nil)))))
strokes))
;; Record write is shared with the headless exporter; the image fetch below is
;; browser-only (WebGL textures).
(keep (fn [image-id]
(let [buffer (uuid/get-u32 image-id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?))))
(props/write-shape-strokes! strokes)))
(defn set-shape-svg-attrs
[attrs]
(let [style (:style attrs)
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
fill-none (= "none" (or (:fill style) (:fill attrs)))]
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
(props/set-shape-svg-attrs attrs))
(defn set-shape-path-content
"Upload path content in chunks to WASM."
[content]
(let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
buffer-size (path/get-byte-size content)
padded-size (* 4 (mth/ceil (/ buffer-size 4)))
buffer (js/Uint8Array. padded-size)]
(path/write-to content (.-buffer buffer) 0)
(h/call wasm/internal-module "_start_shape_path_buffer")
(let [heapu32 (mem/get-heap-u32)]
(loop [offset 0]
(when (< offset padded-size)
(let [end (min padded-size (+ offset (* chunk-size 4)))
chunk (.subarray buffer offset end)
chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4))
offset-size (.-length chunk-u32)
heap-offset (mem/alloc->offset-32 (* 4 offset-size))]
(.set heapu32 chunk-u32 heap-offset)
(h/call wasm/internal-module "_set_shape_path_chunk_buffer")
(recur end)))))
(h/call wasm/internal-module "_set_shape_path_buffer")))
(props/set-shape-path-content content))
(defn set-shape-svg-raw-content
[content]
(let [size (get-string-length content)
offset (mem/alloc size)]
(h/call wasm/internal-module "stringToUTF8" content offset size)
(h/call wasm/internal-module "_set_shape_svg_raw_content")))
(props/set-shape-svg-raw-content content))
(defn set-shape-blend-mode
[blend-mode]
@ -994,25 +914,15 @@
(defn set-shape-bool-type
[bool-type]
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
(props/set-shape-bool-type bool-type))
(defn set-shape-blur
[blur]
(let [type (sr/translate-blur-type :layer-blur)]
(if (some? blur)
(let [hidden (:hidden blur)
value (:value blur)]
(h/call wasm/internal-module "_set_shape_blur" type hidden value))
(h/call wasm/internal-module "_clear_shape_blur" type))))
(props/set-shape-blur blur))
(defn set-shape-background-blur
[background-blur]
(let [type (sr/translate-blur-type :background-blur)]
(if (some? background-blur)
(let [hidden (:hidden background-blur)
value (:value background-blur)]
(h/call wasm/internal-module "_set_shape_blur" type hidden value))
(h/call wasm/internal-module "_clear_shape_blur" type))))
(props/set-shape-background-blur background-blur))
(defn set-shape-corners
[corners]
@ -1229,27 +1139,7 @@
(defn set-shape-shadows
[shadows]
(h/call wasm/internal-module "_clear_shape_shadows")
(run! (fn [shadow]
(let [color (get shadow :color)
blur (get shadow :blur)
rgba (sr-clr/hex->u32argb (get color :color)
(get color :opacity))
hidden (get shadow :hidden)
x (get shadow :offset-x)
y (get shadow :offset-y)
spread (get shadow :spread)
style (get shadow :style)]
(h/call wasm/internal-module "_add_shape_shadow"
rgba
blur
spread
x
y
(sr/translate-shadow-style style)
hidden)))
shadows))
(props/set-shape-shadows shadows))
(defn fonts-from-text-content [content fallback-fonts-only?]
(let [paragraph-set (first (get content :children))
@ -1288,7 +1178,7 @@
(defn set-shape-grow-type
[grow-type]
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
(props/set-shape-grow-type grow-type))
(defn get-text-dimensions
([id]
@ -1399,45 +1289,22 @@
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
masked (get shape :masked-group)
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
content (let [content (get shape :content)]
(if (= type :text)
(ensure-text-content content)
content))
bool-type (get shape :bool-type)
grow-type (get shape :grow-type)
blur (get shape :blur)
background-blur (get shape :background-blur)
svg-attrs (get shape :svg-attrs)
shadows (get shape :shadow)]
content))]
(shapes/set-shape-base-props shape)
;; Host-independent properties (base props, children, blur, background
;; blur, shadows, svg attrs, mask, bool type, path geometry, grow type),
;; shared with the headless exporter so the two can't drift.
(serialize-shape/serialize-shape! shape)
;; Remaining properties that need separate calls (variable-length or conditional)
(set-shape-children children)
(set-shape-blur blur)
(set-shape-background-blur background-blur)
(when (= type :group)
(set-masked (boolean masked)))
(when (= type :bool)
(set-shape-bool-type bool-type))
(when (and (some? content)
(or (= type :path)
(= type :bool)))
(set-shape-path-content content))
(when (some? svg-attrs)
(set-shape-svg-attrs svg-attrs))
;; Browser-only: svg-raw markup (needs React) + workspace layout.
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(set-shape-shadows shadows)
(when (= type :text)
(set-shape-grow-type grow-type))
(set-shape-layout shape)
(set-layout-data shape)
(let [is-text? (= type :text)

View File

@ -14,6 +14,7 @@
[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]
@ -405,68 +406,9 @@
[fonts]
(keep (fn [font] (store-font font)) fonts))
(defn add-emoji-font
[fonts]
(conj fonts {:font-id "gfont-noto-color-emoji"
:font-variant-id "regular"
:style 0
:weight 400
:is-emoji true
:is-fallback true}))
(def noto-fonts
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:mongolian {:font-id "gfont-noto-sans-mongolian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:syriac {:font-id "gfont-noto-sans-syriac" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tifinagh {:font-id "gfont-noto-sans-tifinagh" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:coptic {:font-id "gfont-noto-sans-coptic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ol-chiki {:font-id "gfont-noto-sans-ol-chiki" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vai {:font-id "gfont-noto-sans-vai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:shavian {:font-id "gfont-noto-sans-shavian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:osmanya {:font-id "gfont-noto-sans-osmanya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:runic {:font-id "gfont-noto-sans-runic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:old-italic {:font-id "gfont-noto-sans-old-italic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:brahmi {:font-id "gfont-noto-sans-brahmi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:modi {:font-id "gfont-noto-sans-modi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sora-sompeng {:font-id "gfont-noto-sans-sora-sompeng" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
(defn add-noto-fonts [fonts languages]
(reduce (fn [acc lang]
(if-let [font (get noto-fonts lang)]
(conj acc font)
acc))
fonts
languages))
;; Fallback-font data (emoji + per-script noto fonts) lives in the
;; host-agnostic `app.render-wasm.fallback-fonts`, shared with the headless
;; exporter; kept re-exported here for existing workspace callers.
(def add-emoji-font fbf/add-emoji-font)
(def noto-fonts fbf/noto-fonts)
(def add-noto-fonts fbf/add-noto-fonts)

View File

@ -0,0 +1,199 @@
;; 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.props
"Browser-free WASM shape property setters, shared by the workspace render
orchestrator (`app.render-wasm.api`) and the headless exporter
(`app.wasm.serialize`).
These only touch the WASM FFI (`app.render-wasm.{helpers,mem,serializers,
wasm}`) and the shared `common` byte layouts — no store/DOM/React — so they
run identically in the browser and under Node. Setters that need host-specific
data sources (fonts, image bytes, SVG static markup) stay in `app.render-wasm.api`."
(:require
[app.common.math :as mth]
[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]))
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def ^:const UUID-U8-SIZE 16)
(defn set-shape-children
"Uploads the child id list via the dynamic `_set_children` path (handles any
count). The browser also has fixed-arity fast paths for the incremental edit
path; this dynamic one is the shared/batch version."
[children]
(let [children (into [] (filter uuid?) children)]
(if (empty? children)
(h/call wasm/internal-module "_set_children_0")
(let [heap (mem/get-heap-u32)
size (mem/get-alloc-size children UUID-U8-SIZE)
offset (mem/alloc->offset-32 size)]
(reduce (fn [o id] (mem.h32/write-uuid o heap id)) offset children)
(h/call wasm/internal-module "_set_children")))))
(defn set-shape-bool-type
[bool-type]
(h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type)))
(defn set-shape-grow-type
[grow-type]
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
(defn write-shape-fills!
"Serializes a fill vector into WASM using the shared `common` byte layout.
Only writes the fill *records*; image fill BYTES are host-specific (the browser
fetches WebGL textures, the exporter provisions decoded bytes), so callers load
images after. Returns the coerced `Fills` (for image-id extraction) or nil when
empty."
[fills]
(if (empty? fills)
(do (h/call wasm/internal-module "_clear_shape_fills") nil)
(let [fills (types.fills/coerce fills)
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
heap (mem/get-heap-u32)]
(types.fills/write-to fills heap offset)
(h/call wasm/internal-module "_set_shape_fills")
fills)))
(defn write-shape-strokes!
"Serializes the stroke vector (records only, like `write-shape-fills!`);
image stroke BYTES are host-specific, so callers load images after. Returns
the image ids referenced by the strokes' image fills."
[strokes]
(h/call wasm/internal-module "_clear_shape_strokes")
(into []
(keep
(fn [stroke]
(when-not (:hidden stroke)
(let [opacity (or (:stroke-opacity stroke) 1.0)
color (:stroke-color stroke)
gradient (:stroke-color-gradient stroke)
image (:stroke-image stroke)
width (:stroke-width stroke)
align (:stroke-alignment stroke)
style (-> stroke :stroke-style sr/translate-stroke-style)
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
;; Sentinel -1 means "unset" on the Rust side — keeps the
;; FFI signature flat while letting the renderer fall back
;; to its default dash pattern when no override is stored.
dash (or (:stroke-dash stroke) -1)
gap (or (:stroke-gap stroke) -1)
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
(case align
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end dash gap)
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end dash gap)
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end dash gap))
(cond
(some? gradient)
(do (types.fills.impl/write-gradient-fill offset dview opacity gradient)
(h/call wasm/internal-module "_add_shape_stroke_fill")
nil)
(some? image)
(do (types.fills.impl/write-image-fill offset dview opacity image)
(h/call wasm/internal-module "_add_shape_stroke_fill")
(get image :id))
(some? color)
(do (types.fills.impl/write-solid-fill offset dview opacity color)
(h/call wasm/internal-module "_add_shape_stroke_fill")
nil))))))
strokes))
(defn- get-string-length
[string]
(+ (count string) 1))
(defn set-shape-blur
[blur]
(let [type (sr/translate-blur-type :layer-blur)]
(if (some? blur)
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden blur)) (:value blur))
(h/call wasm/internal-module "_clear_shape_blur" type))))
(defn set-shape-background-blur
[background-blur]
(let [type (sr/translate-blur-type :background-blur)]
(if (some? background-blur)
(h/call wasm/internal-module "_set_shape_blur" type (boolean (:hidden background-blur)) (:value background-blur))
(h/call wasm/internal-module "_clear_shape_blur" type))))
(defn set-shape-shadows
[shadows]
(h/call wasm/internal-module "_clear_shape_shadows")
(run! (fn [shadow]
(let [color (get shadow :color)
blur (get shadow :blur)
rgba (sr-clr/hex->u32argb (get color :color)
(get color :opacity))
hidden (get shadow :hidden)
x (get shadow :offset-x)
y (get shadow :offset-y)
spread (get shadow :spread)
style (get shadow :style)]
(h/call wasm/internal-module "_add_shape_shadow"
rgba
blur
spread
x
y
(sr/translate-shadow-style style)
hidden)))
shadows))
(defn set-masked
[masked]
(h/call wasm/internal-module "_set_shape_masked_group" masked))
(defn set-shape-svg-attrs
[attrs]
(let [style (:style attrs)
fill-rule (-> (or (:fillRule style) (:fillRule attrs)) sr/translate-fill-rule)
stroke-linecap (-> (or (:strokeLinecap style) (:strokeLinecap attrs)) sr/translate-stroke-linecap)
stroke-linejoin (-> (or (:strokeLinejoin style) (:strokeLinejoin attrs)) sr/translate-stroke-linejoin)
fill-none (= "none" (or (:fill style) (:fill attrs)))]
(h/call wasm/internal-module "_set_shape_svg_attrs" fill-rule stroke-linecap stroke-linejoin fill-none)))
(defn set-shape-svg-raw-content
[content]
(let [size (get-string-length content)
offset (mem/alloc size)]
(h/call wasm/internal-module "stringToUTF8" content offset size)
(h/call wasm/internal-module "_set_shape_svg_raw_content")))
(defn set-shape-path-content
"Upload path content in chunks to WASM."
[content]
(let [chunk-size (quot MAX_BUFFER_CHUNK_SIZE 4)
buffer-size (path/get-byte-size content)
padded-size (* 4 (mth/ceil (/ buffer-size 4)))
buffer (js/Uint8Array. padded-size)]
(path/write-to content (.-buffer buffer) 0)
(h/call wasm/internal-module "_start_shape_path_buffer")
(let [heapu32 (mem/get-heap-u32)]
(loop [offset 0]
(when (< offset padded-size)
(let [end (min padded-size (+ offset (* chunk-size 4)))
chunk (.subarray buffer offset end)
chunk-u32 (js/Uint32Array. chunk.buffer chunk.byteOffset (quot (.-length chunk) 4))
offset-size (.-length chunk-u32)
heap-offset (mem/alloc->offset-32 (* 4 offset-size))]
(.set heapu32 chunk-u32 heap-offset)
(h/call wasm/internal-module "_set_shape_path_chunk_buffer")
(recur end)))))
(h/call wasm/internal-module "_set_shape_path_buffer")))

View File

@ -6,240 +6,21 @@
(ns app.render-wasm.api.texts
(:require
[app.common.data :as d]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.uuid :as uuid]
[app.render-wasm.api.fonts :as f]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.wasm :as wasm]))
(def ^:const PARAGRAPH-ATTR-U8-SIZE 12)
(def ^:const SPAN-ATTR-U8-SIZE 64)
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
(defn- encode-text
"Into an UTF8 buffer. Returns an ArrayBuffer instance"
[text]
(let [encoder (js/TextEncoder.)]
(.encode encoder text)))
(defn- write-span-fills
[offset dview fills]
(let [new-ofset (reduce (fn [offset fill]
(let [opacity (get fill :fill-opacity 1.0)
color (get fill :fill-color)
gradient (get fill :fill-color-gradient)
image (get fill :fill-image)]
(cond
(some? color)
(types.fills.impl/write-solid-fill offset dview opacity color)
(some? gradient)
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
(some? image)
(types.fills.impl/write-image-fill offset dview opacity image))))
offset
fills)
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
(defn- write-paragraph
[offset dview paragraph]
(let [text-align (sr/translate-text-align (get paragraph :text-align))
text-direction (sr/translate-text-direction (get paragraph :text-direction))
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
text-transform (sr/translate-text-transform (get paragraph :text-transform))
line-height (f/serialize-line-height (get paragraph :line-height))
letter-spacing (f/serialize-letter-spacing (get paragraph :letter-spacing))]
(-> offset
(mem/write-u8 dview text-align)
(mem/write-u8 dview text-direction)
(mem/write-u8 dview text-decoration)
(mem/write-u8 dview text-transform)
(mem/write-f32 dview line-height)
(mem/write-f32 dview letter-spacing)
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
(defn- write-spans
[offset dview spans paragraph]
(let [paragraph-font-size (get paragraph :font-size)
paragraph-font-weight (-> paragraph :font-weight f/serialize-font-weight)
paragraph-line-height (f/serialize-line-height (get paragraph :line-height))]
(reduce (fn [offset span]
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
font-size (get span :font-size paragraph-font-size)
font-size (f/serialize-font-size font-size)
line-height (f/serialize-line-height (get span :line-height) paragraph-line-height)
letter-spacing (f/serialize-letter-spacing (get span :letter-spacing))
font-weight (get span :font-weight paragraph-font-weight)
font-weight (f/serialize-font-weight font-weight)
font-id (f/normalize-font-id (get span :font-id "sourcesanspro"))
font-family (hash (get span :font-family "sourcesanspro"))
text-buffer (encode-text (get span :text ""))
text-length (mem/size text-buffer)
fills (take MAX-TEXT-FILLS (get span :fills []))
font-variant-id
(get span :font-variant-id)
font-variant-id
(if (uuid? font-variant-id)
font-variant-id
uuid/zero)
text-decoration
(or (sr/translate-text-decoration (:text-decoration span))
(sr/translate-text-decoration (:text-decoration paragraph))
(sr/translate-text-decoration "none"))
text-transform
(or (sr/translate-text-transform (:text-transform span))
(sr/translate-text-transform (:text-transform paragraph))
(sr/translate-text-transform "none"))
text-direction
(or (sr/translate-text-direction (:text-direction span))
(sr/translate-text-direction (:text-direction paragraph))
(sr/translate-text-direction "ltr"))]
(-> offset
(mem/write-u8 dview font-style)
(mem/write-u8 dview text-decoration)
(mem/write-u8 dview text-transform)
(mem/write-u8 dview text-direction)
(mem/write-f32 dview font-size)
(mem/write-f32 dview line-height)
(mem/write-f32 dview letter-spacing)
(mem/write-u32 dview font-weight)
(mem/write-uuid dview font-id)
(mem/write-i32 dview font-family)
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
(mem/write-i32 dview text-length)
(mem/write-i32 dview (count fills))
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
(write-span-fills dview fills))))
offset
spans)))
[app.render-wasm.fallback-fonts :as fbf]
[app.render-wasm.text-content :as tc]))
(defn write-shape-text
;; buffer has the following format:
;; [<num-spans> <paragraph_attributes> <spans_attributes> <text>]
"Workspace text serialization: the byte writing is shared via
`app.render-wasm.text-content`; font resolution is the workspace's (fonts DB)."
[spans paragraph text]
(let [normalized-paragraph (f/normalize-paragraph-font paragraph)
normalized-spans (map #(f/normalize-span-font % normalized-paragraph) spans)
num-spans (count normalized-spans)
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
text-buffer (encode-text text)
text-size (mem/size text-buffer)
total-size (+ 4 metadata-size text-size)
heapu8 (mem/get-heap-u8)
dview (mem/get-data-view)
offset (mem/alloc total-size)]
(-> offset
(mem/write-u32 dview num-spans)
(write-paragraph dview normalized-paragraph)
(write-spans dview normalized-spans normalized-paragraph)
(mem/write-buffer heapu8 text-buffer))
(h/call wasm/internal-module "_set_shape_text_content")))
(def ^:private emoji-pattern
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
(def ^:private unicode-ranges
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
:korean #"[\uAC00-\uD7AF]"
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
:thai #"[\u0E00-\u0E7F]"
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
:tamil #"[\u0B80-\u0BFF]"
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
:vietnamese #"[\u1EA0-\u1EF9]"
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
:bengali #"[\u0980-\u09FF]"
:cherokee #"[\u13A0-\u13FF]"
:ethiopic #"[\u1200-\u137F]"
:georgian #"[\u10A0-\u10FF]"
:gujarati #"[\u0A80-\u0AFF]"
:gurmukhi #"[\u0A00-\u0A7F]"
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
:lao #"[\u0E80-\u0EFF]"
:malayalam #"[\u0D00-\u0D7F]"
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
:sinhala #"[\u0D80-\u0DFF]"
:telugu #"[\u0C00-\u0C7F]"
:tibetan #"[\u0F00-\u0FFF]"
:javanese #"[\uA980-\uA9DF]"
:kannada #"[\u0C80-\u0CFF]"
:oriya #"[\u0B00-\u0B7F]"
:mongolian #"[\u1800-\u18AF]"
:syriac #"[\u0700-\u074F]"
:tifinagh #"[\u2D30-\u2D7F]"
:coptic #"[\u2C80-\u2CFF]"
:ol-chiki #"[\u1C50-\u1C7F]"
:vai #"[\uA500-\uA63F]"
:shavian #"\uD801[\uDC50-\uDC7F]"
:osmanya #"\uD801[\uDC80-\uDCAF]"
:runic #"[\u16A0-\u16FF]"
:old-italic #"\uD800[\uDF00-\uDF2F]"
:brahmi #"\uD804[\uDC00-\uDC7F]"
:modi #"\uD805[\uDE00-\uDE5F]"
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
:bamum #"[\uA6A0-\uA6FF]"
:meroitic #"\uD802[\uDD80-\uDD9F]"
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
;; Additional symbol blocks covered by Noto Sans Symbols 2:
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
;; Legacy Computing Symbols (U+1FB00-1FBFF)
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
(defn contains-emoji? [text]
(let [result (re-find emoji-pattern text)]
(boolean result)))
(defn collect-used-languages
[used text]
(reduce-kv (fn [result lang pattern]
(cond
;; Skip regex operation if we already know that
;; langage is present
(contains? result lang)
result
(re-find pattern text)
(conj result lang)
:else
result))
used
unicode-ranges))
(tc/write-shape-text! spans paragraph text
{:normalize-font-id f/normalize-font-id
: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
;; workspace callers.
(def contains-emoji? fbf/contains-emoji?)
(def collect-used-languages fbf/collect-used-languages)

View File

@ -0,0 +1,157 @@
;; 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.fallback-fonts
"Host-agnostic fallback-font knowledge: which scripts/emoji a text uses and
which (google) fallback fonts cover them. Pure data + pure fns — no browser
or Node dependencies — so the workspace (`api.texts`/`api.fonts`) and the
headless exporter (`app.renderer.wasm`) compute the SAME fallback set from
the same source. Anything a host must fetch/upload for text to render
belongs here, not in host code.")
(def ^:private emoji-pattern
#"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]")
(def ^:private unicode-ranges
{:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]"
:chinese #"[\u4E00-\u9FFF\u3400-\u4DBF]"
:korean #"[\uAC00-\uD7AF]"
:arabic #"[\u0600-\u06FF\u0750-\u077F\u0870-\u089F\u08A0-\u08FF]"
:cyrillic #"[\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69F]"
:greek #"[\u0370-\u03FF\u1F00-\u1FFF]"
:hebrew #"[\u0590-\u05FF\uFB1D-\uFB4F]"
:thai #"[\u0E00-\u0E7F]"
:devanagari #"[\u0900-\u097F\uA8E0-\uA8FF]"
:tamil #"[\u0B80-\u0BFF]"
:latin-ext #"[\u0100-\u017F\u0180-\u024F]"
:vietnamese #"[\u1EA0-\u1EF9]"
:armenian #"[\u0530-\u058F\uFB13-\uFB17]"
:bengali #"[\u0980-\u09FF]"
:cherokee #"[\u13A0-\u13FF]"
:ethiopic #"[\u1200-\u137F]"
:georgian #"[\u10A0-\u10FF]"
:gujarati #"[\u0A80-\u0AFF]"
:gurmukhi #"[\u0A00-\u0A7F]"
:khmer #"[\u1780-\u17FF\u19E0-\u19FF]"
:lao #"[\u0E80-\u0EFF]"
:malayalam #"[\u0D00-\u0D7F]"
:myanmar #"[\u1000-\u109F\uAA60-\uAA7F]"
:sinhala #"[\u0D80-\u0DFF]"
:telugu #"[\u0C00-\u0C7F]"
:tibetan #"[\u0F00-\u0FFF]"
:javanese #"[\uA980-\uA9DF]"
:kannada #"[\u0C80-\u0CFF]"
:oriya #"[\u0B00-\u0B7F]"
:mongolian #"[\u1800-\u18AF]"
:syriac #"[\u0700-\u074F]"
:tifinagh #"[\u2D30-\u2D7F]"
:coptic #"[\u2C80-\u2CFF]"
:ol-chiki #"[\u1C50-\u1C7F]"
:vai #"[\uA500-\uA63F]"
:shavian #"\uD801[\uDC50-\uDC7F]"
:osmanya #"\uD801[\uDC80-\uDCAF]"
:runic #"[\u16A0-\u16FF]"
:old-italic #"\uD800[\uDF00-\uDF2F]"
:brahmi #"\uD804[\uDC00-\uDC7F]"
:modi #"\uD805[\uDE00-\uDE5F]"
:sora-sompeng #"\uD804[\uDCD0-\uDCFF]"
:bamum #"[\uA6A0-\uA6FF]"
:meroitic #"\uD802[\uDD80-\uDD9F]"
;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc.
:symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]"
;; Additional symbol blocks covered by Noto Sans Symbols 2:
;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.)
;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF),
;; Legacy Computing Symbols (U+1FB00-1FBFF)
:symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]"
:music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"})
(defn contains-emoji? [text]
(let [result (re-find emoji-pattern text)]
(boolean result)))
(defn collect-used-languages
[used text]
(reduce-kv (fn [result lang pattern]
(cond
;; Skip regex operation if we already know that
;; langage is present
(contains? result lang)
result
(re-find pattern text)
(conj result lang)
:else
result))
used
unicode-ranges))
(defn add-emoji-font
[fonts]
(conj fonts {:font-id "gfont-noto-color-emoji"
:font-variant-id "regular"
:style 0
:weight 400
:is-emoji true
:is-fallback true}))
(def noto-fonts
{:japanese {:font-id "gfont-noto-sans-jp" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:chinese {:font-id "gfont-noto-sans-sc" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:korean {:font-id "gfont-noto-sans-kr" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:arabic {:font-id "gfont-noto-sans-arabic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cyrillic {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:greek {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:hebrew {:font-id "gfont-noto-sans-hebrew" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:thai {:font-id "gfont-noto-sans-thai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:devanagari {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tamil {:font-id "gfont-noto-sans-tamil" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:latin-ext {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vietnamese {:font-id "gfont-noto-sans" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:armenian {:font-id "gfont-noto-sans-armenian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bengali {:font-id "gfont-noto-sans-bengali" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:cherokee {:font-id "gfont-noto-sans-cherokee" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ethiopic {:font-id "gfont-noto-sans-ethiopic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:georgian {:font-id "gfont-noto-sans-georgian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gujarati {:font-id "gfont-noto-sans-gujarati" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:gurmukhi {:font-id "gfont-noto-sans-gurmukhi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:khmer {:font-id "gfont-noto-sans-khmer" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:lao {:font-id "gfont-noto-sans-lao" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:malayalam {:font-id "gfont-noto-sans-malayalam" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:myanmar {:font-id "gfont-noto-sans-myanmar" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sinhala {:font-id "gfont-noto-sans-sinhala" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:telugu {:font-id "gfont-noto-sans-telugu" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tibetan {:font-id "gfont-noto-serif-tibetan" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:javanese {:font-id "gfont-noto-sans-javanese" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:kannada {:font-id "gfont-noto-sans-kannada" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:oriya {:font-id "gfont-noto-sans-oriya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:mongolian {:font-id "gfont-noto-sans-mongolian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:syriac {:font-id "gfont-noto-sans-syriac" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:tifinagh {:font-id "gfont-noto-sans-tifinagh" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:coptic {:font-id "gfont-noto-sans-coptic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:ol-chiki {:font-id "gfont-noto-sans-ol-chiki" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:vai {:font-id "gfont-noto-sans-vai" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:shavian {:font-id "gfont-noto-sans-shavian" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:osmanya {:font-id "gfont-noto-sans-osmanya" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:runic {:font-id "gfont-noto-sans-runic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:old-italic {:font-id "gfont-noto-sans-old-italic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:brahmi {:font-id "gfont-noto-sans-brahmi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:modi {:font-id "gfont-noto-sans-modi" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:sora-sompeng {:font-id "gfont-noto-sans-sora-sompeng" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:bamum {:font-id "gfont-noto-sans-bamum" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:meroitic {:font-id "gfont-noto-sans-meroitic" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols {:font-id "gfont-noto-sans-symbols" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:symbols-2 {:font-id "gfont-noto-sans-symbols-2" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}
:music {:font-id "gfont-noto-music" :font-variant-id "regular" :style 0 :weight 400 :is-fallback true}})
(defn add-noto-fonts [fonts languages]
(reduce (fn [acc lang]
(if-let [font (get noto-fonts lang)]
(conj acc font)
acc))
fonts
languages))

View File

@ -0,0 +1,47 @@
;; 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.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)."
(:require
[app.common.types.fills :as types.fills]))
(defn- fill-image-ids
[fills]
(some-> fills not-empty types.fills/coerce types.fills/get-image-ids))
(defn- stroke-image-ids
[strokes]
(keep (comp :id :stroke-image) strokes))
(defn- text-image-ids
"Image-fill ids referenced by a text shape's span fills."
[content]
(when content
(->> (tree-seq :children :children content)
(mapcat :fills)
(keep (comp :id :fill-image)))))
(defn shape-image-ids
"Distinct image ids referenced by one shape: its fills, its strokes' image
fills, and (for texts) its spans' image fills."
[shape]
(-> #{}
(into (fill-image-ids (:fills shape)))
(into (stroke-image-ids (:strokes shape)))
;; `:content` is only a text tree for text shapes (paths reuse the key
;; for geometry).
(into (when (= :text (:type shape))
(text-image-ids (:content shape))))))
(defn scene-image-ids
"Distinct image ids referenced anywhere in an `objects` map."
[scene]
(into #{} (mapcat shape-image-ids) (vals scene)))

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.render-wasm.serialize-shape
"Single source of truth for the host-independent part of serializing a whole
shape into the WASM design state.
Both batch serializers call this so they can't drift:
- the workspace `app.render-wasm.api/set-object` (browser), and
- the headless exporter `app.wasm.serialize/set-shape!` (Node).
It applies only the properties that need no host-specific resources or driver:
base props, children, blur, background blur, shadows, svg attrs, group mask,
bool type, path/bool geometry and text grow type. The parts that DO differ by
host are handled by each caller AFTER this runs:
- fills / strokes (image bytes are fetched + uploaded differently),
- text content (fonts),
- svg-raw markup (browser renders it via React),
- layout (grid/flex — workspace only).
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]))
(defn serialize-shape!
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
runs first because it selects the current shape (`use_shape`) the rest mutate."
[shape]
(let [type (get shape :type)]
(shapes/set-shape-base-props shape)
(props/set-shape-children (get shape :shapes))
(props/set-shape-blur (get shape :blur))
(props/set-shape-background-blur (get shape :background-blur))
(props/set-shape-shadows (get shape :shadow))
(when (some? (get shape :svg-attrs))
(props/set-shape-svg-attrs (get shape :svg-attrs)))
(when (= type :group)
(props/set-masked (boolean (get shape :masked-group))))
(when (= type :bool)
(props/set-shape-bool-type (get shape :bool-type)))
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
(props/set-shape-path-content (get shape :content)))
(when (= type :text)
(props/set-shape-grow-type (get shape :grow-type)))))

View File

@ -0,0 +1,198 @@
;; 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.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!`.
Fully portable (no store/DOM/React), so it runs under Node too."
(:require
[app.common.data :as d]
[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)
(def ^:const SPAN-ATTR-U8-SIZE 64)
(def ^:const MAX-TEXT-FILLS types.fills.impl/MAX-FILLS)
(def ^:private default-font-size 14)
(def ^:private default-line-height 1.2)
(def ^:private default-letter-spacing 0.0)
;; --- pure attribute serializers -------------------------------------------
(defn serialize-font-size
[font-size]
(cond
(number? font-size) font-size
(string? font-size) (or (d/parse-double font-size) default-font-size)
:else default-font-size))
(defn serialize-font-weight
[font-weight]
(if (number? font-weight)
font-weight
(let [font-weight-str (str font-weight)]
(cond
(re-matches #"\d+" font-weight-str) (js/Number font-weight-str)
(str/includes? font-weight-str "bold") 700
(str/includes? font-weight-str "black") 900
(str/includes? font-weight-str "extrabold") 800
(str/includes? font-weight-str "extralight") 200
(str/includes? font-weight-str "light") 300
(str/includes? font-weight-str "medium") 500
(str/includes? font-weight-str "semibold") 600
(str/includes? font-weight-str "thin") 100
:else 400))))
(defn serialize-line-height
([line-height] (serialize-line-height line-height default-line-height))
([line-height default-value]
(cond
(number? line-height) line-height
(string? line-height) (or (d/parse-double line-height) default-value)
:else default-value)))
(defn serialize-letter-spacing
[letter-spacing]
(cond
(number? letter-spacing) letter-spacing
(string? letter-spacing) (or (d/parse-double letter-spacing) default-letter-spacing)
:else default-letter-spacing))
;; --- binary writers --------------------------------------------------------
(defn- encode-text
"Into an UTF8 buffer. Returns an ArrayBuffer instance."
[text]
(let [encoder (js/TextEncoder.)]
(.encode encoder text)))
(defn- write-span-fills
[offset dview fills]
(let [new-ofset (reduce (fn [offset fill]
(let [opacity (get fill :fill-opacity 1.0)
color (get fill :fill-color)
gradient (get fill :fill-color-gradient)
image (get fill :fill-image)]
(cond
(some? color)
(types.fills.impl/write-solid-fill offset dview opacity color)
(some? gradient)
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
(some? image)
(types.fills.impl/write-image-fill offset dview opacity image))))
offset
fills)
padding-fills (max 0 (- MAX-TEXT-FILLS (count fills)))]
(+ new-ofset (* padding-fills types.fills.impl/FILL-U8-SIZE))))
(defn- write-paragraph
[offset dview paragraph]
(let [text-align (sr/translate-text-align (get paragraph :text-align))
text-direction (sr/translate-text-direction (get paragraph :text-direction))
text-decoration (sr/translate-text-decoration (get paragraph :text-decoration))
text-transform (sr/translate-text-transform (get paragraph :text-transform))
line-height (serialize-line-height (get paragraph :line-height))
letter-spacing (serialize-letter-spacing (get paragraph :letter-spacing))]
(-> offset
(mem/write-u8 dview text-align)
(mem/write-u8 dview text-direction)
(mem/write-u8 dview text-decoration)
(mem/write-u8 dview text-transform)
(mem/write-f32 dview line-height)
(mem/write-f32 dview letter-spacing)
(mem/assert-written offset PARAGRAPH-ATTR-U8-SIZE))))
(defn- write-spans
[offset dview spans paragraph normalize-font-id]
(let [paragraph-font-size (get paragraph :font-size)
paragraph-font-weight (-> paragraph :font-weight serialize-font-weight)
paragraph-line-height (serialize-line-height (get paragraph :line-height))]
(reduce
(fn [offset span]
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
font-size (serialize-font-size (get span :font-size paragraph-font-size))
line-height (serialize-line-height (get span :line-height) paragraph-line-height)
letter-spacing (serialize-letter-spacing (get span :letter-spacing))
font-weight (serialize-font-weight (get span :font-weight paragraph-font-weight))
font-id (normalize-font-id (get span :font-id "sourcesanspro"))
font-family (hash (get span :font-family "sourcesanspro"))
text-buffer (encode-text (get span :text ""))
text-length (mem/size text-buffer)
fills (take MAX-TEXT-FILLS (get span :fills []))
font-variant-id (get span :font-variant-id)
font-variant-id (if (uuid? font-variant-id) font-variant-id uuid/zero)
text-decoration (or (sr/translate-text-decoration (:text-decoration span))
(sr/translate-text-decoration (:text-decoration paragraph))
(sr/translate-text-decoration "none"))
text-transform (or (sr/translate-text-transform (:text-transform span))
(sr/translate-text-transform (:text-transform paragraph))
(sr/translate-text-transform "none"))
text-direction (or (sr/translate-text-direction (:text-direction span))
(sr/translate-text-direction (:text-direction paragraph))
(sr/translate-text-direction "ltr"))]
(-> offset
(mem/write-u8 dview font-style)
(mem/write-u8 dview text-decoration)
(mem/write-u8 dview text-transform)
(mem/write-u8 dview text-direction)
(mem/write-f32 dview font-size)
(mem/write-f32 dview line-height)
(mem/write-f32 dview letter-spacing)
(mem/write-u32 dview font-weight)
(mem/write-uuid dview font-id)
(mem/write-i32 dview font-family)
(mem/write-uuid dview (d/nilv font-variant-id uuid/zero))
(mem/write-i32 dview text-length)
(mem/write-i32 dview (count fills))
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
(write-span-fills dview fills))))
offset
spans)))
(defn write-shape-text!
"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,
- `: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
normalize-paragraph identity
normalize-span (fn [span _paragraph] span)}}]
(let [paragraph (normalize-paragraph paragraph)
spans (map #(normalize-span % paragraph) spans)
num-spans (count spans)
fills-size (* types.fills.impl/FILL-U8-SIZE MAX-TEXT-FILLS)
metadata-size (+ PARAGRAPH-ATTR-U8-SIZE
(* num-spans (+ SPAN-ATTR-U8-SIZE fills-size)))
text-buffer (encode-text text)
text-size (mem/size text-buffer)
total-size (+ 4 metadata-size text-size)
heapu8 (mem/get-heap-u8)
dview (mem/get-data-view)
offset (mem/alloc total-size)]
(-> offset
(mem/write-u32 dview num-spans)
(write-paragraph dview paragraph)
(write-spans dview spans paragraph normalize-font-id)
(mem/write-buffer heapu8 text-buffer))
(h/call wasm/internal-module "_set_shape_text_content")))

View File

@ -41,7 +41,7 @@ export EMCC_CFLAGS="--no-entry \
-sMAX_WEBGL_VERSION=2 \
-sEXPORT_NAME=createRustSkiaModule \
-sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \
-sENVIRONMENT=web \
-sENVIRONMENT=web,worker,node \
-sMODULARIZE=1 \
-sDISABLE_EXCEPTION_CATCHING=1 \
-sFILESYSTEM=0 \

View File

@ -4,7 +4,7 @@ use macros::wasm_error;
use crate::emscripten::init_gl;
use crate::mem;
use crate::render::{gpu_state::GpuState, RenderState};
use crate::render::{gpu_state::GpuState, RenderResources, RenderState};
use crate::state::{State, TextEditorState, UIState};
static mut DESIGN_STATE: *mut State = std::ptr::null_mut();
@ -23,7 +23,12 @@ static mut GPU_STATE: *mut GpuState = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_gpu_state() -> &'static mut GpuState {
unsafe {
debug_assert!(!GPU_STATE.is_null(), "GPU State is null");
// `assert!` (not `debug_assert!`): a headless instance never inits GPU
// state, so an interactive call must fail-fast rather than deref null.
assert!(
!GPU_STATE.is_null(),
"GPU State is null (headless instance?)"
);
&mut *GPU_STATE
}
}
@ -39,6 +44,26 @@ pub(crate) fn get_render_state() -> &'static mut RenderState {
}
}
/// Whether the global render state has been initialized. The headless export
/// path (`init_headless`) boots without a GPU render state, so tile and
/// interaction bookkeeping (which only exists to drive incremental on-screen
/// rendering) must be skipped when this is false.
#[inline(always)]
pub(crate) fn has_render_state() -> bool {
unsafe { !RENDER_STATE.is_null() }
}
/// GPU-free resources for the headless export path
static mut RENDER_RESOURCES: *mut RenderResources = std::ptr::null_mut();
#[inline(always)]
pub(crate) fn get_resources() -> &'static mut RenderResources {
unsafe {
debug_assert!(!RENDER_RESOURCES.is_null(), "Render Resources is null");
&mut *RENDER_RESOURCES
}
}
/// Text Editor State
static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut();
@ -113,6 +138,23 @@ fn render_init(width: i32, height: i32) {
}
}
/// Initializes the interactive RenderResources (GPU image store).
fn resources_init() {
unsafe {
let resources = RenderResources::try_new().expect("Cannot initialize RenderResources");
RENDER_RESOURCES = Box::into_raw(Box::new(resources));
}
}
/// Initializes GPU-free RenderResources for the headless export path.
fn resources_init_headless() {
unsafe {
let resources = RenderResources::try_new_headless()
.expect("Cannot initialize headless RenderResources");
RENDER_RESOURCES = Box::into_raw(Box::new(resources));
}
}
/// Initializes DesignState.
fn design_init() {
unsafe {
@ -143,6 +185,7 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
#[cfg(target_arch = "wasm32")]
init_gl!();
gpu_init();
resources_init();
render_init(width, height);
text_editor_init();
design_init();
@ -150,6 +193,19 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
Ok(())
}
/// Boots the engine for headless export with no GPU/WebGL context: only
/// `RenderResources` (GPU-free) + the design state. The export (raster/PDF)
/// paths render onto their own surface; the interactive render loop is not
/// available on an instance initialized this way. `width`/`height` are kept for
/// API symmetry with `init` but unused — the export sizes from shape bounds.
#[no_mangle]
#[wasm_error]
pub extern "C" fn init_headless(_width: i32, _height: i32) -> Result<()> {
resources_init_headless();
design_init();
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn clean_up() -> Result<()> {

View File

@ -0,0 +1,81 @@
// Headless render-wasm smoke test: loads the artifact in Node (no browser/
// WebGL), boots via init_headless, renders a red rect to PNG, validates it.
// Requires render-wasm built with -sENVIRONMENT=web,worker,node.
// node render-wasm/src/js/render-headless.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, resolve } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const ARTIFACT_DIR = resolve(here, "../../../frontend/resources/public/js");
const JS_PATH = resolve(ARTIFACT_DIR, "render-wasm.js");
const WASM_PATH = resolve(ARTIFACT_DIR, "render-wasm.wasm");
// 4 + max(gradient 156, image 36, solid 4); solid fill = tag u8 @0, ARGB u32 @4.
const FILL_U8_SIZE = 160;
async function main() {
const wasmBytes = readFileSync(WASM_PATH);
const factory = (await import(pathToFileURL(JS_PATH).href)).default;
const Module = await factory({
instantiateWasm(imports, success) {
WebAssembly.instantiate(wasmBytes, imports).then(({ instance }) => success(instance));
return {};
},
locateFile: (p) => resolve(ARTIFACT_DIR, p),
printErr: (s) => console.error("[wasm:err]", s),
});
const call = (name, ...args) => {
const fn = Module["_" + name];
if (typeof fn !== "function") throw new Error(`export _${name} missing`);
return fn(...args);
};
call("init_headless", 800, 600);
// 200x120 rect; any fixed non-nil uuid, as long as use/render agree.
const [a, b, c, d] = [1, 2, 3, 4];
call("init_shapes_pool", 1);
call("use_shape", a, b, c, d);
call("set_shape_selrect", 0, 0, 200, 120);
// Solid opaque-red fill: [num_fills u8][3 pad][160-byte record].
const ptr = call("alloc_bytes", 4 + FILL_U8_SIZE);
const buf = Module.HEAPU8.subarray(ptr, ptr + 4 + FILL_U8_SIZE);
buf.fill(0);
buf[0] = 1;
const fill = new DataView(buf.buffer, buf.byteOffset + 4, FILL_U8_SIZE);
fill.setUint8(0, 0x00);
fill.setUint32(4, 0xffff0000, true);
call("set_shape_fills");
// Result layout: [len u32][w u32][h u32][png...].
const resPtr = call("render_shape_raster", a, b, c, d, 1.0);
const u32 = Module.HEAPU32;
const base = resPtr >>> 2;
const [len, width, height] = [u32[base], u32[base + 1], u32[base + 2]];
const png = Module.HEAPU8.slice(resPtr + 12, resPtr + 12 + len);
call("free_bytes");
const out = "/tmp/headless-render.png";
writeFileSync(out, png);
const MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const okMagic = MAGIC.every((v, i) => png[i] === v);
const ihdrW = (png[16] << 24) | (png[17] << 16) | (png[18] << 8) | png[19];
const ihdrH = (png[20] << 24) | (png[21] << 16) | (png[22] << 8) | png[23];
console.log(`render_shape_raster -> ${len} bytes (${width}x${height}); PNG ${ihdrW}x${ihdrH} -> ${out}`);
if (!okMagic || ihdrW !== 200 || ihdrH !== 120 || len < 100) {
throw new Error("unexpected PNG output");
}
console.log("OK: headless render in Node works.");
}
main().catch((e) => {
console.error("FAILED:", e);
process.exit(1);
});

View File

@ -22,7 +22,7 @@ use std::collections::HashMap;
use crate::error::{Error, Result};
use crate::render::{FrameType, RenderFlag};
use globals::{get_design_state, get_gpu_state, get_render_state};
use globals::{get_design_state, get_gpu_state, get_render_state, get_resources, has_render_state};
use macros::wasm_error;
use math::{Bounds, Matrix};
@ -720,7 +720,7 @@ pub extern "C" fn is_image_cached(
is_thumbnail: bool,
) -> Result<bool> {
let id = uuid_from_u32_quartet(a, b, c, d);
let result = get_render_state().has_image(&id, is_thumbnail);
let result = get_resources().images.contains(&id, is_thumbnail);
Ok(result)
}
@ -734,8 +734,7 @@ pub extern "C" fn set_shape_svg_raw_content() -> Result<()> {
.trim_end_matches('\0')
.to_string();
let render_state = get_render_state();
let font_manager = skia::FontMgr::from(render_state.fonts().font_provider().clone());
let font_manager = skia::FontMgr::from(get_resources().fonts.font_provider().clone());
shape.set_svg_raw_content(svg_raw_content);
shape.update_svg_raw_content(font_manager);
});
@ -1001,6 +1000,36 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) -
})
}
/// PNG via CPU raster (no GPU/WebGL). Returns `[len][width][height][png]` (LE),
/// same layout as `render_shape_pixels`.
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_shape_raster(
a: u32,
b: u32,
c: u32,
d: u32,
scale: f32,
) -> Result<*mut u8> {
let id = uuid_from_u32_quartet(a, b, c, d);
if !scale.is_finite() {
return Err(Error::CriticalError("Scale is not finite".to_string()));
}
with_state!(state, {
let (data, width, height) = state.render_shape_raster(&id, scale)?;
let len = data.len() as u32;
let mut buf = Vec::with_capacity(12 + data.len());
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&width.to_le_bytes());
buf.extend_from_slice(&height.to_le_bytes());
buf.extend_from_slice(&data);
Ok(mem::write_bytes(buf))
})
}
pub fn main() {
// Why an empty main?
// Right now with the target `wasm32-unknown-emscripten` it is not possible

View File

@ -7,6 +7,8 @@ pub mod grid_layout;
mod images;
mod options;
pub mod pdf;
pub mod raster;
mod resources;
mod shadows;
pub mod shape_renderer;
mod strokes;
@ -34,10 +36,11 @@ use crate::tiles::{self, PendingTiles, TileRect};
use crate::uuid::Uuid;
use crate::view::Viewbox;
use crate::wapi;
use crate::{get_gpu_state, performance};
use crate::{get_gpu_state, get_resources, performance};
pub use fonts::*;
pub use images::*;
pub(crate) use resources::RenderResources;
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
@ -348,15 +351,12 @@ pub(crate) struct RenderState {
pub options: RenderOptions,
stats: RenderStats,
pub surfaces: Surfaces,
pub fonts: FontStore,
pub viewbox: Viewbox,
pub cached_viewbox: Viewbox,
pub images: ImageStore,
pub background_color: skia::Color,
// Stack of nodes pending to be rendered.
pending_nodes: Vec<NodeRenderState>,
pub current_tile: Option<tiles::Tile>,
pub sampling_options: skia::SamplingOptions,
pub render_area: Rect,
// render_area expanded by surface margins — used for visibility checks so that
// shapes in the margin zone are rendered (needed for background blur sampling).
@ -546,19 +546,18 @@ impl RenderState {
pub fn try_new(width: i32, height: i32) -> Result<RenderState> {
// This needs to be done once per WebGL context.
let sampling_options =
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
let sampling_options = get_resources().sampling_options;
let fonts = FontStore::try_new()?;
let surfaces = Surfaces::try_new(
(width, height),
sampling_options,
tiles::get_tile_dimensions(),
)?;
// This is used multiple times everywhere so instead of creating new instances every
// time we reuse this one.
Self::assemble(width, height, surfaces)
}
fn assemble(width: i32, height: i32, surfaces: Surfaces) -> Result<RenderState> {
let viewbox = Viewbox::new(width as f32, height as f32);
let tiles = tiles::TileHashMap::new();
let options = RenderOptions::default();
@ -567,14 +566,11 @@ impl RenderState {
options,
stats: RenderStats::new(),
surfaces,
fonts,
viewbox,
cached_viewbox: Viewbox::new(0., 0.),
images: ImageStore::new(),
background_color: skia::Color::TRANSPARENT,
pending_nodes: vec![],
current_tile: None,
sampling_options,
render_area: Rect::new_empty(),
render_area_with_margins: Rect::new_empty(),
tiles,
@ -778,35 +774,6 @@ impl RenderState {
Ok(result)
}
pub fn fonts(&self) -> &FontStore {
&self.fonts
}
pub fn fonts_mut(&mut self) -> &mut FontStore {
&mut self.fonts
}
pub fn add_image(&mut self, id: Uuid, is_thumbnail: bool, image_data: &[u8]) -> Result<()> {
self.images.add(id, is_thumbnail, image_data)
}
/// Adds an image from an existing WebGL texture, avoiding re-decoding
pub fn add_image_from_gl_texture(
&mut self,
id: Uuid,
is_thumbnail: bool,
texture_id: u32,
width: i32,
height: i32,
) -> Result<()> {
self.images
.add_image_from_gl_texture(id, is_thumbnail, texture_id, width, height)
}
pub fn has_image(&self, id: &Uuid, is_thumbnail: bool) -> bool {
self.images.contains(id, is_thumbnail)
}
pub fn set_debug_flags(&mut self, debug: u32) {
self.options.flags = debug;
}
@ -821,7 +788,7 @@ impl RenderState {
self.viewbox.width().floor() as i32,
self.viewbox.height().floor() as i32,
)?;
self.fonts.set_scale_debug_font(dpr);
get_resources().fonts.set_scale_debug_font(dpr);
self.viewbox.set_dpr(dpr);
self.surfaces.set_dpr(dpr);
}
@ -969,7 +936,7 @@ impl RenderState {
text_paint.set_color(skia::Color::GRAY);
text_paint.set_anti_alias(true);
let font = self.fonts.debug_font();
let font = get_resources().fonts.debug_font();
// FIXME
let text = "Loading…";
let (text_width, _) = font.measure_str(text, None);
@ -1398,7 +1365,7 @@ impl RenderState {
}
match &shape.shape_type {
Type::SVGRaw(_) => {
Type::SVGRaw(sr) => {
if let Some(svg_transform) = shape.svg_transform() {
matrix.pre_concat(&svg_transform);
}
@ -1410,7 +1377,18 @@ impl RenderState {
if let Some(svg) = shape.svg.as_ref() {
svg.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
} else {
panic!("SVG should be available");
let font_manager =
skia::FontMgr::from(get_resources().fonts.font_provider().clone());
let dom_result = skia::svg::Dom::from_str(&sr.content, font_manager);
match dom_result {
Ok(dom) => {
dom.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
shape.to_mut().set_svg(dom);
}
Err(e) => {
eprintln!("Error parsing SVG. Error: {}", e);
}
}
}
}
@ -2987,7 +2965,7 @@ impl RenderState {
surface.draw(
drop_canvas,
(0.0, 0.0),
self.sampling_options,
get_resources().sampling_options,
Some(&drop_paint),
);
drop_canvas.restore();
@ -2997,7 +2975,7 @@ impl RenderState {
surface.draw(
drop_canvas,
(0.0, 0.0),
self.sampling_options,
get_resources().sampling_options,
Some(&drop_paint),
);
drop_canvas.restore();

View File

@ -6,6 +6,7 @@ use macros::wasm_error;
#[cfg(target_arch = "wasm32")]
use crate::get_render_state;
use crate::get_resources;
use skia_safe::{self as skia, Rect};
@ -62,16 +63,16 @@ pub fn render_wasm_label(render_state: &mut RenderState) {
"WebGL rendering"
};
let dpr = render_state.options.dpr;
let (scalar, _) = render_state.fonts.debug_font().measure_str(str, None);
let (scalar, _) = get_resources().fonts.debug_font().measure_str(str, None);
let mut p = skia::Point::new(canvas_width - (20.0 * dpr) - scalar, 50.0 * dpr);
let debug_font = render_state.fonts.debug_font();
let debug_font = get_resources().fonts.debug_font();
canvas.draw_str(str, p, debug_font, &paint);
if render_state.options.is_text_editor_v3() {
str = "Text Editor v3";
let (scalar, _) = render_state.fonts.debug_font().measure_str(str, None);
let (scalar, _) = get_resources().fonts.debug_font().measure_str(str, None);
p.x = canvas_width - (20.0 * dpr) - scalar;
p.y += 15.0 * dpr;
canvas.draw_str(str, p, debug_font, &paint);
@ -85,7 +86,7 @@ pub fn render_debug_tiles_for_viewbox(render_state: &mut RenderState) {
paint.set_color(skia::Color::RED);
let str_rect = format!("{} {} {} {}", sx, sy, ex, ey);
let debug_font = render_state.fonts.debug_font();
let debug_font = get_resources().fonts.debug_font();
canvas.draw_str(str_rect, skia::Point::new(100.0, 150.0), debug_font, &paint);
}
@ -104,7 +105,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) {
let str_rect = format!("{} {} {} {}", sx, sy, ex, ey);
let debug_font = render_state.fonts.debug_font();
let debug_font = get_resources().fonts.debug_font();
canvas.draw_str(str_rect, skia::Point::new(100.0, 100.0), debug_font, &paint);
let tile_size = tiles::get_tile_size(scale);
@ -114,7 +115,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) {
let debug_rect = get_debug_rect(rect);
let p = skia::Point::new(debug_rect.x(), debug_rect.y() - 1.);
let str = format!("{}:{}", x, y);
let debug_font = render_state.fonts.debug_font();
let debug_font = get_resources().fonts.debug_font();
paint.set_style(skia::PaintStyle::Fill);
canvas.draw_str(str, p, debug_font, &paint);
canvas.draw_rect(debug_rect, &paint);
@ -153,7 +154,7 @@ pub fn render_workspace_current_tile(
let tile_position_origin = skia::Point::new(rect.x() + 10., rect.y() + 20.);
p.set_style(skia::PaintStyle::Fill);
let str = format!("{prefix} {}:{}", tile.x(), tile.y());
let mut debug_font = render_state.fonts.debug_font().clone();
let mut debug_font = get_resources().fonts.debug_font().clone();
debug_font.set_size(16.);
canvas.draw_str(str, tile_position_origin, &debug_font, &p);
}

View File

@ -2,6 +2,7 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::error::Result;
use crate::get_resources;
use crate::render::get_source_rect;
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
@ -13,7 +14,7 @@ fn draw_image_fill(
antialias: bool,
surface_id: SurfaceId,
) {
let Some(image) = render_state.images.get(&image_fill.id()) else {
let Some(image) = get_resources().images.get(&image_fill.id()) else {
return;
};
@ -83,7 +84,7 @@ fn draw_image_fill(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
dest_rect,
render_state.sampling_options,
get_resources().sampling_options,
paint,
);

View File

@ -2,6 +2,7 @@ use skia_safe::{self as skia, ImageFilter, Rect};
use super::{RenderState, SurfaceId};
use crate::error::Result;
use crate::get_resources;
/// Composes two image filters, returning a combined filter if both are present,
/// or the individual filter if only one is present, or None if neither is present.
@ -51,12 +52,12 @@ where
canvas.save();
canvas.scale((1.0 / scale, 1.0 / scale));
canvas.translate((bounds.left * scale, bounds.top * scale));
surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None);
surface.draw(canvas, (0.0, 0.0), get_resources().sampling_options, None);
canvas.restore();
} else {
canvas.save();
canvas.translate((bounds.left, bounds.top));
surface.draw(canvas, (0.0, 0.0), render_state.sampling_options, None);
surface.draw(canvas, (0.0, 0.0), get_resources().sampling_options, None);
canvas.restore();
}
Ok(true)

View File

@ -61,7 +61,8 @@ enum StoredImage {
pub struct ImageStore {
images: HashMap<(Uuid, bool), StoredImage>,
context: Box<DirectContext>,
/// gpu-only
context: Option<Box<DirectContext>>,
}
/// Creates a Skia image from an existing WebGL texture.
@ -149,7 +150,17 @@ impl ImageStore {
let context = &gpu_state.context;
Self {
images: HashMap::with_capacity(2048),
context: Box::new(context.clone()),
context: Some(Box::new(context.clone())),
}
}
/// GPU-free image store for the headless export path: no GPU context, so
/// images are kept as encoded bytes and decoded on the CPU at draw time
/// (see `get_cpu_image`).
pub fn new_without_gpu() -> Self {
Self {
images: HashMap::with_capacity(16),
context: None,
}
}
@ -167,10 +178,18 @@ impl ImageStore {
let raw_data = image_data.to_vec();
if let Some(gpu_image) = decode_image(&mut self.context, &raw_data) {
self.images.insert(key, StoredImage::Gpu(gpu_image));
} else {
self.images.insert(key, StoredImage::Raw(raw_data));
match self.context.as_mut() {
Some(context) => {
if let Some(gpu_image) = decode_image(context, &raw_data) {
self.images.insert(key, StoredImage::Gpu(gpu_image));
} else {
self.images.insert(key, StoredImage::Raw(raw_data));
}
}
// GPU-free: keep the encoded bytes; decoded on the CPU at draw time.
None => {
self.images.insert(key, StoredImage::Raw(raw_data));
}
}
Ok(())
}
@ -193,7 +212,12 @@ impl ImageStore {
}
// Create a Skia image from the existing GL texture
let image = create_image_from_gl_texture(&mut self.context, texture_id, width, height)?;
let Some(context) = self.context.as_mut() else {
return Err(crate::error::Error::CriticalError(
"Cannot register a GL texture without a GPU context".to_string(),
));
};
let image = create_image_from_gl_texture(context, texture_id, width, height)?;
self.images.insert(key, StoredImage::Gpu(image));
Ok(())
@ -214,8 +238,27 @@ impl ImageStore {
}
pub fn get_cpu_image(&mut self, id: &Uuid) -> Option<Image> {
let gpu_image = self.get(id)?.clone();
gpu_image.make_non_texture_image(self.context.as_mut())
// GPU path: promote to a texture, then copy to a CPU image.
if self.context.is_some() {
let gpu_image = self.get(id)?.clone();
let context = self.context.as_mut()?;
return gpu_image.make_non_texture_image(context.as_mut());
}
// Headless (no GPU context): decode the stored encoded bytes directly to
// a CPU image, which draws fine on a raster/PDF canvas. Try full first,
// then thumbnail.
self.decode_raw_cpu_image(id, false)
.or_else(|| self.decode_raw_cpu_image(id, true))
}
fn decode_raw_cpu_image(&self, id: &Uuid, is_thumbnail: bool) -> Option<Image> {
match self.images.get(&(*id, is_thumbnail))? {
StoredImage::Raw(raw_data) => {
let data = unsafe { skia::Data::new_bytes(raw_data) };
Image::from_encoded(&data)
}
StoredImage::Gpu(img) => Some(img.clone()),
}
}
fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> {
@ -225,7 +268,8 @@ impl ImageStore {
match entry {
StoredImage::Gpu(ref img) => Some(img),
StoredImage::Raw(raw_data) => {
let gpu_image = decode_image(&mut self.context, raw_data)?;
let context = self.context.as_mut()?;
let gpu_image = decode_image(context, raw_data)?;
*entry = StoredImage::Gpu(gpu_image);
if let StoredImage::Gpu(ref img) = entry {

View File

@ -69,6 +69,7 @@ impl RenderOptions {
self.fast_mode = enabled;
}
#[cfg(target_arch = "wasm32")]
pub fn set_capture_frames(&mut self, capture_frames: i32) {
self.capture_frames = capture_frames;
}

View File

@ -4,8 +4,8 @@ use crate::error::Result;
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::vector::{self, VectorTarget};
use super::RenderState;
use super::vector;
use super::RenderResources;
/// Renders a shape tree to a PDF document and returns the raw PDF bytes.
///
@ -16,7 +16,7 @@ use super::RenderState;
/// pixel-based (blur, shadows with blur) are rasterised internally by Skia's
/// PDF backend
pub fn render_to_pdf(
shared: &mut RenderState,
shared: &mut RenderResources,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
@ -24,7 +24,15 @@ pub fn render_to_pdf(
let shape = tree
.get(id)
.ok_or_else(|| crate::error::Error::CriticalError("Shape not found for PDF".to_string()))?;
let bounds = shape.extrect(tree, scale);
// Boards export at their own bounds (like the classic exporter, which
// screenshots exactly the board box): content overflowing a board lands
// outside the page/surface and is cropped. Other shapes keep the extended
// rect so their own shadows/blur are included.
let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) {
shape.selrect()
} else {
shape.extrect(tree, scale)
};
let page_w = bounds.width() * scale;
let page_h = bounds.height() * scale;
@ -45,7 +53,7 @@ pub fn render_to_pdf(
let page_canvas = on_page.canvas();
page_canvas.scale((scale, scale));
page_canvas.translate((-bounds.left(), -bounds.top()));
vector::render_tree(shared, page_canvas, id, tree, scale, VectorTarget::Pdf)?;
vector::render_tree_pdf(shared, page_canvas, id, tree, scale, bounds)?;
}
let document = on_page.end_page();

View File

@ -0,0 +1,59 @@
use skia_safe as skia;
use crate::error::{Error, Result};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::vector;
use super::RenderResources;
/// Renders a shape tree to PNG bytes on a CPU raster surface (no GPU/WebGL).
/// Returns `(png_bytes, width_px, height_px)`.
pub fn render_to_raster(
shared: &mut RenderResources,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
) -> Result<(Vec<u8>, i32, i32)> {
let Some(shape) = tree.get(id) else {
return Ok((Vec::new(), 0, 0));
};
// Boards export at their own bounds (like the classic exporter, which
// screenshots exactly the board box): content overflowing a board lands
// outside the page/surface and is cropped. Other shapes keep the extended
// rect so their own shadows/blur are included.
let bounds = if matches!(shape.shape_type, crate::shapes::Type::Frame(_)) {
shape.selrect()
} else {
shape.extrect(tree, scale)
};
let width = (bounds.width() * scale).ceil() as i32;
let height = (bounds.height() * scale).ceil() as i32;
if width <= 0 || height <= 0 {
return Ok((Vec::new(), 0, 0));
}
let mut surface = skia::surfaces::raster_n32_premul((width, height)).ok_or_else(|| {
Error::CriticalError("Failed to create raster export surface".to_string())
})?;
{
let canvas = surface.canvas();
canvas.clear(skia::Color::TRANSPARENT);
canvas.scale((scale, scale));
canvas.translate((-bounds.left(), -bounds.top()));
vector::render_tree(shared, canvas, id, tree, scale, bounds)?;
}
let data = surface
.image_snapshot()
.encode(
None::<&mut skia::gpu::DirectContext>,
skia::EncodedImageFormat::PNG,
100,
)
.ok_or_else(|| Error::CriticalError("PNG encode failed".to_string()))?;
Ok((data.as_bytes().to_vec(), width, height))
}

View File

@ -0,0 +1,43 @@
use skia_safe as skia;
use crate::error::Result;
use super::{FontStore, ImageStore};
/// Render dependencies that the export (PDF) path needs independently of the
/// surface/tile/GPU machinery in [`RenderState`]: the font store, the
/// decoded-image store, and the default sampling options.
///
/// Splitting these out keeps the vector/PDF export path (which takes
/// `&mut RenderResources`) decoupled from the interactive renderer, and gives a
/// single source of truth reached through `get_resources()`.
pub(crate) struct RenderResources {
pub fonts: FontStore,
pub images: ImageStore,
pub sampling_options: skia::SamplingOptions,
}
impl RenderResources {
pub fn try_new() -> Result<Self> {
Ok(Self {
fonts: FontStore::try_new()?,
images: ImageStore::new(),
sampling_options: skia::SamplingOptions::new(
skia::FilterMode::Linear,
skia::MipmapMode::Nearest,
),
})
}
/// Headless export resources: CPU-only image store, no GPU/WebGL.
pub fn try_new_headless() -> Result<Self> {
Ok(Self {
fonts: FontStore::try_new()?,
images: ImageStore::new_without_gpu(),
sampling_options: skia::SamplingOptions::new(
skia::FilterMode::Linear,
skia::MipmapMode::Nearest,
),
})
}
}

View File

@ -8,6 +8,7 @@ use skia_safe::{self as skia, ImageFilter, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::error::{Error, Result};
use crate::get_resources;
use crate::render::filters::compose_filters;
use crate::render::{get_dest_rect, get_source_rect};
@ -467,7 +468,7 @@ fn draw_image_stroke_in_container(
surface_id: SurfaceId,
) -> Result<()> {
let scale = render_state.get_scale();
let Some(image) = render_state.images.get(&image_fill.id()) else {
let Some(image) = get_resources().images.get(&image_fill.id()) else {
return Ok(());
};
@ -575,7 +576,7 @@ fn draw_image_stroke_in_container(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
dest_rect,
render_state.sampling_options,
get_resources().sampling_options,
&image_paint,
);

View File

@ -106,6 +106,7 @@ impl DocAtlas {
})
}
// TODO: delete one docatlas is optional
pub fn is_empty(&self) -> bool {
self.size.width <= 0 || self.size.height <= 0
}

View File

@ -1,6 +1,7 @@
use skia_safe::{self as skia, Color4f};
use super::{RenderState, ShapesPoolRef, SurfaceId};
use crate::get_resources;
use crate::globals::get_ui_state;
use crate::render::grid_layout;
use crate::shapes::{Layout, Type};
@ -67,7 +68,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
rulers::render(
canvas,
viewbox,
&render_state.fonts,
&get_resources().fonts,
get_ui_state().ruler_state(),
);

View File

@ -9,19 +9,9 @@ use crate::uuid::Uuid;
use super::shape_renderer::ShapeRenderer;
use super::text;
use super::RenderState;
use super::RenderResources;
use super::{get_dest_rect, get_source_rect};
// ---------------------------------------------------------------------------
// VectorTarget — vector export backend selector
// ---------------------------------------------------------------------------
/// Vector export backend selector (PDF today; SVG could be added as a variant).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VectorTarget {
Pdf,
}
// ---------------------------------------------------------------------------
// VectorRenderer — implements ShapeRenderer for canvas-based vector export
// ---------------------------------------------------------------------------
@ -29,23 +19,16 @@ pub(super) enum VectorTarget {
/// Canvas-based vector render backend (CPU Skia canvas, no GPU surfaces).
pub(super) struct VectorRenderer<'a> {
canvas: &'a Canvas,
shared: &'a mut RenderState,
shared: &'a mut RenderResources,
scale: f32,
_target: VectorTarget,
}
impl<'a> VectorRenderer<'a> {
pub fn new(
canvas: &'a Canvas,
shared: &'a mut RenderState,
scale: f32,
target: VectorTarget,
) -> Self {
pub fn new(canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32) -> Self {
Self {
canvas,
shared,
scale,
_target: target,
}
}
}
@ -364,15 +347,88 @@ impl ShapeRenderer for VectorRenderer<'_> {
// Tree traversal
// ---------------------------------------------------------------------------
/// Depth-first render of the shape tree rooted at `id`.
/// Options threaded through the whole traversal. Carries the export root/page
/// (needed to re-render the backdrop for background blur) and the background
/// blur strategy for the active canvas.
struct TreeOpts<'a> {
/// The exported root shape id — the backdrop for any shape is re-rendered
/// from here.
root: &'a Uuid,
/// Root's export bounds (shape space); sizes/positions the offscreen
/// backdrop surface to match the page.
page: skia::Rect,
/// `true` on a PDF canvas (which ignores Skia backdrop filters): background
/// blur is produced by rendering the backdrop onto an offscreen raster
/// surface and embedding the blurred result as an image. `false` on a raster
/// canvas, where a plain Skia backdrop filter works directly.
embed_bg_blur: bool,
/// When rendering a backdrop, the shape whose own subtree must be omitted
/// (so the blur samples only what is *behind* it).
skip: Option<&'a Uuid>,
}
/// Depth-first render of the shape tree rooted at `id`. Used for raster export
/// (PNG/webp), where background blur is applied with a direct Skia backdrop
/// filter (raster surfaces support it). `page` must be the same bounds the
/// caller used to size/translate its surface — the embedded background-blur
/// backdrop is drawn device-aligned against it.
pub(super) fn render_tree(
shared: &mut RenderState,
shared: &mut RenderResources,
canvas: &Canvas,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
target: VectorTarget,
page: skia::Rect,
) -> Result<()> {
render_tree_dispatch(shared, canvas, id, tree, scale, page, false)
}
/// Like [`render_tree`], but for a PDF canvas: background blur is embedded as a
/// rasterised image (the PDF backend ignores backdrop filters).
pub(super) fn render_tree_pdf(
shared: &mut RenderResources,
canvas: &Canvas,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
page: skia::Rect,
) -> Result<()> {
render_tree_dispatch(shared, canvas, id, tree, scale, page, true)
}
#[allow(clippy::too_many_arguments)]
fn render_tree_dispatch(
shared: &mut RenderResources,
canvas: &Canvas,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
page: skia::Rect,
embed_bg_blur: bool,
) -> Result<()> {
let opts = TreeOpts {
root: id,
page,
embed_bg_blur,
skip: None,
};
render_tree_inner(shared, canvas, id, tree, scale, &opts)
}
fn render_tree_inner(
shared: &mut RenderResources,
canvas: &Canvas,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
opts: &TreeOpts,
) -> Result<()> {
// When re-rendering a backdrop, omit the blur shape's own subtree so it only
// samples what is behind it.
if opts.skip == Some(id) {
return Ok(());
}
let Some(element) = tree.get(id) else {
return Ok(());
};
@ -381,12 +437,27 @@ pub(super) fn render_tree(
return Ok(());
}
// Background blur samples already-drawn content behind the shape, so it must
// run before the shape (and its subtree) paints. Text/SVGRaw are excluded,
// matching the GPU path.
if !matches!(element.shape_type, Type::Text(_) | Type::SVGRaw(_)) {
if let Some(blur) = element.visible_background_blur() {
if blur.value > 0.0 {
if opts.embed_bg_blur {
render_background_blur_image(shared, canvas, element, tree, scale, opts)?;
} else {
render_background_blur_backdrop(canvas, element, blur.value * scale);
}
}
}
}
match &element.shape_type {
Type::Group(group) => {
render_group(shared, canvas, element, group.masked, tree, scale, target)?;
render_group(shared, canvas, element, group.masked, tree, scale, opts)?;
}
Type::Frame(_) => {
render_frame(shared, canvas, element, tree, scale, target)?;
render_frame(shared, canvas, element, tree, scale, opts)?;
}
// Leaf types listed explicitly (no `_`) so a new Type must be handled.
Type::Rect(_)
@ -395,25 +466,135 @@ pub(super) fn render_tree(
| Type::Bool(_)
| Type::Text(_)
| Type::SVGRaw(_) => {
render_leaf(shared, canvas, element, scale, target)?;
render_leaf(shared, canvas, element, scale)?;
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Background blur
// ---------------------------------------------------------------------------
/// Background blur on a canvas that supports Skia backdrop filters (raster).
/// Blurs the current device contents within the shape silhouette and stamps the
/// result back with `Src`. `sigma_radius` is the blur radius already multiplied
/// by the export scale.
fn render_background_blur_backdrop(canvas: &Canvas, shape: &Shape, sigma_radius: f32) {
let sigma = radius_to_sigma(sigma_radius);
let Some(blur_filter) =
skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None)
else {
return;
};
let matrix = shape.centered_transform();
canvas.save();
canvas.concat(&matrix);
clip_to_shape(canvas, shape, true);
// Apply the blur in device space (sigma already includes the export scale);
// the clip, set with the full transform, survives reset_matrix.
canvas.reset_matrix();
let mut paint = Paint::default();
paint.set_blend_mode(skia::BlendMode::Src);
let layer_rec = skia::canvas::SaveLayerRec::default()
.backdrop(&blur_filter)
.backdrop_tile_mode(skia::TileMode::Clamp)
.paint(&paint);
canvas.save_layer(&layer_rec);
canvas.restore(); // composite the blurred-backdrop layer
canvas.restore(); // pop the clip + transform
}
/// Background blur for a PDF canvas (backdrop filters unsupported): render the
/// backdrop — the whole page minus this shape's own subtree — onto an offscreen
/// raster surface, blur it, and embed the result as an image clipped to the
/// shape. The rest of the page stays vector.
///
/// LIMITATION: the backdrop omits only this shape's subtree, not shapes painted
/// *after* it. For content stacked on top of the blur shape the foreground would
/// bleed into the blur; correct for the common case (nothing above the panel).
fn render_background_blur_image(
shared: &mut RenderResources,
canvas: &Canvas,
shape: &Shape,
tree: ShapesPoolRef,
scale: f32,
opts: &TreeOpts,
) -> Result<()> {
let bounds = opts.page;
let width = (bounds.width() * scale).ceil() as i32;
let height = (bounds.height() * scale).ceil() as i32;
if width <= 0 || height <= 0 {
return Ok(());
}
// Render the backdrop into an offscreen raster surface, in the same
// coordinate space as the PDF page (see `render/pdf.rs`).
let Some(mut surface) = skia::surfaces::raster_n32_premul((width, height)) else {
return Ok(());
};
{
let oc = surface.canvas();
oc.clear(skia::Color::TRANSPARENT);
oc.scale((scale, scale));
oc.translate((-bounds.left(), -bounds.top()));
let sub = TreeOpts {
root: opts.root,
page: opts.page,
embed_bg_blur: false,
skip: Some(&shape.id),
};
render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?;
}
let image = surface.image_snapshot();
// Bake the blur into a raster bitmap. The PDF backend ignores image filters
// at draw time (same limitation as backdrop filters), so we must blur on a
// raster surface — where filters work — and embed the pre-blurred result.
let sigma = radius_to_sigma(shape.visible_background_blur().map_or(0.0, |b| b.value) * scale);
let blurred = {
let Some(mut blur_surface) = skia::surfaces::raster_n32_premul((width, height)) else {
return Ok(());
};
let bc = blur_surface.canvas();
bc.clear(skia::Color::TRANSPARENT);
let mut paint = Paint::default();
if let Some(filter) =
skia::image_filters::blur((sigma, sigma), skia::TileMode::Clamp, None, None)
{
paint.set_image_filter(filter);
}
bc.draw_image(&image, (0.0, 0.0), Some(&paint));
blur_surface.image_snapshot()
};
let matrix = shape.centered_transform();
canvas.save();
canvas.concat(&matrix);
clip_to_shape(canvas, shape, true);
// Draw the pre-blurred full-page bitmap in device space (1 image px per
// device unit) so it aligns with the page regardless of the shape transform.
canvas.reset_matrix();
canvas.draw_image(&blurred, (0.0, 0.0), None);
canvas.restore();
Ok(())
}
// ---------------------------------------------------------------------------
// Groups
// ---------------------------------------------------------------------------
fn render_group(
shared: &mut RenderState,
shared: &mut RenderResources,
canvas: &Canvas,
element: &Shape,
masked: bool,
tree: ShapesPoolRef,
scale: f32,
target: VectorTarget,
opts: &TreeOpts,
) -> Result<()> {
// A group has no geometry of its own and does NOT propagate a transform to
// its children: child shapes are stored in absolute coordinates and each
@ -422,7 +603,7 @@ fn render_group(
canvas.save();
// Group drop shadow: subtree silhouette, below the opacity/clip layer.
render_container_drop_shadows(shared, canvas, element, tree, scale, target, false)?;
render_container_drop_shadows(shared, canvas, element, tree, scale, false, opts)?;
// Layer for opacity / blend mode (and group-level layer blur)
let needs_layer = element.needs_layer();
@ -455,21 +636,21 @@ fn render_group(
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
for child_id in &children {
render_tree(shared, canvas, child_id, tree, scale, target)?;
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
}
if let Some(mask_id) = element.mask_id() {
let mut mask_paint = Paint::default();
mask_paint.set_blend_mode(skia::BlendMode::DstIn);
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&mask_paint));
render_tree(shared, canvas, mask_id, tree, scale, target)?;
render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?;
canvas.restore(); // mask layer
}
canvas.restore(); // composition layer
} else {
for child_id in &children {
render_tree(shared, canvas, child_id, tree, scale, target)?;
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
}
}
@ -485,12 +666,12 @@ fn render_group(
// ---------------------------------------------------------------------------
fn render_frame(
shared: &mut RenderState,
shared: &mut RenderResources,
canvas: &Canvas,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
target: VectorTarget,
opts: &TreeOpts,
) -> Result<()> {
// A frame's own geometry (background, clip, strokes) is placed by its
// `centered_transform`, but — like groups — it does NOT propagate that
@ -503,7 +684,7 @@ fn render_frame(
// Frame drop shadow: background + subtree silhouette, below the clip layer
// so it extends outside the frame bounds.
render_container_drop_shadows(shared, canvas, element, tree, scale, target, true)?;
render_container_drop_shadows(shared, canvas, element, tree, scale, true, opts)?;
let needs_layer = element.needs_layer();
@ -542,7 +723,7 @@ fn render_frame(
if !element.fills.is_empty() {
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
renderer.draw_fills(element, &element.fills)?;
renderer.draw_fill_inner_shadows(element)?;
canvas.restore();
@ -551,7 +732,7 @@ fn render_frame(
// Children (absolute coords, no frame transform).
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
for child_id in &children {
render_tree(shared, canvas, child_id, tree, scale, target)?;
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
}
// Strokes over children (clipped frames), in the frame's space.
@ -559,7 +740,7 @@ fn render_frame(
if !visible_strokes.is_empty() {
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
renderer.draw_strokes(element, &visible_strokes)?;
canvas.restore();
}
@ -575,13 +756,13 @@ fn render_frame(
/// layer (its alpha becomes the shadow). `draw_fills` includes the frame
/// background in the silhouette.
fn render_container_drop_shadows(
shared: &mut RenderState,
shared: &mut RenderResources,
canvas: &Canvas,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
target: VectorTarget,
draw_fills: bool,
opts: &TreeOpts,
) -> Result<()> {
for shadow in element.drop_shadows_visible() {
let Some(filter) = shadow.get_drop_shadow_filter() else {
@ -592,13 +773,13 @@ fn render_container_drop_shadows(
canvas.save_layer(&skia::canvas::SaveLayerRec::default().paint(&paint));
if draw_fills && !element.fills.is_empty() {
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
renderer.draw_fills(element, &element.fills)?;
}
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
for child_id in &children {
render_tree(shared, canvas, child_id, tree, scale, target)?;
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
}
canvas.restore();
@ -611,11 +792,10 @@ fn render_container_drop_shadows(
// ---------------------------------------------------------------------------
fn render_leaf(
shared: &mut RenderState,
shared: &mut RenderResources,
canvas: &Canvas,
element: &Shape,
scale: f32,
target: VectorTarget,
) -> Result<()> {
let needs_layer = element.needs_layer();
@ -633,7 +813,7 @@ fn render_leaf(
canvas.save_layer(&layer_rec);
}
let mut renderer = VectorRenderer::new(canvas, shared, scale, target);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
// Layer blur (non-text shapes)
let blur_layer = if !matches!(element.shape_type, Type::Text(_)) {
@ -695,7 +875,7 @@ fn render_leaf_content<R: ShapeRenderer + ?Sized>(renderer: &mut R, shape: &Shap
// ---------------------------------------------------------------------------
fn draw_image_fill(
shared: &mut RenderState,
shared: &mut RenderResources,
canvas: &Canvas,
shape: &Shape,
image_fill: &crate::shapes::ImageFill,
@ -737,7 +917,7 @@ fn draw_image_fill(
fn draw_single_stroke(
canvas: &Canvas,
shared: &mut RenderState,
shared: &mut RenderResources,
scale: f32,
shape: &Shape,
stroke: &Stroke,
@ -848,7 +1028,7 @@ fn draw_stroke_kind_aware(canvas: &Canvas, shape: &Shape, stroke: &Stroke, paint
/// CPU image over it with `SrcIn` so only the stroke area shows the image.
fn draw_image_stroke(
canvas: &Canvas,
shared: &mut RenderState,
shared: &mut RenderResources,
scale: f32,
shape: &Shape,
stroke: &Stroke,

View File

@ -1285,6 +1285,15 @@ impl Shape {
})
}
/// Font families used by this shape (the text spans' families), or an
/// empty vec for non-text shapes.
pub fn font_families(&self) -> Vec<FontFamily> {
match &self.shape_type {
Type::Text(content) => content.font_families(),
_ => Vec::new(),
}
}
#[allow(dead_code)]
pub fn mask_filter(&self, scale: f32) -> Option<skia::MaskFilter> {
self.blur

View File

@ -391,6 +391,20 @@ impl TextContent {
self.bounds = Rect::from_xywh(x, y, w, h);
}
/// Distinct font families referenced by this content's spans, in first-seen
/// order.
pub fn font_families(&self) -> Vec<FontFamily> {
let mut seen: Vec<FontFamily> = Vec::new();
for paragraph in &self.paragraphs {
for span in paragraph.children() {
if !seen.contains(&span.font_family) {
seen.push(span.font_family);
}
}
}
seen
}
pub fn add_paragraph(&mut self, paragraph: Paragraph) {
self.paragraphs.push(paragraph);
self.content_version = self.content_version.wrapping_add(1);

View File

@ -1,4 +1,4 @@
use crate::get_render_state;
use crate::get_resources;
use crate::shapes::text::TextContent;
use skia_safe::{
self as skia, textlayout::Paragraph as SkiaParagraph, FontMetrics, Point, Rect, TextBlob,
@ -174,7 +174,7 @@ impl TextPaths {
) -> Option<(skia::Path, skia::Rect)> {
let utf16_text = span_text.encode_utf16().collect::<Vec<u16>>();
let text = unsafe { skia_safe::as_utf16_unchecked(&utf16_text) };
let emoji_font = get_render_state().fonts().get_emoji_font(font.size());
let emoji_font = get_resources().fonts.get_emoji_font(font.size());
let use_font = emoji_font.as_ref().unwrap_or(font);
if let Some(mut text_blob) = TextBlob::from_text(text, use_font) {

View File

@ -11,9 +11,9 @@ pub use ui::UIState;
use crate::error::{Error, Result};
use crate::render::FrameType;
use crate::shapes::{grid_layout::grid_cell_data, Shape};
use crate::shapes::{grid_layout::grid_cell_data, FontFamily, Shape};
use crate::uuid::Uuid;
use crate::{get_render_state, tiles};
use crate::{get_render_state, get_resources, has_render_state, tiles};
/// This struct holds the state of the Rust application between JS calls.
///
@ -96,7 +96,33 @@ impl State {
}
pub fn render_shape_pdf(&mut self, id: &Uuid, scale: f32) -> Result<Vec<u8>> {
crate::render::pdf::render_to_pdf(get_render_state(), id, &self.shapes, scale)
crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale)
}
/// GPU-free counterpart of [`State::render_shape_pixels`]: PNG on a CPU
/// raster surface, no GPU/WebGL.
pub fn render_shape_raster(&mut self, id: &Uuid, scale: f32) -> Result<(Vec<u8>, i32, i32)> {
crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale)
}
/// Distinct font families used by the (visible) subtree rooted at `id`, in
/// first-seen order — the on-demand set the headless exporter provisions.
pub fn fonts_used_by_shape(&self, id: &Uuid) -> Vec<FontFamily> {
let Some(root) = self.shapes.get(id) else {
return Vec::new();
};
let mut result: Vec<FontFamily> = Vec::new();
for child_id in root.all_children_iter(&self.shapes, false, true) {
if let Some(shape) = self.shapes.get(&child_id) {
for family in shape.font_families() {
if !result.contains(&family) {
result.push(family);
}
}
}
}
result
}
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<FrameType> {
@ -150,8 +176,6 @@ impl State {
}
pub fn delete_shape_children(&mut self, parent_id: Uuid, id: Uuid) {
let render_state = get_render_state();
// We don't really do a self.shapes.remove so that redo/undo keep working
let Some(shape) = self.shapes.get(&id) else {
return;
@ -159,23 +183,28 @@ impl State {
// Only remove the children when is being deleted from the owner
if shape.parent_id.is_none() || shape.parent_id == Some(parent_id) {
// IMPORTANT:
// Do NOT use `get_tiles_for_shape` here. That method intersects the shape
// tiles with the current interest area, which means we'd only invalidate
// the subset currently near the viewport. When the user later pans/zooms
// to reveal previously cached tiles, stale pixels could reappear.
//
// Instead, remove the shape from *all* tiles where it was indexed, and
// drop cached tiles for those entries.
let indexed_tiles: Vec<tiles::Tile> = render_state
.tiles
.get_tiles_of(shape.id)
.map(|t| t.iter().copied().collect())
.unwrap_or_default();
// Tile invalidation only applies to the on-screen render state; the
// headless export path has none, so skip it there.
if has_render_state() {
let render_state = get_render_state();
// IMPORTANT:
// Do NOT use `get_tiles_for_shape` here. That method intersects the shape
// tiles with the current interest area, which means we'd only invalidate
// the subset currently near the viewport. When the user later pans/zooms
// to reveal previously cached tiles, stale pixels could reappear.
//
// Instead, remove the shape from *all* tiles where it was indexed, and
// drop cached tiles for those entries.
let indexed_tiles: Vec<tiles::Tile> = render_state
.tiles
.get_tiles_of(shape.id)
.map(|t| t.iter().copied().collect())
.unwrap_or_default();
for tile in indexed_tiles {
render_state.remove_cached_tile(tile);
render_state.tiles.remove_shape_at(tile, shape.id);
for tile in indexed_tiles {
render_state.remove_cached_tile(tile);
render_state.tiles.remove_shape_at(tile, shape.id);
}
}
if let Some(shape_to_delete) = self.shapes.get(&id) {
@ -184,8 +213,8 @@ impl State {
if let Some(shape_to_delete) = self.shapes.get_mut(&shape_id) {
shape_to_delete.set_deleted(true);
}
if render_state.show_grid == Some(shape_id) {
render_state.show_grid = None;
if has_render_state() && get_render_state().show_grid == Some(shape_id) {
get_render_state().show_grid = None;
}
}
}
@ -215,7 +244,8 @@ impl State {
/// and groups properly encompass their children.
pub fn set_parent_for_current_shape(&mut self, id: Uuid) {
// Reparent preview during drag is handled by structure modifiers only.
if get_render_state().options.is_interactive_transform() {
// Headless export has no render state and never runs interactive drags.
if has_render_state() && get_render_state().options.is_interactive_transform() {
return;
}
@ -265,7 +295,7 @@ impl State {
}
pub fn font_collection(&self) -> &FontCollection {
get_render_state().fonts().font_collection()
get_resources().fonts.font_collection()
}
pub fn get_grid_coords(&self, pos_x: f32, pos_y: f32) -> Option<(i32, i32)> {
@ -298,18 +328,20 @@ impl State {
}
pub fn touch_current(&mut self) {
let render_state = get_render_state();
if !self.loading {
if let Some(current_id) = self.current_id {
render_state.mark_touched(current_id);
}
// `mark_touched` only drives incremental on-screen tile invalidation;
// the headless export path has no render state, so skip it there.
if self.loading || !has_render_state() {
return;
}
if let Some(current_id) = self.current_id {
get_render_state().mark_touched(current_id);
}
}
pub fn touch_shape(&mut self, id: Uuid) {
let render_state = get_render_state();
if !self.loading {
render_state.mark_touched(id);
if self.loading || !has_render_state() {
return;
}
get_render_state().mark_touched(id);
}
}

View File

@ -1,4 +1,4 @@
use crate::get_render_state;
use crate::get_resources;
use crate::skia::textlayout::FontCollection;
use crate::skia::Image;
use crate::uuid::Uuid;
@ -25,12 +25,12 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid {
}
pub fn get_image(image_id: &Uuid) -> Option<&Image> {
get_render_state().images.get(image_id)
get_resources().images.get(image_id)
}
// FIXME: move to a different place ?
pub fn get_fallback_fonts() -> &'static HashSet<String> {
get_render_state().fonts().get_fallback()
get_resources().fonts.get_fallback()
}
pub fn get_font_collection() -> &'static FontCollection {

View File

@ -1,5 +1,5 @@
use crate::error::{Error, Result};
use crate::get_render_state;
use crate::get_resources;
use crate::mem;
use crate::shapes::Fill;
use crate::state::State;
@ -104,7 +104,10 @@ pub extern "C" fn store_image() -> Result<()> {
let image_bytes = &bytes[IMAGE_HEADER_SIZE..];
with_state!(state, {
if let Err(msg) = get_render_state().add_image(ids.image_id, is_thumbnail, image_bytes) {
if let Err(msg) = get_resources()
.images
.add(ids.image_id, is_thumbnail, image_bytes)
{
eprintln!("{}", msg);
}
touch_shapes_with_image(state, ids.image_id);
@ -174,7 +177,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> {
);
with_state!(state, {
if let Err(msg) = get_render_state().add_image_from_gl_texture(
if let Err(msg) = get_resources().images.add_image_from_gl_texture(
ids.image_id,
is_thumbnail,
texture_id,

View File

@ -1,9 +1,11 @@
use macros::{wasm_error, ToJs};
use crate::get_render_state;
use crate::get_resources;
use crate::mem;
use crate::render::FontStore;
use crate::shapes::{FontFamily, FontStyle};
use crate::utils::uuid_from_u32_quartet;
use crate::with_state;
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]
#[repr(u8)]
@ -45,14 +47,44 @@ pub extern "C" fn store_font(
let font_style = RawFontStyle::from(style);
let family = FontFamily::new(id, weight, font_style.into());
let _ = get_render_state()
.fonts_mut()
let _ = get_resources()
.fonts
.add(family, &font_bytes, is_emoji, is_fallback);
mem::free_bytes()?;
Ok(())
}
/// Resets the font store to its default state, dropping every font uploaded via
/// `store_font`. A headless host that reuses a single WASM instance across
/// requests must call this per render so fonts don't accumulate unbounded.
#[no_mangle]
#[wasm_error]
pub extern "C" fn clear_fonts() -> Result<()> {
get_resources().fonts = FontStore::try_new()?;
Ok(())
}
/// Distinct font families used by the subtree, for on-demand provisioning by a
/// headless host. Buffer (LE): `[count u32]` then `count` ×
/// `[uuid 16B][weight u32][style u32]` (0=normal, 1=italic); uuid bytes match
/// the quartet `store_font` consumes.
#[no_mangle]
pub extern "C" fn get_fonts_for_shape(a: u32, b: u32, c: u32, d: u32) -> *mut u8 {
let id = uuid_from_u32_quartet(a, b, c, d);
let families = with_state!(state, { state.fonts_used_by_shape(&id) });
let mut buf = Vec::with_capacity(4 + families.len() * 24);
buf.extend_from_slice(&(families.len() as u32).to_le_bytes());
for family in families {
let id_bytes: [u8; 16] = family.id().into();
buf.extend_from_slice(&id_bytes);
buf.extend_from_slice(&family.weight().to_le_bytes());
buf.extend_from_slice(&(family.style() as u32).to_le_bytes());
}
mem::write_bytes(buf)
}
#[no_mangle]
pub extern "C" fn is_font_uploaded(
a: u32,
@ -66,7 +98,5 @@ pub extern "C" fn is_font_uploaded(
let id = uuid_from_u32_quartet(a, b, c, d);
let font_style = RawFontStyle::from(style);
let family = FontFamily::new(id, weight, font_style.into());
let res = get_render_state().fonts().has_family(&family, is_emoji);
res
get_resources().fonts.has_family(&family, is_emoji)
}