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,