mirror of
https://github.com/penpot/penpot.git
synced 2026-08-07 13:29:07 +00:00
✨ Support png, jpeg and webp export over the headless exporter
This commit is contained in:
parent
fecedcea51
commit
bcc8399bcc
@ -78,7 +78,9 @@ export PENPOT_INTERNAL_URI=http://localhost:3450
|
|||||||
|
|
||||||
# Headless WASM export: render `:is-wasm` exports via the in-process Skia/WASM
|
# 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,
|
# pipeline (no browser). Reads the render-wasm artifact from PENPOT_WASM_DIR,
|
||||||
# defaulting to ../frontend/resources/public/js (render-wasm.js/.wasm).
|
# defaulting to ../frontend/resources/public/js (render-wasm.js/.wasm) — which
|
||||||
|
# is where render-wasm/build leaves it in devenv. The docker image ships the
|
||||||
|
# artifact inside the exporter bundle and points PENPOT_WASM_DIR at it.
|
||||||
# export PENPOT_WASM_HEADLESS=true
|
# export PENPOT_WASM_HEADLESS=true
|
||||||
# export PENPOT_WASM_DIR=../frontend/resources/public/js
|
# export PENPOT_WASM_DIR=../frontend/resources/public/js
|
||||||
# export PENPOT_WASM_IMAGE_CACHE_MB=256
|
# export PENPOT_WASM_IMAGE_CACHE_MB=256
|
||||||
|
|||||||
@ -6,7 +6,8 @@ ENV LANG=en_US.UTF-8 \
|
|||||||
NODE_VERSION=v24.18.0 \
|
NODE_VERSION=v24.18.0 \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive \
|
||||||
PATH=/opt/node/bin:/opt/imagick/bin:$PATH \
|
PATH=/opt/node/bin:/opt/imagick/bin:$PATH \
|
||||||
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers
|
PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers \
|
||||||
|
PENPOT_WASM_DIR=/opt/penpot/exporter/js
|
||||||
|
|
||||||
RUN set -ex; \
|
RUN set -ex; \
|
||||||
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
|
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
;; api.shapes,...}), so the CLJS↔WASM binary protocol cannot drift from the
|
;; api.shapes,...}), so the CLJS↔WASM binary protocol cannot drift from the
|
||||||
;; editor's. shadow-cljs compiles only the required dependency graph, so the
|
;; editor's. shadow-cljs compiles only the required dependency graph, so the
|
||||||
;; browser-coupled namespaces (api, api.webgl, api.fonts) are never pulled in.
|
;; browser-coupled namespaces (api, api.webgl, api.fonts) are never pulled in.
|
||||||
|
;; NOTE: `app.config` exists in both trees; the exporter's `:paths` come
|
||||||
|
;; first, so its own wins.
|
||||||
penpot/frontend {:local/root "../frontend"}
|
penpot/frontend {:local/root "../frontend"}
|
||||||
org.clojure/clojure {:mvn/version "1.12.2"}
|
org.clojure/clojure {:mvn/version "1.12.2"}
|
||||||
binaryage/devtools {:mvn/version "1.0.7"}
|
binaryage/devtools {:mvn/version "1.0.7"}
|
||||||
|
|||||||
@ -18,6 +18,17 @@ cp pnpm-workspace.yaml target/;
|
|||||||
cp package.json target/;
|
cp package.json target/;
|
||||||
touch target/pnpm-workspace.yaml;
|
touch target/pnpm-workspace.yaml;
|
||||||
|
|
||||||
|
# The headless renderer (PENPOT_WASM_HEADLESS) needs the render-wasm artifact.
|
||||||
|
WASM_SRC="../frontend/resources/public/js";
|
||||||
|
if [ ! -f "$WASM_SRC/render-wasm.wasm" ]; then
|
||||||
|
echo "ERROR: $WASM_SRC/render-wasm.wasm is missing." >&2;
|
||||||
|
echo "Build it first with: (cd render-wasm && ./build)" >&2;
|
||||||
|
exit 1;
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p target/js;
|
||||||
|
cp "$WASM_SRC/render-wasm.js" "$WASM_SRC/render-wasm.wasm" target/js/;
|
||||||
|
|
||||||
cat <<EOF | tee target/setup
|
cat <<EOF | tee target/setup
|
||||||
#/usr/bin/env bash
|
#/usr/bin/env bash
|
||||||
set -e;
|
set -e;
|
||||||
|
|||||||
@ -45,12 +45,18 @@
|
|||||||
;; Opt-in headless path: when an export is flagged `:is-wasm` AND the
|
;; 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
|
;; `:wasm-headless` config is enabled, render with the in-process Skia/WASM
|
||||||
;; pipeline (no browser). Off by default, so existing behavior is unchanged.
|
;; pipeline (no browser). Off by default, so existing behavior is unchanged.
|
||||||
(let [headless? (and is-wasm (cf/get :wasm-headless))]
|
;;
|
||||||
(l/info :hint "render"
|
;; `:svg` stays on the browser path even when headless is enabled: it needs
|
||||||
:type type
|
;; real vector markup from `app.renderer.svg`, which a raster/PDF backend
|
||||||
:is-wasm (boolean is-wasm)
|
;; cannot produce. Every other type is encoded natively by Skia.
|
||||||
:wasm-headless (boolean (cf/get :wasm-headless))
|
(let [headless? (and is-wasm
|
||||||
:backend (if headless? "wasm" "browser"))
|
(cf/get :wasm-headless)
|
||||||
|
(not= :svg type))]
|
||||||
|
(when is-wasm
|
||||||
|
(l/info :hint "render"
|
||||||
|
:type type
|
||||||
|
:wasm-headless (boolean (cf/get :wasm-headless))
|
||||||
|
:backend (if headless? "wasm" "browser")))
|
||||||
(if headless?
|
(if headless?
|
||||||
(rw/render params on-object)
|
(rw/render params on-object)
|
||||||
(case type
|
(case type
|
||||||
|
|||||||
@ -18,9 +18,12 @@
|
|||||||
enumerated by the host-agnostic app.render-wasm.{resources,fallback-fonts})
|
enumerated by the host-agnostic app.render-wasm.{resources,fallback-fonts})
|
||||||
-> relayout text (real font metrics) -> render each object -> tempfile
|
-> relayout text (real font metrics) -> render each object -> tempfile
|
||||||
|
|
||||||
|
Handles png/jpeg/webp (Skia encodes all three natively) and pdf. `:svg` is
|
||||||
|
routed to the browser backend by `app.renderer` — it needs vector markup, not
|
||||||
|
a raster.
|
||||||
|
|
||||||
NOTE (current slice): single shared WASM design state, so requests are
|
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
|
serialized one at a time (no pooling yet)."
|
||||||
still need the PNG->format conversion that `bitmap` does."
|
|
||||||
(:require
|
(:require
|
||||||
["node:fs" :as fs]
|
["node:fs" :as fs]
|
||||||
["undici" :as http]
|
["undici" :as http]
|
||||||
@ -76,31 +79,54 @@
|
|||||||
(reset! queue (p/handle result (fn [_ _] nil)))
|
(reset! queue (p/handle result (fn [_ _] nil)))
|
||||||
result))
|
result))
|
||||||
|
|
||||||
|
;; --- backend endpoints
|
||||||
|
;;
|
||||||
|
;; Every fetch below targets the *internal* endpoint (`internal-uri`, falling
|
||||||
|
;; back to `public-uri`), same as the Playwright backends and
|
||||||
|
;; `handlers.resources`: in a real deployment the exporter reaches the backend
|
||||||
|
;; over the container network, not through the public ingress.
|
||||||
|
|
||||||
|
(defn- internal-uri
|
||||||
|
"Absolute URI for `path` on the internal (backend) endpoint."
|
||||||
|
[path]
|
||||||
|
(-> (cf/get-internal-uri)
|
||||||
|
(u/ensure-path-slash)
|
||||||
|
(u/join path)
|
||||||
|
(str)))
|
||||||
|
|
||||||
|
(defn- rpc-headers
|
||||||
|
"Auth headers for backend RPC calls (management key + bearer)."
|
||||||
|
[token]
|
||||||
|
#js {"Content-Type" "application/transit+json"
|
||||||
|
"X-Shared-Key" (str "exporter " cf/management-key)
|
||||||
|
"Authorization" (str "Bearer " token)})
|
||||||
|
|
||||||
|
(defn- asset-headers
|
||||||
|
"Auth headers for `/assets/*` downloads. Cookie, not Bearer: those endpoints
|
||||||
|
redirect to a presigned S3/minio URL, and a Bearer Authorization header makes
|
||||||
|
S3 400 (\"multiple authentication types\")."
|
||||||
|
[token]
|
||||||
|
#js {"X-Shared-Key" (str "exporter " cf/management-key)
|
||||||
|
"Cookie" (str "auth-token=" token)})
|
||||||
|
|
||||||
;; --- shape bundle fetch (backend RPC)
|
;; --- shape bundle fetch (backend RPC)
|
||||||
|
|
||||||
(defn- fetch-objects
|
(defn- fetch-objects
|
||||||
"Fetches the page's `objects` map from the backend via the `get-page` RPC,
|
"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)."
|
using the same auth the exporter uses elsewhere (management key + bearer)."
|
||||||
[{:keys [file-id page-id share-id token]}]
|
[{:keys [file-id page-id share-id token]}]
|
||||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
(let [headers (rpc-headers token)
|
||||||
headers #js {"Content-Type" "application/transit+json"
|
|
||||||
"X-Shared-Key" (str "exporter " cf/management-key)
|
|
||||||
"Authorization" (str "Bearer " token)}
|
|
||||||
;; share-id is an OPTIONAL uuid on the backend; it must be omitted when
|
;; share-id is an OPTIONAL uuid on the backend; it must be omitted when
|
||||||
;; absent, not sent as nil (nil fails the uuid schema).
|
;; absent, not sent as nil (nil fails the uuid schema).
|
||||||
body (t/encode-str (cond-> {:file-id file-id
|
body (t/encode-str (cond-> {:file-id file-id
|
||||||
:page-id page-id}
|
:page-id page-id}
|
||||||
share-id (assoc :share-id share-id)))
|
share-id (assoc :share-id share-id)))
|
||||||
uri (-> (cf/get :public-uri)
|
uri (internal-uri "api/rpc/command/get-page")]
|
||||||
(u/ensure-path-slash)
|
(l/dbg :hint "wasm render: get-page"
|
||||||
(u/join "api/rpc/command/get-page")
|
|
||||||
(str))]
|
|
||||||
(l/info :hint "wasm render: get-page"
|
|
||||||
:uri uri
|
:uri uri
|
||||||
:file-id (str file-id)
|
:file-id (str file-id)
|
||||||
:page-id (str page-id)
|
:page-id (str page-id))
|
||||||
:token-len (count (str token)))
|
(->> (http/fetch uri #js {:method "POST" :headers headers :body body})
|
||||||
(->> (http/fetch uri #js {:method "POST" :headers headers :body body :dispatcher agent})
|
|
||||||
(p/mcat (fn [^js resp]
|
(p/mcat (fn [^js resp]
|
||||||
(if (= 200 (.-status resp))
|
(if (= 200 (.-status resp))
|
||||||
(.text resp)
|
(.text resp)
|
||||||
@ -130,17 +156,11 @@
|
|||||||
Returns a promise of the variant vector (or nil on failure — fonts then just
|
Returns a promise of the variant vector (or nil on failure — fonts then just
|
||||||
fall back, they don't fail the export)."
|
fall back, they don't fail the export)."
|
||||||
[{:keys [file-id share-id token]}]
|
[{:keys [file-id share-id token]}]
|
||||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
(let [headers (rpc-headers token)
|
||||||
headers #js {"Content-Type" "application/transit+json"
|
|
||||||
"X-Shared-Key" (str "exporter " cf/management-key)
|
|
||||||
"Authorization" (str "Bearer " token)}
|
|
||||||
body (t/encode-str (cond-> {:file-id file-id}
|
body (t/encode-str (cond-> {:file-id file-id}
|
||||||
share-id (assoc :share-id share-id)))
|
share-id (assoc :share-id share-id)))
|
||||||
uri (-> (cf/get :public-uri)
|
uri (internal-uri "api/rpc/command/get-font-variants")]
|
||||||
(u/ensure-path-slash)
|
(->> (http/fetch uri #js {:method "POST" :headers headers :body body})
|
||||||
(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]
|
(p/mcat (fn [^js resp]
|
||||||
(if (= 200 (.-status resp))
|
(if (= 200 (.-status resp))
|
||||||
(.text resp)
|
(.text resp)
|
||||||
@ -154,17 +174,9 @@
|
|||||||
"Downloads a stored asset (font TTF) by id, returning a promise of an
|
"Downloads a stored asset (font TTF) by id, returning a promise of an
|
||||||
ArrayBuffer (or nil)."
|
ArrayBuffer (or nil)."
|
||||||
[asset-id {:keys [token]}]
|
[asset-id {:keys [token]}]
|
||||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
(let [headers (asset-headers token)
|
||||||
;; Cookie, not Bearer: /assets/* redirects to a presigned S3/minio URL,
|
uri (internal-uri (str "assets/by-id/" asset-id))]
|
||||||
;; and a Bearer Authorization header makes S3 400 ("multiple
|
(->> (http/fetch uri #js {:method "GET" :headers headers})
|
||||||
;; authentication types").
|
|
||||||
headers #js {"X-Shared-Key" (str "exporter " cf/management-key)
|
|
||||||
"Cookie" (str "auth-token=" token)}
|
|
||||||
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]
|
(p/mcat (fn [^js resp]
|
||||||
(if (= 200 (.-status resp))
|
(if (= 200 (.-status resp))
|
||||||
(.arrayBuffer resp)
|
(.arrayBuffer resp)
|
||||||
@ -178,18 +190,14 @@
|
|||||||
"Rewrites a gstatic ttf url to the local gfonts proxy (mirrors the browser's
|
"Rewrites a gstatic ttf url to the local gfonts proxy (mirrors the browser's
|
||||||
`google-font-ttf-url`)."
|
`google-font-ttf-url`)."
|
||||||
[ttf-url]
|
[ttf-url]
|
||||||
(let [proxy (-> (cf/get :public-uri)
|
(let [proxy (internal-uri "internal/gfonts/font/")]
|
||||||
(u/ensure-path-slash)
|
|
||||||
(u/join "internal/gfonts/font/")
|
|
||||||
(str))]
|
|
||||||
(str/replace ttf-url "https://fonts.gstatic.com/s/" proxy)))
|
(str/replace ttf-url "https://fonts.gstatic.com/s/" proxy)))
|
||||||
|
|
||||||
(defn- fetch-gfont-bytes
|
(defn- fetch-gfont-bytes
|
||||||
"Downloads a google font TTF through the local gfonts proxy."
|
"Downloads a google font TTF through the local gfonts proxy."
|
||||||
[ttf-url]
|
[ttf-url]
|
||||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
(let [uri (gfont-proxy-url ttf-url)]
|
||||||
uri (gfont-proxy-url ttf-url)]
|
(->> (http/fetch uri #js {:method "GET"})
|
||||||
(->> (http/fetch uri #js {:method "GET" :dispatcher agent})
|
|
||||||
(p/mcat (fn [^js resp]
|
(p/mcat (fn [^js resp]
|
||||||
(if (= 200 (.-status resp))
|
(if (= 200 (.-status resp))
|
||||||
(.arrayBuffer resp)
|
(.arrayBuffer resp)
|
||||||
@ -298,17 +306,9 @@
|
|||||||
(defn- fetch-file-media-bytes
|
(defn- fetch-file-media-bytes
|
||||||
"Downloads an image fill's encoded bytes by file-media id."
|
"Downloads an image fill's encoded bytes by file-media id."
|
||||||
[media-id {:keys [token]}]
|
[media-id {:keys [token]}]
|
||||||
(let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
|
(let [headers (asset-headers token)
|
||||||
;; Cookie, not Bearer: /assets/* redirects to a presigned S3/minio URL,
|
uri (internal-uri (str "assets/by-file-media-id/" media-id))]
|
||||||
;; and a Bearer Authorization header makes S3 400 ("multiple
|
(->> (http/fetch uri #js {:method "GET" :headers headers})
|
||||||
;; authentication types").
|
|
||||||
headers #js {"X-Shared-Key" (str "exporter " cf/management-key)
|
|
||||||
"Cookie" (str "auth-token=" token)}
|
|
||||||
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]
|
(p/mcat (fn [^js resp]
|
||||||
(if (= 200 (.-status resp))
|
(if (= 200 (.-status resp))
|
||||||
(.arrayBuffer resp)
|
(.arrayBuffer resp)
|
||||||
@ -335,7 +335,7 @@
|
|||||||
[scene params]
|
[scene params]
|
||||||
(let [all-ids (resources/scene-image-ids scene)
|
(let [all-ids (resources/scene-image-ids scene)
|
||||||
new-ids (remove wasm/image-cached? all-ids)]
|
new-ids (remove wasm/image-cached? all-ids)]
|
||||||
(l/info :hint "wasm render: provisioning images"
|
(l/dbg :hint "wasm render: provisioning images"
|
||||||
:total (count all-ids)
|
:total (count all-ids)
|
||||||
:cached (- (count all-ids) (count new-ids)))
|
:cached (- (count all-ids) (count new-ids)))
|
||||||
(->> new-ids
|
(->> new-ids
|
||||||
@ -344,7 +344,7 @@
|
|||||||
(p/fmap (fn [buf]
|
(p/fmap (fn [buf]
|
||||||
(if buf
|
(if buf
|
||||||
(do
|
(do
|
||||||
(l/info :hint "wasm render: image stored"
|
(l/dbg :hint "wasm render: image stored"
|
||||||
:media-id (str image-id)
|
:media-id (str image-id)
|
||||||
:bytes (.-byteLength ^js buf))
|
:bytes (.-byteLength ^js buf))
|
||||||
(wasm/store-image! image-id buf))
|
(wasm/store-image! image-id buf))
|
||||||
@ -365,40 +365,47 @@
|
|||||||
|
|
||||||
(defn- render-object-bytes
|
(defn- render-object-bytes
|
||||||
[type id scale]
|
[type id scale]
|
||||||
(case type
|
(if (= :pdf type)
|
||||||
:pdf (let [bytes (wasm/render-shape-pdf id scale)]
|
(let [bytes (wasm/render-shape-pdf id scale)]
|
||||||
(l/info :hint "PDF generated via Skia (render-wasm headless)"
|
(l/dbg :hint "PDF generated via Skia (render-wasm headless)"
|
||||||
:object-id (str id)
|
:object-id (str id)
|
||||||
:backend "skia-wasm"
|
:backend "skia-wasm"
|
||||||
:bytes (.-length bytes))
|
:bytes (.-length bytes))
|
||||||
bytes)
|
bytes)
|
||||||
(wasm/render-shape-raster id scale)))
|
;; The export type doubles as the encoder format: Skia encodes png/jpeg/webp
|
||||||
|
;; natively, so — unlike the Playwright backend — webp needs no imagemagick
|
||||||
|
;; conversion pass. `:svg` never reaches here; it needs vector markup, so
|
||||||
|
;; `app.renderer` keeps it on the browser path.
|
||||||
|
(wasm/render-shape-raster id scale type)))
|
||||||
|
|
||||||
|
;; NOTE: `handlers.export-shapes/prepare-exports` splits an export into
|
||||||
|
;; partitions of 50 objects, and each partition arrives here as its own
|
||||||
|
;; request. Since the unit of work is the partition, every one of them
|
||||||
|
;; re-fetches and re-serializes the *whole* page scene, and re-provisions its
|
||||||
|
;; fonts. Exporting 200 frames therefore pays that setup 4x. Acceptable while
|
||||||
|
;; the module is a single shared instance (`enqueue!` serializes requests
|
||||||
|
;; anyway); revisit together with module pooling.
|
||||||
|
|
||||||
(defn- render*
|
(defn- render*
|
||||||
[{:keys [scale type objects] :as params} on-object]
|
[{:keys [scale type objects] :as params} on-object]
|
||||||
(l/info :hint "wasm render: start" :type type :scale scale :objects (count objects))
|
(l/dbg :hint "wasm render: start"
|
||||||
|
:type type
|
||||||
|
:scale scale
|
||||||
|
:objects (count objects)
|
||||||
|
:file-id (str (:file-id params))
|
||||||
|
:page-id (str (:page-id params)))
|
||||||
(->> (ensure-module!)
|
(->> (ensure-module!)
|
||||||
(p/mcat (fn [_]
|
(p/mcat (fn [_] (fetch-objects params)))
|
||||||
(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]
|
(p/mcat (fn [scene]
|
||||||
(let [sample (first (vals scene))]
|
(l/dbg :hint "wasm render: scene fetched" :shapes (count 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)
|
(serialize/serialize-scene! scene)
|
||||||
(l/info :hint "wasm render: scene serialized")
|
(l/dbg :hint "wasm render: scene serialized")
|
||||||
;; Reset the shared module's font store so fonts from a previous
|
;; Reset the shared module's font store so fonts from a previous
|
||||||
;; request don't accumulate / leak into this one.
|
;; request don't accumulate / leak into this one.
|
||||||
(wasm/clear-fonts!)
|
(wasm/clear-fonts!)
|
||||||
;; Fetch the file's custom (team) font variants once, provision
|
;; Fetch the file's custom (team) font variants once, provision
|
||||||
;; every referenced image once, then resolve/provision fonts per
|
;; every referenced image once, then resolve/provision the
|
||||||
;; rendered object.
|
;; deduped font set for all rendered objects.
|
||||||
(->> (p/all [(fetch-font-variants params)
|
(->> (p/all [(fetch-font-variants params)
|
||||||
(provision-images! scene params)
|
(provision-images! scene params)
|
||||||
(provision-fallback-fonts! scene)])
|
(provision-fallback-fonts! scene)])
|
||||||
@ -407,9 +414,10 @@
|
|||||||
(let [resolve-font (make-resolve-font (or variants []) params)]
|
(let [resolve-font (make-resolve-font (or variants []) params)]
|
||||||
;; Provision every object's fonts BEFORE rendering, so
|
;; Provision every object's fonts BEFORE rendering, so
|
||||||
;; the text relayout below sees real font metrics.
|
;; the text relayout below sees real font metrics.
|
||||||
(p/all (map (fn [{:keys [id]}]
|
;; Deduped across objects: a 50-frame partition that
|
||||||
(wasm/provision-fonts! id resolve-font))
|
;; shares one family downloads its TTF once, not 50
|
||||||
objects)))))
|
;; times.
|
||||||
|
(wasm/provision-fonts! (map :id objects) resolve-font))))
|
||||||
(p/mcat
|
(p/mcat
|
||||||
(fn [_]
|
(fn [_]
|
||||||
;; Serialize-time layout used the fallback font (fonts
|
;; Serialize-time layout used the fallback font (fonts
|
||||||
@ -421,7 +429,7 @@
|
|||||||
(let [bytes (render-object-bytes type id scale)
|
(let [bytes (render-object-bytes type id scale)
|
||||||
path (sh/tempfile :prefix "penpot.tmp.wasm."
|
path (sh/tempfile :prefix "penpot.tmp.wasm."
|
||||||
:suffix (mime/get-extension type))]
|
:suffix (mime/get-extension type))]
|
||||||
(l/info :hint "wasm render: object rendered"
|
(l/dbg :hint "wasm render: object rendered"
|
||||||
:object-id (str id) :bytes (.-length bytes))
|
:object-id (str id) :bytes (.-length bytes))
|
||||||
(fs/writeFileSync path bytes)
|
(fs/writeFileSync path bytes)
|
||||||
;; `on-object` may return a plain value (zip append
|
;; `on-object` may return a plain value (zip append
|
||||||
|
|||||||
@ -23,11 +23,13 @@
|
|||||||
(:require
|
(:require
|
||||||
["node:fs" :as fs]
|
["node:fs" :as fs]
|
||||||
["node:path" :as path]
|
["node:path" :as path]
|
||||||
|
[app.common.data :as d]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.render-wasm.helpers :as h]
|
[app.render-wasm.helpers :as h]
|
||||||
[app.render-wasm.mem :as mem]
|
[app.render-wasm.mem :as mem]
|
||||||
|
[app.render-wasm.serializers :as sr]
|
||||||
[app.render-wasm.wasm :as wasm]
|
[app.render-wasm.wasm :as wasm]
|
||||||
[promesa.core :as p]
|
[promesa.core :as p]
|
||||||
[shadow.esm :refer [dynamic-import]]))
|
[shadow.esm :refer [dynamic-import]]))
|
||||||
@ -37,6 +39,8 @@
|
|||||||
|
|
||||||
;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32].
|
;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32].
|
||||||
(def ^:private RASTER-HEADER-BYTES 12)
|
(def ^:private RASTER-HEADER-BYTES 12)
|
||||||
|
;; render_shape_pdf result header: [len u32] only.
|
||||||
|
(def ^:private PDF-HEADER-BYTES 4)
|
||||||
;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32].
|
;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32].
|
||||||
(def ^:private FONT-ENTRY-BYTES 24)
|
(def ^:private FONT-ENTRY-BYTES 24)
|
||||||
|
|
||||||
@ -111,6 +115,21 @@
|
|||||||
(mem/free)
|
(mem/free)
|
||||||
entries))
|
entries))
|
||||||
|
|
||||||
|
(defn- font-key
|
||||||
|
"Value key for a family map. Its `:id` is a JS array, so the map itself can't
|
||||||
|
be compared by value."
|
||||||
|
[{:keys [id weight style]}]
|
||||||
|
[(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style])
|
||||||
|
|
||||||
|
(defn fonts-for-shapes
|
||||||
|
"Distinct font families needed by every subtree in `shape-ids`. Objects in a
|
||||||
|
partition overwhelmingly share families, so deduping here means one download
|
||||||
|
and one `_store_font` per family rather than one per object."
|
||||||
|
[shape-ids]
|
||||||
|
(into [] (comp (mapcat fonts-for-shape)
|
||||||
|
(d/distinct-xf font-key))
|
||||||
|
shape-ids))
|
||||||
|
|
||||||
(defn store-font!
|
(defn store-font!
|
||||||
"Uploads one font's TTF bytes into the WASM font store, keyed by the family
|
"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."
|
(uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer."
|
||||||
@ -191,12 +210,12 @@
|
|||||||
(h/call wasm/internal-module "_evict_images_to_budget" max-mb))
|
(h/call wasm/internal-module "_evict_images_to_budget" max-mb))
|
||||||
|
|
||||||
(defn provision-fonts!
|
(defn provision-fonts!
|
||||||
"Resolves and uploads every font needed by `shape-id`. `resolve-font` is an
|
"Resolves and uploads every font needed by `shape-ids`, each family fetched
|
||||||
injected fn of the family map -> promise of TTF bytes (or nil to skip). This
|
once. `resolve-font` is an injected fn of the family map -> promise of TTF
|
||||||
keeps the font *source* (gfonts proxy / custom assets / backend) out of the
|
bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom
|
||||||
driver."
|
assets / backend) out of the driver."
|
||||||
[shape-id resolve-font]
|
[shape-ids resolve-font]
|
||||||
(->> (fonts-for-shape shape-id)
|
(->> (fonts-for-shapes shape-ids)
|
||||||
(map (fn [family]
|
(map (fn [family]
|
||||||
(->> (resolve-font family)
|
(->> (resolve-font family)
|
||||||
(p/fmap (fn [bytes] (when bytes (store-font! family bytes)))))))
|
(p/fmap (fn [bytes] (when bytes (store-font! family bytes)))))))
|
||||||
@ -204,35 +223,32 @@
|
|||||||
|
|
||||||
;; --- RENDER
|
;; --- RENDER
|
||||||
|
|
||||||
(defn- render-call
|
(defn- read-render-result
|
||||||
[fn-name shape-id scale]
|
"Copies the encoded payload out of a `_render_shape_*` result buffer and frees
|
||||||
(let [module wasm/internal-module
|
it. `header-bytes` is the size of the header preceding the payload."
|
||||||
buf (uuid/get-u32 shape-id)
|
[offset header-bytes]
|
||||||
offset (h/call module fn-name
|
(let [heap32 (mem/get-heap-u32)
|
||||||
(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))
|
len (aget heap32 (mem/->offset-32 offset))
|
||||||
bytes (read-result-bytes (+ offset RASTER-HEADER-BYTES) len)]
|
bytes (read-result-bytes (+ offset header-bytes) len)]
|
||||||
(mem/free)
|
(mem/free)
|
||||||
bytes))
|
bytes))
|
||||||
|
|
||||||
(defn render-shape-raster
|
(defn render-shape-raster
|
||||||
"Renders the shape subtree to PNG bytes (Uint8Array) on a CPU surface."
|
"Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU
|
||||||
[shape-id scale]
|
surface. `format` is :png, :jpeg or :webp; jpeg is flattened onto white on
|
||||||
(render-call "_render_shape_raster" shape-id scale))
|
the Rust side, since it has no alpha channel."
|
||||||
|
[shape-id scale format]
|
||||||
|
(let [buf (uuid/get-u32 shape-id)]
|
||||||
|
(-> (h/call wasm/internal-module "_render_shape_raster"
|
||||||
|
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||||
|
scale (sr/translate-raster-format format))
|
||||||
|
(read-render-result RASTER-HEADER-BYTES))))
|
||||||
|
|
||||||
(defn render-shape-pdf
|
(defn render-shape-pdf
|
||||||
"Renders the shape subtree to PDF bytes (Uint8Array)."
|
"Renders the shape subtree to PDF bytes (Uint8Array)."
|
||||||
[shape-id scale]
|
[shape-id scale]
|
||||||
;; PDF result header is [len u32] only (no w/h); handled separately.
|
(let [buf (uuid/get-u32 shape-id)]
|
||||||
(let [module wasm/internal-module
|
(-> (h/call wasm/internal-module "_render_shape_pdf"
|
||||||
buf (uuid/get-u32 shape-id)
|
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
||||||
offset (h/call module "_render_shape_pdf"
|
scale)
|
||||||
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
|
(read-render-result PDF-HEADER-BYTES))))
|
||||||
scale)
|
|
||||||
heap32 (mem/get-heap-u32)
|
|
||||||
len (aget heap32 (mem/->offset-32 offset))
|
|
||||||
bytes (read-result-bytes (+ offset 4) len)]
|
|
||||||
(mem/free)
|
|
||||||
bytes))
|
|
||||||
|
|||||||
@ -138,6 +138,16 @@
|
|||||||
default (unchecked-get values "layer-blur")]
|
default (unchecked-get values "layer-blur")]
|
||||||
(d/nilv (unchecked-get values (d/name blur-type)) default)))
|
(d/nilv (unchecked-get values (d/name blur-type)) default)))
|
||||||
|
|
||||||
|
(defn translate-raster-format
|
||||||
|
"Export image format keyword (:png/:jpeg/:webp) -> `RasterFormat` code.
|
||||||
|
Unlike the other translators this one has NO default: falling back to png
|
||||||
|
would emit png bytes under a jpeg/webp mtype, which is exactly what the
|
||||||
|
explicit format threading exists to prevent."
|
||||||
|
[format]
|
||||||
|
(let [values (unchecked-get wasm/serializers "raster-format")]
|
||||||
|
(or (unchecked-get values (d/name format))
|
||||||
|
(throw (ex-info "unsupported raster format" {:format format})))))
|
||||||
|
|
||||||
(defn translate-layout-flex-dir
|
(defn translate-layout-flex-dir
|
||||||
[flex-dir]
|
[flex-dir]
|
||||||
(let [values (unchecked-get wasm/serializers "flex-direction")]
|
(let [values (unchecked-get wasm/serializers "flex-direction")]
|
||||||
|
|||||||
@ -46,7 +46,8 @@
|
|||||||
(reset! context-lost? false))
|
(reset! context-lost? false))
|
||||||
|
|
||||||
(defonce serializers
|
(defonce serializers
|
||||||
#js {:blur-type shared/RawBlurType
|
#js {:raster-format shared/RasterFormat
|
||||||
|
:blur-type shared/RawBlurType
|
||||||
:blend-mode shared/RawBlendMode
|
:blend-mode shared/RawBlendMode
|
||||||
:bool-type shared/RawBoolType
|
:bool-type shared/RawBoolType
|
||||||
:font-style shared/RawFontStyle
|
:font-style shared/RawFontStyle
|
||||||
|
|||||||
@ -20,6 +20,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::render::raster::RasterFormat;
|
||||||
use crate::render::{FrameType, RenderFlag};
|
use crate::render::{FrameType, RenderFlag};
|
||||||
|
|
||||||
use globals::{get_design_state, get_gpu_state, get_render_state, get_resources, has_render_state};
|
use globals::{get_design_state, get_gpu_state, get_render_state, get_resources, has_render_state};
|
||||||
@ -1030,8 +1031,9 @@ 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),
|
/// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]`
|
||||||
/// same layout as `render_shape_pixels`.
|
/// (LE), same layout as `render_shape_pixels`. `format` selects the encoder:
|
||||||
|
/// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
#[wasm_error]
|
#[wasm_error]
|
||||||
pub extern "C" fn render_shape_raster(
|
pub extern "C" fn render_shape_raster(
|
||||||
@ -1040,6 +1042,7 @@ pub extern "C" fn render_shape_raster(
|
|||||||
c: u32,
|
c: u32,
|
||||||
d: u32,
|
d: u32,
|
||||||
scale: f32,
|
scale: f32,
|
||||||
|
format: u32,
|
||||||
) -> Result<*mut u8> {
|
) -> Result<*mut u8> {
|
||||||
let id = uuid_from_u32_quartet(a, b, c, d);
|
let id = uuid_from_u32_quartet(a, b, c, d);
|
||||||
|
|
||||||
@ -1047,8 +1050,10 @@ pub extern "C" fn render_shape_raster(
|
|||||||
return Err(Error::CriticalError("Scale is not finite".to_string()));
|
return Err(Error::CriticalError("Scale is not finite".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let format = RasterFormat::from_u32(format)?;
|
||||||
|
|
||||||
with_state!(state, {
|
with_state!(state, {
|
||||||
let (data, width, height) = state.render_shape_raster(&id, scale)?;
|
let (data, width, height) = state.render_shape_raster(&id, scale, format)?;
|
||||||
|
|
||||||
let len = data.len() as u32;
|
let len = data.len() as u32;
|
||||||
let mut buf = Vec::with_capacity(12 + data.len());
|
let mut buf = Vec::with_capacity(12 + data.len());
|
||||||
|
|||||||
@ -3,17 +3,79 @@ use skia_safe as skia;
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::state::ShapesPoolRef;
|
use crate::state::ShapesPoolRef;
|
||||||
use crate::uuid::Uuid;
|
use crate::uuid::Uuid;
|
||||||
|
use macros::ToJs;
|
||||||
|
|
||||||
use super::vector;
|
use super::vector;
|
||||||
use super::RenderResources;
|
use super::RenderResources;
|
||||||
|
|
||||||
/// Renders a shape tree to PNG bytes on a CPU raster surface (no GPU/WebGL).
|
/// Encoded output format for [`render_to_raster`] and `render_shape_pixels`.
|
||||||
/// Returns `(png_bytes, width_px, height_px)`.
|
/// `ToJs` publishes the discriminants to `api/shared.js`, so the CLJS side
|
||||||
|
/// reads them from here instead of hardcoding the codes.
|
||||||
|
#[repr(u8)]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ToJs)]
|
||||||
|
pub enum RasterFormat {
|
||||||
|
Png = 0,
|
||||||
|
Jpeg = 1,
|
||||||
|
Webp = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RasterFormat {
|
||||||
|
/// Maps the wire code sent by the CLJS hosts.
|
||||||
|
pub fn from_u32(value: u32) -> Result<Self> {
|
||||||
|
match value {
|
||||||
|
0 => Ok(Self::Png),
|
||||||
|
1 => Ok(Self::Jpeg),
|
||||||
|
2 => Ok(Self::Webp),
|
||||||
|
_ => Err(Error::CriticalError(format!(
|
||||||
|
"Unknown raster format: {value}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encoded(self) -> skia::EncodedImageFormat {
|
||||||
|
match self {
|
||||||
|
Self::Png => skia::EncodedImageFormat::PNG,
|
||||||
|
Self::Jpeg => skia::EncodedImageFormat::JPEG,
|
||||||
|
Self::Webp => skia::EncodedImageFormat::WEBP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encoder quality. Mirrors the Playwright backend (`bitmap.cljs`): jpeg
|
||||||
|
/// screenshots default to 95, webp is converted with `-quality 100`.
|
||||||
|
/// Ignored by the PNG encoder.
|
||||||
|
pub fn quality(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
Self::Jpeg => 95,
|
||||||
|
_ => 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque backdrop this format requires, or `None` when it keeps alpha.
|
||||||
|
/// JPEG has no alpha channel, so transparent regions must be flattened onto
|
||||||
|
/// white — Playwright ignores `omitBackground` for jpeg and does the same.
|
||||||
|
/// PNG/WEBP keep transparency.
|
||||||
|
pub fn opaque_background(self) -> Option<skia::Color> {
|
||||||
|
match self {
|
||||||
|
Self::Jpeg => Some(skia::Color::WHITE),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Colour to clear a fresh export surface with.
|
||||||
|
pub fn clear_color(self) -> skia::Color {
|
||||||
|
self.opaque_background()
|
||||||
|
.unwrap_or(skia::Color::TRANSPARENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a shape tree to encoded image bytes on a CPU raster surface (no
|
||||||
|
/// GPU/WebGL). Returns `(encoded_bytes, width_px, height_px)`.
|
||||||
pub fn render_to_raster(
|
pub fn render_to_raster(
|
||||||
shared: &mut RenderResources,
|
shared: &mut RenderResources,
|
||||||
id: &Uuid,
|
id: &Uuid,
|
||||||
tree: ShapesPoolRef,
|
tree: ShapesPoolRef,
|
||||||
scale: f32,
|
scale: f32,
|
||||||
|
format: RasterFormat,
|
||||||
) -> Result<(Vec<u8>, i32, i32)> {
|
) -> Result<(Vec<u8>, i32, i32)> {
|
||||||
let Some(shape) = tree.get(id) else {
|
let Some(shape) = tree.get(id) else {
|
||||||
return Ok((Vec::new(), 0, 0));
|
return Ok((Vec::new(), 0, 0));
|
||||||
@ -40,7 +102,7 @@ pub fn render_to_raster(
|
|||||||
|
|
||||||
{
|
{
|
||||||
let canvas = surface.canvas();
|
let canvas = surface.canvas();
|
||||||
canvas.clear(skia::Color::TRANSPARENT);
|
canvas.clear(format.clear_color());
|
||||||
canvas.scale((scale, scale));
|
canvas.scale((scale, scale));
|
||||||
canvas.translate((-bounds.left(), -bounds.top()));
|
canvas.translate((-bounds.left(), -bounds.top()));
|
||||||
vector::render_tree(shared, canvas, id, tree, scale, bounds)?;
|
vector::render_tree(shared, canvas, id, tree, scale, bounds)?;
|
||||||
@ -50,10 +112,10 @@ pub fn render_to_raster(
|
|||||||
.image_snapshot()
|
.image_snapshot()
|
||||||
.encode(
|
.encode(
|
||||||
None::<&mut skia::gpu::DirectContext>,
|
None::<&mut skia::gpu::DirectContext>,
|
||||||
skia::EncodedImageFormat::PNG,
|
format.encoded(),
|
||||||
100,
|
format.quality(),
|
||||||
)
|
)
|
||||||
.ok_or_else(|| Error::CriticalError("PNG encode failed".to_string()))?;
|
.ok_or_else(|| Error::CriticalError(format!("{format:?} encode failed")))?;
|
||||||
|
|
||||||
Ok((data.as_bytes().to_vec(), width, height))
|
Ok((data.as_bytes().to_vec(), width, height))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ pub use ui::RulerState;
|
|||||||
pub use ui::UIState;
|
pub use ui::UIState;
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::render::raster::RasterFormat;
|
||||||
use crate::render::FrameType;
|
use crate::render::FrameType;
|
||||||
use crate::shapes::{grid_layout::grid_cell_data, FontFamily, Shape};
|
use crate::shapes::{grid_layout::grid_cell_data, FontFamily, Shape};
|
||||||
use crate::uuid::Uuid;
|
use crate::uuid::Uuid;
|
||||||
@ -103,10 +104,15 @@ impl State {
|
|||||||
crate::render::pdf::render_to_pdf(get_resources(), 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
|
/// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to
|
||||||
/// raster surface, no GPU/WebGL.
|
/// `format` on a CPU raster surface, no GPU/WebGL.
|
||||||
pub fn render_shape_raster(&mut self, id: &Uuid, scale: f32) -> Result<(Vec<u8>, i32, i32)> {
|
pub fn render_shape_raster(
|
||||||
crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale)
|
&mut self,
|
||||||
|
id: &Uuid,
|
||||||
|
scale: f32,
|
||||||
|
format: RasterFormat,
|
||||||
|
) -> Result<(Vec<u8>, i32, i32)> {
|
||||||
|
crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinct font families used by the (visible) subtree rooted at `id`, in
|
/// Distinct font families used by the (visible) subtree rooted at `id`, in
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user