Support png, jpeg and webp export over the headless exporter

This commit is contained in:
Elena Torro 2026-07-27 16:05:09 +02:00
parent fecedcea51
commit bcc8399bcc
12 changed files with 263 additions and 133 deletions

View File

@ -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

View File

@ -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; \

View File

@ -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"}

View File

@ -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 <<EOF | tee target/setup
#/usr/bin/env bash
set -e;

View File

@ -45,12 +45,18 @@
;; 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"))
;;
;; `:svg` stays on the browser path even when headless is enabled: it needs
;; real vector markup from `app.renderer.svg`, which a raster/PDF backend
;; cannot produce. Every other type is encoded natively by Skia.
(let [headless? (and is-wasm
(cf/get :wasm-headless)
(not= :svg type))]
(when is-wasm
(l/info :hint "render"
:type type
:wasm-headless (boolean (cf/get :wasm-headless))
:backend (if headless? "wasm" "browser")))
(if headless?
(rw/render params on-object)
(case type

View File

@ -18,9 +18,12 @@
enumerated by the host-agnostic app.render-wasm.{resources,fallback-fonts})
-> 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

View File

@ -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))))

View File

@ -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")]

View File

@ -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

View File

@ -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());

View File

@ -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<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(
shared: &mut RenderResources,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
format: RasterFormat,
) -> Result<(Vec<u8>, 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))
}

View File

@ -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<u8>, 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<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