diff --git a/render-wasm/_build_env b/render-wasm/_build_env
index 70887662df..1ffa8aa584 100644
--- a/render-wasm/_build_env
+++ b/render-wasm/_build_env
@@ -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,node \
-sMODULARIZE=1 \
-sDISABLE_EXCEPTION_CATCHING=1 \
-sFILESYSTEM=0 \
diff --git a/render-wasm/docs/rendering_architecture.md b/render-wasm/docs/rendering_architecture.md
index 709c882900..3ff1d7ddf9 100644
--- a/render-wasm/docs/rendering_architecture.md
+++ b/render-wasm/docs/rendering_architecture.md
@@ -1,16 +1,21 @@
-# Rendering Architecture: Live (GPU) vs Vector (PDF) Export
+# Rendering Architecture: Live (GPU) vs Vector/Canvas Export
Penpot's WASM engine has **two render paths** that must produce the same picture:
| Path | Purpose | Backend | Code |
|------|---------|---------|------|
| **Live / GPU** | On-screen workspace, thumbnails, PNG export | WebGL surfaces + Skia | `render.rs::render_shape` (+ `render/{fills,strokes,shadows,text,...}.rs`) |
-| **Vector** | True vector PDF (and future SVG) export | Single CPU Skia canvas (no GPU) | `render/vector.rs` → `render/pdf.rs` |
+| **Vector / canvas** | Vector PDF and raster PNG export, runnable headless (no GPU) | Single CPU Skia canvas | `render/vector.rs` → `render/pdf.rs`, `render/raster.rs` |
They share the same shape tree and the same low-level drawing primitives, but
compose them differently. Keeping them in sync is the whole game — see
[Parity guards](#parity-guards).
+The vector/canvas path drives the same `render_tree` onto two surface kinds — a
+PDF document (`render/pdf.rs`) or a CPU bitmap (`render/raster.rs`) — and, since
+it never touches GPU surfaces, it can also run with **no GL context at all**
+(see [Headless mode](#headless-gpu-free-mode)).
+
## Why two paths?
The live path draws each shape into **many intermediate GPU surfaces** (fills,
@@ -18,9 +23,11 @@ strokes, shadows, …) and composites them. Compositing rasterises. That is fine
for the screen and for PNG, but a PDF made that way would be a bitmap.
The vector path bypasses the GPU surface system and draws **directly onto a
-Skia PDF canvas**, so paths, text and fills come out as real PDF vector
-operations. Only inherently pixel-based effects (blur, blurred shadows) are
-rasterised — by Skia's PDF backend, by design.
+single Skia canvas**, so for PDF output paths, text and fills come out as real
+PDF vector operations (only inherently pixel-based effects — blur, blurred
+shadows — are rasterised, by Skia's PDF backend). The same draw path onto a
+CPU raster surface (`render/raster.rs`) gives a PNG without a GPU, which is also
+what makes a browser-less / server-side render possible.
## The two pipelines
@@ -47,13 +54,13 @@ flowchart TB
p3["render_inner_stroke / render_overlay_emoji (text)"]
end
- subgraph VEC["Vector path — render/vector.rs"]
+ subgraph VEC["Vector / canvas path — render/vector.rs"]
direction TB
- v0["render_to_pdf → render_tree(shape)"]
+ v0["render_to_pdf / render_to_raster → render_tree(shape)"]
v1["render_group / render_frame / render_leaf
concat centered_transform, save_layer for opacity/blur"]
v2["draw_drop_shadows (inline)"]
v3["render_leaf_content<R: ShapeRenderer>
fills → fill inner shadows → strokes → stroke inner shadows"]
- v4["one Skia PDF canvas
final z = draw call order"]
+ v4["one Skia canvas (PDF doc or CPU bitmap)
final z = draw call order"]
v0 --> v1 --> v2 --> v3 --> v4
end
@@ -75,6 +82,33 @@ flowchart TB
| Blur / blurred shadow | GPU filter passes | Rasterised by Skia's PDF backend |
| Perf machinery | tiles, `fast_mode`, `can_render_directly` | none (one-shot export) |
+## Headless (GPU-free) mode
+
+The vector/canvas path uses only fonts, images and sampling options from
+`RenderState` — never the GPU surface system — so the engine can boot with no
+WebGL context at all. This is what lets the export run in a browser without a
+GL context, natively (the tests), or server-side under Node.
+
+- **Boot:** `init_headless` (`globals.rs`) builds the engine via
+ `RenderState::try_new_headless`, which skips `gpu_init()` and uses CPU-raster
+ placeholder surfaces (`Surfaces::try_new_headless`) the export path never
+ reads. The interactive render loop is not available on such an instance.
+- **Render:** `render_shape_raster` (PNG) and `render_shape_pdf` go through the
+ same `render_tree`, unchanged.
+- **Images:** `ImageStore::without_gpu` has no GPU context, so image fills are
+ decoded on the CPU (`Image::from_encoded`) at draw time instead of uploaded as
+ textures. As with fonts, the host must upload the image bytes (image `add`
+ export) for the fill to appear.
+- **Fonts (on demand):** the host uploads the fonts a shape needs before
+ rendering. `get_fonts_for_shape` (→ `Shape::font_families`) returns the
+ distinct families used by a subtree — the WASM-tree equivalent of the
+ browser's `get-content-fonts` — so the host can fetch and `store_font` exactly
+ those; `clear_fonts` resets the store between requests.
+
+The native tests build a `try_new_headless` `RenderState` directly, so the whole
+vector/raster path is exercised on the host with no GL context — see
+`render/raster_tests.rs`, `globals_tests.rs`, `state_tests.rs`.
+
## Export wiring (single vs multiple)
The client-side WASM export — rendering in the browser through the vector path
@@ -83,9 +117,12 @@ exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and
only when render-wasm is active and the `:wasm-export` flag is set.
**Multiple/batch export** (`request-multiple-export`) always runs **server-side**
-via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the
-server can use its own WASM renderer. So everything documented here (vector PDF,
-the fixes, parity) applies to single export only.
+via the `:export-shapes` command; it passes an `:is-wasm` hint so the server can
+use its own WASM renderer. That server-side renderer is exactly this engine in
+[headless mode](#headless-gpu-free-mode) (`init_headless` + `render_shape_raster`
+/`render_shape_pdf` + `get_fonts_for_shape`); the host that drives it (fetching
+the shape tree and fonts, calling these exports) lives outside render-wasm, in
+the exporter.
## Parity guards
@@ -109,23 +146,19 @@ The contract is documented on the `ShapeRenderer` trait
`handle_stroke_caps`, `render_inner_stroke`, `render_overlay_emoji`.
Whatever is still duplicated is the remaining drift surface.
-### Not yet done — full unification
-
-The end goal is for `render_shape` to also implement `ShapeRenderer` and route
-its leaf rendering through `render_leaf_content`, so both paths share order and
-gating by construction. This is a large refactor of the live hot path (tiles,
-`fast_mode`, surface compositing, tree-level drop shadows) and **should be
-gated by pixel parity tests** (a vector-vs-GPU raster diff harness lives on a
-separate branch) — do not refactor the live path without that safety net.
-
## File map
| What | Where |
|------|-------|
| Vector entry / PDF | `render/pdf.rs`, `render/vector.rs` |
+| Raster (PNG) entry | `render/raster.rs` |
| Parity trait | `render/shape_renderer.rs` |
| Order seam | `render/vector.rs::render_leaf_content` |
| Live shape render | `render.rs::render_shape` |
| Surface compositing | `render.rs::draw_shape_surface_stack_into` |
| Shared stroke geometry / caps | `render/strokes.rs` |
| Shared text render | `render/text.rs` |
+| Headless boot | `globals.rs::init_headless`, `render.rs::RenderState::try_new_headless`, `render/surfaces.rs::Surfaces::try_new_headless` |
+| GPU-free image store | `render/images.rs::ImageStore::without_gpu` |
+| Font enumeration | `wasm/fonts.rs::get_fonts_for_shape`, `shapes.rs::Shape::font_families` |
+| Headless tests | `render/raster_tests.rs`, `globals_tests.rs`, `state_tests.rs` |
diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs
index 0c9008c005..879503f553 100644
--- a/render-wasm/src/globals.rs
+++ b/render-wasm/src/globals.rs
@@ -23,7 +23,9 @@ 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
}
}
@@ -102,6 +104,15 @@ fn render_init(width: i32, height: i32) {
}
}
+/// Initializes a GPU-free RenderState for the headless export path.
+fn render_init_headless(width: i32, height: i32) {
+ unsafe {
+ let render_state = RenderState::try_new_headless(width, height)
+ .expect("Cannot initialize headless RenderState");
+ RENDER_STATE = Box::into_raw(Box::new(render_state));
+ }
+}
+
/// Initializes DesignState.
fn design_init() {
unsafe {
@@ -130,6 +141,19 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> {
Ok(())
}
+/// Boots the engine for headless export with no GPU/WebGL context (skips
+/// `init_gl!()`/`gpu_init()`). Only the canvas-based export paths
+/// (`render_shape_raster`/`render_shape_pdf`) and font provisioning work on an
+/// instance initialized this way; the interactive render loop does not.
+#[no_mangle]
+#[wasm_error]
+pub extern "C" fn init_headless(width: i32, height: i32) -> Result<()> {
+ render_init_headless(width, height);
+ text_editor_init();
+ design_init();
+ Ok(())
+}
+
#[no_mangle]
#[wasm_error]
pub extern "C" fn clean_up() -> Result<()> {
@@ -141,3 +165,7 @@ pub extern "C" fn clean_up() -> Result<()> {
mem::free_bytes()?;
Ok(())
}
+
+#[cfg(all(test, not(target_arch = "wasm32")))]
+#[path = "globals_tests.rs"]
+mod tests;
diff --git a/render-wasm/src/globals_tests.rs b/render-wasm/src/globals_tests.rs
new file mode 100644
index 0000000000..eec7895cc7
--- /dev/null
+++ b/render-wasm/src/globals_tests.rs
@@ -0,0 +1,63 @@
+//! GPU-free headless boot + render test: `render_init_headless` builds the
+//! render state on the CPU and renders a real text shape end-to-end, as the
+//! exporter does after `init_headless`.
+
+use super::*;
+use crate::shapes::{
+ Fill, FontFamily, FontStyle, GrowType, Paragraph, SolidColor, TextAlign, TextContent,
+ TextDirection, TextSpan, Type,
+};
+use crate::uuid::Uuid;
+use skia_safe as skia;
+
+const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
+
+#[test]
+fn headless_init_and_render_text_without_gpu() {
+ render_init_headless(800, 600);
+ text_editor_init();
+ design_init();
+
+ let state = get_design_state();
+ let id = Uuid::new_v4();
+ {
+ let shape = state.shapes.add_shape(id);
+ shape.selrect = skia::Rect::from_xywh(0.0, 0.0, 200.0, 60.0);
+
+ // Default built-in font (registered by FontStore::try_new).
+ let span = TextSpan::new(
+ "Headless".to_string(),
+ FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
+ 32.0,
+ 1.2,
+ 0.0,
+ None,
+ None,
+ TextDirection::LTR,
+ 400,
+ Uuid::nil(),
+ vec![Fill::Solid(SolidColor(skia::Color::BLACK))],
+ );
+ let paragraph = Paragraph::new(
+ TextAlign::default(),
+ TextDirection::LTR,
+ None,
+ None,
+ 1.2,
+ 0.0,
+ vec![span],
+ );
+ let mut content =
+ TextContent::new(skia::Rect::from_xywh(0.0, 0.0, 200.0, 60.0), GrowType::Fixed);
+ content.add_paragraph(paragraph);
+ shape.shape_type = Type::Text(content);
+ }
+
+ let (png, width, height) = state
+ .render_shape_raster(&id, 1.0)
+ .expect("headless raster render");
+
+ assert!(width > 0 && height > 0, "non-empty dimensions");
+ assert!(png.len() > PNG_MAGIC.len(), "non-empty PNG");
+ assert_eq!(&png[..8], &PNG_MAGIC, "valid PNG signature");
+}
diff --git a/render-wasm/src/js/render-headless.mjs b/render-wasm/src/js/render-headless.mjs
new file mode 100644
index 0000000000..97bc1760c3
--- /dev/null
+++ b/render-wasm/src/js/render-headless.mjs
@@ -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,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);
+});
diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs
index d16ae271e6..b6b41c4314 100644
--- a/render-wasm/src/main.rs
+++ b/render-wasm/src/main.rs
@@ -918,6 +918,36 @@ pub extern "C" fn render_shape_pixels(
})
}
+/// 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))
+ })
+}
+
#[no_mangle]
pub extern "C" fn render_stats() {
get_render_state().print_stats();
diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs
index 2cfc01850e..eec0652ffe 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -7,6 +7,7 @@ pub mod grid_layout;
mod images;
mod options;
pub mod pdf;
+pub mod raster;
mod shadows;
pub mod shape_renderer;
mod strokes;
@@ -529,13 +530,46 @@ impl RenderState {
let sampling_options =
skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
- let fonts = FontStore::try_new()?;
let surfaces = Surfaces::try_new(
(width, height),
sampling_options,
tiles::get_tile_dimensions(),
)?;
+ Self::assemble(width, height, sampling_options, surfaces, ImageStore::new())
+ }
+
+ /// GPU-free `RenderState` for the headless export path (raster/PDF). Tile/
+ /// atlas surfaces are CPU placeholders the export path never reads; the
+ /// interactive render loop is not supported on this instance.
+ pub fn try_new_headless(width: i32, height: i32) -> Result {
+ let sampling_options =
+ skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::Nearest);
+
+ let surfaces = Surfaces::try_new_headless(
+ (width, height),
+ sampling_options,
+ tiles::get_tile_dimensions(),
+ )?;
+
+ Self::assemble(
+ width,
+ height,
+ sampling_options,
+ surfaces,
+ ImageStore::without_gpu(),
+ )
+ }
+
+ fn assemble(
+ width: i32,
+ height: i32,
+ sampling_options: skia::SamplingOptions,
+ surfaces: Surfaces,
+ images: ImageStore,
+ ) -> Result {
+ let fonts = FontStore::try_new()?;
+
// This is used multiple times everywhere so instead of creating new instances every
// time we reuse this one.
@@ -550,7 +584,7 @@ impl RenderState {
fonts,
viewbox,
cached_viewbox: Viewbox::new(0., 0.),
- images: ImageStore::new(),
+ images,
background_color: skia::Color::TRANSPARENT,
pending_nodes: vec![],
current_tile: None,
diff --git a/render-wasm/src/render/images.rs b/render-wasm/src/render/images.rs
index b567829ab7..16f3d1a0a8 100644
--- a/render-wasm/src/render/images.rs
+++ b/render-wasm/src/render/images.rs
@@ -61,7 +61,9 @@ enum StoredImage {
pub struct ImageStore {
images: HashMap<(Uuid, bool), StoredImage>,
- context: Box,
+ /// GPU context for decoding images to textures. `None` in headless mode,
+ /// where image fills are skipped (other shapes render normally).
+ context: Option>,
}
/// Creates a Skia image from an existing WebGL texture.
@@ -149,7 +151,16 @@ impl ImageStore {
let context = &gpu_state.context;
Self {
images: HashMap::with_capacity(2048),
- context: Box::new(context.clone()),
+ context: Some(Box::new(context.clone())),
+ }
+ }
+
+ /// `ImageStore` with no GPU context (headless export); images are decoded on
+ /// the CPU at draw time (see `get_cpu_image`) instead of uploaded as textures.
+ pub fn 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 {
- 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 {
+ 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,9 @@ impl ImageStore {
match entry {
StoredImage::Gpu(ref img) => Some(img),
StoredImage::Raw(raw_data) => {
- let gpu_image = decode_image(&mut self.context, raw_data)?;
+ // GPU-texture path only; the headless CPU path is `get_cpu_image`.
+ 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 {
diff --git a/render-wasm/src/render/raster.rs b/render-wasm/src/render/raster.rs
new file mode 100644
index 0000000000..4018f51dd2
--- /dev/null
+++ b/render-wasm/src/render/raster.rs
@@ -0,0 +1,57 @@
+use skia_safe as skia;
+
+use crate::error::{Error, Result};
+use crate::state::ShapesPoolRef;
+use crate::uuid::Uuid;
+
+use super::vector::{self, VectorTarget};
+use super::RenderState;
+
+/// Renders a shape tree to PNG bytes on a CPU raster surface (no GPU/WebGL),
+/// through the same `render_tree` used by the PDF backend. Returns
+/// `(png_bytes, width_px, height_px)`. With a headless `RenderState` this runs
+/// without a GL context.
+pub fn render_to_raster(
+ shared: &mut RenderState,
+ id: &Uuid,
+ tree: ShapesPoolRef,
+ scale: f32,
+) -> Result<(Vec, i32, i32)> {
+ let Some(shape) = tree.get(id) else {
+ return Ok((Vec::new(), 0, 0));
+ };
+ let bounds = 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, VectorTarget::Raster)?;
+ }
+
+ 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))
+}
+
+#[cfg(all(test, not(target_arch = "wasm32")))]
+#[path = "raster_tests.rs"]
+mod tests;
diff --git a/render-wasm/src/render/raster_tests.rs b/render-wasm/src/render/raster_tests.rs
new file mode 100644
index 0000000000..f101e716e4
--- /dev/null
+++ b/render-wasm/src/render/raster_tests.rs
@@ -0,0 +1,52 @@
+//! GPU-free raster export tests. Run on the host (not wasm32), never call
+//! `init()`/`gpu_init()` — a headless `RenderState` needs no GL context.
+
+use super::*;
+use crate::shapes::{Fill, SolidColor};
+use crate::state::ShapesPool;
+
+const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
+
+fn rect_pool(id: Uuid, color: skia::Color) -> ShapesPool {
+ let mut pool = ShapesPool::new();
+ let shape = pool.add_shape(id);
+ shape.selrect = skia::Rect::from_xywh(0.0, 0.0, 100.0, 50.0);
+ shape.add_fill(Fill::Solid(SolidColor(color)));
+ pool
+}
+
+#[test]
+fn renders_solid_rect_to_png_without_gpu() {
+ let mut rs = RenderState::try_new_headless(64, 64).expect("headless render state");
+ let id = Uuid::new_v4();
+ let pool = rect_pool(id, skia::Color::RED);
+
+ let (png, width, height) = render_to_raster(&mut rs, &id, &pool, 1.0).expect("raster render");
+
+ assert_eq!((width, height), (100, 50), "output dimensions");
+ assert!(png.len() > PNG_MAGIC.len(), "non-empty PNG");
+ assert_eq!(&png[..8], &PNG_MAGIC, "valid PNG signature");
+}
+
+#[test]
+fn honors_scale() {
+ let mut rs = RenderState::try_new_headless(64, 64).expect("headless render state");
+ let id = Uuid::new_v4();
+ let pool = rect_pool(id, skia::Color::BLUE);
+
+ let (_png, width, height) = render_to_raster(&mut rs, &id, &pool, 2.0).expect("raster render");
+
+ assert_eq!((width, height), (200, 100), "dimensions scale with `scale`");
+}
+
+#[test]
+fn missing_shape_returns_empty() {
+ let mut rs = RenderState::try_new_headless(64, 64).expect("headless render state");
+ let pool = ShapesPool::new();
+
+ let (png, width, height) =
+ render_to_raster(&mut rs, &Uuid::new_v4(), &pool, 1.0).expect("render");
+
+ assert!(png.is_empty());
+ assert_eq!((width, height), (0, 0));
+}
diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs
index 04d6948371..de54ddd67e 100644
--- a/render-wasm/src/render/surfaces.rs
+++ b/render-wasm/src/render/surfaces.rs
@@ -86,6 +86,12 @@ pub struct DocAtlas {
pub tile_doc_rects: HashMap,
}
+/// CPU raster surface (no GPU/WebGL), for the headless render state.
+fn headless_surface(width: i32, height: i32) -> Result {
+ skia::surfaces::raster_n32_premul((width.max(1), height.max(1)))
+ .ok_or_else(|| Error::CriticalError("Failed to create raster surface".to_string()))
+}
+
impl DocAtlas {
pub fn try_new() -> Result {
// Keep atlas as a regular surface like the rest. Start with a tiny
@@ -105,6 +111,21 @@ impl DocAtlas {
})
}
+ /// GPU-free [`DocAtlas::try_new`]; never written to on the export path.
+ pub fn try_new_headless() -> Result {
+ let mut surface = headless_surface(1, 1)?;
+ surface.canvas().clear(skia::Color::TRANSPARENT);
+
+ Ok(Self {
+ surface,
+ origin: skia::Point::new(0.0, 0.0),
+ size: skia::ISize::new(0, 0),
+ scale: 1.0,
+ doc_bounds: None,
+ tile_doc_rects: HashMap::default(),
+ })
+ }
+
pub fn is_empty(&self) -> bool {
self.size.width <= 0 || self.size.height <= 0
}
@@ -510,6 +531,48 @@ impl Surfaces {
})
}
+ /// GPU-free [`Surfaces::try_new`]: CPU-raster placeholders (1×1, except
+ /// `target`) the canvas export renderers never read.
+ pub fn try_new_headless(
+ (width, height): (i32, i32),
+ sampling_options: skia::SamplingOptions,
+ tile_dims: skia::ISize,
+ ) -> Result {
+ let extra_tile_dims = skia::ISize::new(
+ tile_dims.width * TILE_SIZE_MULTIPLIER,
+ tile_dims.height * TILE_SIZE_MULTIPLIER,
+ );
+ let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4);
+
+ Ok(Self {
+ target: headless_surface(width, height)?,
+ filter: headless_surface(1, 1)?,
+ cache: headless_surface(1, 1)?,
+ current: headless_surface(1, 1)?,
+ drop_shadows: headless_surface(1, 1)?,
+ inner_shadows: headless_surface(1, 1)?,
+ text_drop_shadows: headless_surface(1, 1)?,
+ shape_fills: headless_surface(1, 1)?,
+ shape_strokes: headless_surface(1, 1)?,
+ ui: headless_surface(1, 1)?,
+ debug: headless_surface(1, 1)?,
+ export: headless_surface(1, 1)?,
+ backbuffer: headless_surface(1, 1)?,
+ tile_atlas: headless_surface(1, 1)?,
+ tiles: TileTextureCache::new(1, 512),
+ atlas: DocAtlas::try_new_headless()?,
+ sampling_options,
+ atlas_sampling_options: skia::SamplingOptions::new(
+ skia::FilterMode::Nearest,
+ skia::MipmapMode::None,
+ ),
+ margins,
+ dirty_surfaces: 0,
+ extra_tile_dims,
+ dpr: 1.0,
+ })
+ }
+
pub fn set_dpr(&mut self, dpr: f32) {
self.dpr = dpr;
}
diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs
index 721a02fbc5..0e54992316 100644
--- a/render-wasm/src/render/vector.rs
+++ b/render-wasm/src/render/vector.rs
@@ -16,10 +16,12 @@ 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).
+/// Vector export backend selector. Drawing is identical for every variant;
+/// `Raster` draws to a bitmap surface instead of a PDF document.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VectorTarget {
Pdf,
+ Raster,
}
// ---------------------------------------------------------------------------
diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs
index 9c24fa2807..b8b3282f07 100644
--- a/render-wasm/src/shapes.rs
+++ b/render-wasm/src/shapes.rs
@@ -1253,6 +1253,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 {
+ match &self.shape_type {
+ Type::Text(content) => content.font_families(),
+ _ => Vec::new(),
+ }
+ }
+
#[allow(dead_code)]
pub fn mask_filter(&self, scale: f32) -> Option {
self.blur
diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs
index 86159ac3df..05a68f9d5d 100644
--- a/render-wasm/src/shapes/text.rs
+++ b/render-wasm/src/shapes/text.rs
@@ -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 {
+ let mut seen: Vec = 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);
diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs
index 36627038dc..8aa1d7d6c7 100644
--- a/render-wasm/src/state.rs
+++ b/render-wasm/src/state.rs
@@ -8,7 +8,7 @@ pub use text_editor::*;
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};
@@ -86,6 +86,32 @@ impl State {
crate::render::pdf::render_to_pdf(get_render_state(), 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_render_state(), 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 {
+ let Some(root) = self.shapes.get(id) else {
+ return Vec::new();
+ };
+
+ let mut result: Vec = 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 {
let render_state = get_render_state();
// If zoom changed (e.g. interrupted zoom render followed by pan), the
@@ -286,3 +312,7 @@ impl State {
}
}
}
+
+#[cfg(all(test, not(target_arch = "wasm32")))]
+#[path = "state_tests.rs"]
+mod tests;
diff --git a/render-wasm/src/state_tests.rs b/render-wasm/src/state_tests.rs
new file mode 100644
index 0000000000..6becdac047
--- /dev/null
+++ b/render-wasm/src/state_tests.rs
@@ -0,0 +1,79 @@
+//! GPU-free font-provisioning tests: `fonts_used_by_shape` enumerates exactly
+//! the families a text tree uses, and `FontStore` provisions them with no GPU.
+
+use super::*;
+use crate::render::FontStore;
+use crate::shapes::{
+ Fill, FontFamily, FontStyle, GrowType, Paragraph, SolidColor, TextAlign, TextContent,
+ TextDirection, TextSpan, Type,
+};
+
+const TTF: &[u8] = include_bytes!("fonts/sourcesanspro-regular.ttf");
+
+fn text_span(family: FontFamily) -> TextSpan {
+ TextSpan::new(
+ "Hello".to_string(),
+ family,
+ 24.0,
+ 1.2,
+ 0.0,
+ None,
+ None,
+ TextDirection::LTR,
+ family.weight() as i32,
+ Uuid::nil(),
+ vec![Fill::Solid(SolidColor(skia::Color::BLACK))],
+ )
+}
+
+fn text_shape(state: &mut State, id: Uuid, families: &[FontFamily]) {
+ let shape = state.shapes.add_shape(id);
+ shape.selrect = skia::Rect::from_xywh(0.0, 0.0, 200.0, 50.0);
+
+ let spans = families.iter().copied().map(text_span).collect();
+ let paragraph = Paragraph::new(
+ TextAlign::default(),
+ TextDirection::LTR,
+ None,
+ None,
+ 1.2,
+ 0.0,
+ spans,
+ );
+ let mut content =
+ TextContent::new(skia::Rect::from_xywh(0.0, 0.0, 200.0, 50.0), GrowType::Fixed);
+ content.add_paragraph(paragraph);
+ shape.shape_type = Type::Text(content);
+}
+
+#[test]
+fn enumerates_distinct_fonts_used_by_text() {
+ let mut state = State::new();
+ let id = Uuid::new_v4();
+ let a = FontFamily::new(Uuid::new_v4(), 700, FontStyle::Italic);
+ let b = FontFamily::new(Uuid::new_v4(), 400, FontStyle::Normal);
+
+ // `a` appears twice; reported once, in first-seen order.
+ text_shape(&mut state, id, &[a, b, a]);
+
+ assert_eq!(state.fonts_used_by_shape(&id), vec![a, b]);
+}
+
+#[test]
+fn non_text_shape_needs_no_fonts() {
+ let mut state = State::new();
+ let id = Uuid::new_v4();
+ state.shapes.add_shape(id).selrect = skia::Rect::from_xywh(0.0, 0.0, 10.0, 10.0);
+
+ assert!(state.fonts_used_by_shape(&id).is_empty());
+}
+
+#[test]
+fn font_store_provisions_family_on_demand_without_gpu() {
+ let family = FontFamily::new(Uuid::new_v4(), 700, FontStyle::Italic);
+ let mut store = FontStore::try_new().expect("font store");
+
+ assert!(!store.has_family(&family, false), "not present before upload");
+ store.add(family, TTF, false, false).expect("provision font");
+ assert!(store.has_family(&family, false), "present after upload");
+}
diff --git a/render-wasm/src/wasm/fonts.rs b/render-wasm/src/wasm/fonts.rs
index 21c4e6a797..4d7f5ad7c0 100644
--- a/render-wasm/src/wasm/fonts.rs
+++ b/render-wasm/src/wasm/fonts.rs
@@ -2,8 +2,10 @@ use macros::{wasm_error, ToJs};
use crate::get_render_state;
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)]
@@ -53,6 +55,36 @@ pub extern "C" fn store_font(
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_render_state().fonts_mut() = 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,