diff --git a/backend/scripts/_env b/backend/scripts/_env index b039bf43a6..9b33950016 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -78,7 +78,9 @@ export PENPOT_INTERNAL_URI=http://localhost:3450 # 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). +# 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_DIR=../frontend/resources/public/js # export PENPOT_WASM_IMAGE_CACHE_MB=256 diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 260e5fda8d..c579e33418 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -6,7 +6,8 @@ ENV LANG=en_US.UTF-8 \ NODE_VERSION=v24.18.0 \ DEBIAN_FRONTEND=noninteractive \ 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; \ useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ diff --git a/exporter/deps.edn b/exporter/deps.edn index 87eac71c4c..2654d307ea 100644 --- a/exporter/deps.edn +++ b/exporter/deps.edn @@ -6,6 +6,8 @@ ;; api.shapes,...}), so the CLJS↔WASM binary protocol cannot drift from 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. + ;; NOTE: `app.config` exists in both trees; the exporter's `:paths` come + ;; first, so its own wins. penpot/frontend {:local/root "../frontend"} org.clojure/clojure {:mvn/version "1.12.2"} binaryage/devtools {:mvn/version "1.0.7"} diff --git a/exporter/scripts/build b/exporter/scripts/build index 40eba8f44c..7b2e4f7a63 100755 --- a/exporter/scripts/build +++ b/exporter/scripts/build @@ -18,6 +18,17 @@ cp pnpm-workspace.yaml target/; cp package.json target/; 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 < 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 - serialized one at a time (no pooling yet). PNG + PDF are wired; jpeg/webp - still need the PNG->format conversion that `bitmap` does." + serialized one at a time (no pooling yet)." (:require ["node:fs" :as fs] ["undici" :as http] @@ -76,31 +79,54 @@ (reset! queue (p/handle result (fn [_ _] nil))) 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) (defn- fetch-objects "Fetches the page's `objects` map from the backend via the `get-page` RPC, using the same auth the exporter uses elsewhere (management key + bearer)." [{:keys [file-id page-id share-id token]}] - (let [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" (str "Bearer " token)} + (let [headers (rpc-headers token) ;; share-id is an OPTIONAL uuid on the backend; it must be omitted when ;; absent, not sent as nil (nil fails the uuid schema). body (t/encode-str (cond-> {:file-id file-id :page-id page-id} share-id (assoc :share-id share-id))) - uri (-> (cf/get :public-uri) - (u/ensure-path-slash) - (u/join "api/rpc/command/get-page") - (str))] - (l/info :hint "wasm render: get-page" + uri (internal-uri "api/rpc/command/get-page")] + (l/dbg :hint "wasm render: get-page" :uri uri :file-id (str file-id) - :page-id (str page-id) - :token-len (count (str token))) - (->> (http/fetch uri #js {:method "POST" :headers headers :body body :dispatcher agent}) + :page-id (str page-id)) + (->> (http/fetch uri #js {:method "POST" :headers headers :body body}) (p/mcat (fn [^js resp] (if (= 200 (.-status resp)) (.text resp) @@ -130,17 +156,11 @@ 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]}] - (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" (str "Bearer " token)} + (let [headers (rpc-headers token) 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}) + uri (internal-uri "api/rpc/command/get-font-variants")] + (->> (http/fetch uri #js {:method "POST" :headers headers :body body}) (p/mcat (fn [^js resp] (if (= 200 (.-status resp)) (.text resp) @@ -154,17 +174,9 @@ "Downloads a stored asset (font TTF) by id, returning a promise of an ArrayBuffer (or nil)." [asset-id {:keys [token]}] - (let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}}) - ;; Cookie, not Bearer: /assets/* redirects to a presigned S3/minio URL, - ;; and a Bearer Authorization header makes S3 400 ("multiple - ;; 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}) + (let [headers (asset-headers token) + uri (internal-uri (str "assets/by-id/" asset-id))] + (->> (http/fetch uri #js {:method "GET" :headers headers}) (p/mcat (fn [^js resp] (if (= 200 (.-status resp)) (.arrayBuffer resp) @@ -178,18 +190,14 @@ "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))] + (let [proxy (internal-uri "internal/gfonts/font/")] (str/replace ttf-url "https://fonts.gstatic.com/s/" proxy))) (defn- fetch-gfont-bytes "Downloads a google font TTF through the local gfonts proxy." [ttf-url] - (let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}}) - uri (gfont-proxy-url ttf-url)] - (->> (http/fetch uri #js {:method "GET" :dispatcher agent}) + (let [uri (gfont-proxy-url ttf-url)] + (->> (http/fetch uri #js {:method "GET"}) (p/mcat (fn [^js resp] (if (= 200 (.-status resp)) (.arrayBuffer resp) @@ -298,17 +306,9 @@ (defn- fetch-file-media-bytes "Downloads an image fill's encoded bytes by file-media id." [media-id {:keys [token]}] - (let [agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}}) - ;; Cookie, not Bearer: /assets/* redirects to a presigned S3/minio URL, - ;; and a Bearer Authorization header makes S3 400 ("multiple - ;; 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}) + (let [headers (asset-headers token) + uri (internal-uri (str "assets/by-file-media-id/" media-id))] + (->> (http/fetch uri #js {:method "GET" :headers headers}) (p/mcat (fn [^js resp] (if (= 200 (.-status resp)) (.arrayBuffer resp) @@ -335,7 +335,7 @@ [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" + (l/dbg :hint "wasm render: provisioning images" :total (count all-ids) :cached (- (count all-ids) (count new-ids))) (->> new-ids @@ -344,7 +344,7 @@ (p/fmap (fn [buf] (if buf (do - (l/info :hint "wasm render: image stored" + (l/dbg :hint "wasm render: image stored" :media-id (str image-id) :bytes (.-byteLength ^js buf)) (wasm/store-image! image-id buf)) @@ -365,40 +365,47 @@ (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))) + (if (= :pdf type) + (let [bytes (wasm/render-shape-pdf id scale)] + (l/dbg :hint "PDF generated via Skia (render-wasm headless)" + :object-id (str id) + :backend "skia-wasm" + :bytes (.-length bytes)) + bytes) + ;; The export type doubles as the encoder format: Skia encodes png/jpeg/webp + ;; natively, so — 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* [{: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!) - (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 [_] (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)))))) + (l/dbg :hint "wasm render: scene fetched" :shapes (count 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 ;; 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. + ;; every referenced image once, then resolve/provision the + ;; deduped font set for all rendered objects. (->> (p/all [(fetch-font-variants params) (provision-images! scene params) (provision-fallback-fonts! scene)]) @@ -407,9 +414,10 @@ (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))))) + ;; Deduped across objects: a 50-frame partition that + ;; shares one family downloads its TTF once, not 50 + ;; times. + (wasm/provision-fonts! (map :id objects) resolve-font)))) (p/mcat (fn [_] ;; Serialize-time layout used the fallback font (fonts @@ -421,7 +429,7 @@ (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" + (l/dbg :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 diff --git a/exporter/src/app/wasm.cljs b/exporter/src/app/wasm.cljs index d47ea246f3..2fcceb4ccf 100644 --- a/exporter/src/app/wasm.cljs +++ b/exporter/src/app/wasm.cljs @@ -23,11 +23,13 @@ (:require ["node:fs" :as fs] ["node:path" :as path] + [app.common.data :as d] [app.common.logging :as l] [app.common.uuid :as uuid] [app.config :as cf] [app.render-wasm.helpers :as h] [app.render-wasm.mem :as mem] + [app.render-wasm.serializers :as sr] [app.render-wasm.wasm :as wasm] [promesa.core :as p] [shadow.esm :refer [dynamic-import]])) @@ -37,6 +39,8 @@ ;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32]. (def ^:private RASTER-HEADER-BYTES 12) +;; render_shape_pdf result header: [len u32] only. +(def ^:private PDF-HEADER-BYTES 4) ;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32]. (def ^:private FONT-ENTRY-BYTES 24) @@ -111,6 +115,21 @@ (mem/free) entries)) +(defn- font-key + "Value key for a family map. Its `:id` is a JS array, so the map itself can't + be compared by value." + [{:keys [id weight style]}] + [(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style]) + +(defn fonts-for-shapes + "Distinct font families needed by every subtree in `shape-ids`. Objects in a + partition overwhelmingly share families, so deduping here means one download + and one `_store_font` per family rather than one per object." + [shape-ids] + (into [] (comp (mapcat fonts-for-shape) + (d/distinct-xf font-key)) + shape-ids)) + (defn store-font! "Uploads one font's TTF bytes into the WASM font store, keyed by the family (uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer." @@ -191,12 +210,12 @@ (h/call wasm/internal-module "_evict_images_to_budget" max-mb)) (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) + "Resolves and uploads every font needed by `shape-ids`, each family fetched + once. `resolve-font` is an injected fn of the family map -> promise of TTF + bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom + assets / backend) out of the driver." + [shape-ids resolve-font] + (->> (fonts-for-shapes shape-ids) (map (fn [family] (->> (resolve-font family) (p/fmap (fn [bytes] (when bytes (store-font! family bytes))))))) @@ -204,35 +223,32 @@ ;; --- 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) +(defn- read-render-result + "Copies the encoded payload out of a `_render_shape_*` result buffer and frees + it. `header-bytes` is the size of the header preceding the payload." + [offset header-bytes] + (let [heap32 (mem/get-heap-u32) len (aget heap32 (mem/->offset-32 offset)) - bytes (read-result-bytes (+ offset RASTER-HEADER-BYTES) len)] + bytes (read-result-bytes (+ offset 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)) + "Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU + surface. `format` is :png, :jpeg or :webp; jpeg is flattened onto white on + the Rust side, since it has no alpha channel." + [shape-id scale format] + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_raster" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale (sr/translate-raster-format format)) + (read-render-result RASTER-HEADER-BYTES)))) (defn render-shape-pdf "Renders the shape subtree to PDF bytes (Uint8Array)." [shape-id scale] - ;; 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)) + (let [buf (uuid/get-u32 shape-id)] + (-> (h/call wasm/internal-module "_render_shape_pdf" + (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) + scale) + (read-render-result PDF-HEADER-BYTES)))) diff --git a/frontend/src/app/render_wasm/serializers.cljs b/frontend/src/app/render_wasm/serializers.cljs index 3cc62c3c0c..16aadd7a63 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/frontend/src/app/render_wasm/serializers.cljs @@ -138,6 +138,16 @@ default (unchecked-get values "layer-blur")] (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 [flex-dir] (let [values (unchecked-get wasm/serializers "flex-direction")] diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index 77d8726347..dee660408a 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -46,7 +46,8 @@ (reset! context-lost? false)) (defonce serializers - #js {:blur-type shared/RawBlurType + #js {:raster-format shared/RasterFormat + :blur-type shared/RawBlurType :blend-mode shared/RawBlendMode :bool-type shared/RawBoolType :font-style shared/RawFontStyle diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 52f7b77263..c95ba526d9 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -20,6 +20,7 @@ use std::collections::HashMap; #[allow(unused_imports)] use crate::error::{Error, Result}; +use crate::render::raster::RasterFormat; use crate::render::{FrameType, RenderFlag}; 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), -/// same layout as `render_shape_pixels`. +/// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]` +/// (LE), same layout as `render_shape_pixels`. `format` selects the encoder: +/// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`). #[no_mangle] #[wasm_error] pub extern "C" fn render_shape_raster( @@ -1040,6 +1042,7 @@ pub extern "C" fn render_shape_raster( c: u32, d: u32, scale: f32, + format: u32, ) -> Result<*mut u8> { 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())); } + let format = RasterFormat::from_u32(format)?; + 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 mut buf = Vec::with_capacity(12 + data.len()); diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs index 34692d6ab4..8ccbca1cca 100644 --- a/render-wasm/src/render/raster.rs +++ b/render-wasm/src/render/raster.rs @@ -3,17 +3,79 @@ use skia_safe as skia; use crate::error::{Error, Result}; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; +use macros::ToJs; 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)`. +/// Encoded output format for [`render_to_raster`] and `render_shape_pixels`. +/// `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 { + 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 { + 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( shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, scale: f32, + format: RasterFormat, ) -> Result<(Vec, i32, i32)> { let Some(shape) = tree.get(id) else { return Ok((Vec::new(), 0, 0)); @@ -40,7 +102,7 @@ pub fn render_to_raster( { let canvas = surface.canvas(); - canvas.clear(skia::Color::TRANSPARENT); + canvas.clear(format.clear_color()); canvas.scale((scale, scale)); canvas.translate((-bounds.left(), -bounds.top())); vector::render_tree(shared, canvas, id, tree, scale, bounds)?; @@ -50,10 +112,10 @@ pub fn render_to_raster( .image_snapshot() .encode( None::<&mut skia::gpu::DirectContext>, - skia::EncodedImageFormat::PNG, - 100, + format.encoded(), + 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)) } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index f9cd814548..fb277f2fdc 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -10,6 +10,7 @@ pub use ui::RulerState; pub use ui::UIState; use crate::error::{Error, Result}; +use crate::render::raster::RasterFormat; use crate::render::FrameType; use crate::shapes::{grid_layout::grid_cell_data, FontFamily, Shape}; use crate::uuid::Uuid; @@ -103,10 +104,15 @@ impl State { 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, i32, i32)> { - crate::render::raster::render_to_raster(get_resources(), id, &self.shapes, scale) + /// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to + /// `format` on a CPU raster surface, no GPU/WebGL. + pub fn render_shape_raster( + &mut self, + id: &Uuid, + scale: f32, + format: RasterFormat, + ) -> Result<(Vec, 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