Add headless (GPU-free) render-wasm export path

- init_headless + RenderState::try_new_headless / Surfaces::try_new_headless:
  boot on CPU-raster placeholders, no GL context
- render_shape_raster + render/raster.rs: CPU raster PNG export
- CanvasCtx: GPU-free context shared by the PDF and raster canvas paths
- get_fonts_for_shape + Shape/TextContent font enumeration
- clear_fonts; ImageStore::without_gpu
- _build_env: ENVIRONMENT=web,node so the module loads under Node
This commit is contained in:
Elena Torro 2026-06-04 17:30:19 +02:00
parent 8195f0f763
commit 95ab2ed54d
17 changed files with 688 additions and 36 deletions

View File

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

View File

@ -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<br/>concat centered_transform, save_layer for opacity/blur"]
v2["draw_drop_shadows (inline)"]
v3["render_leaf_content&lt;R: ShapeRenderer&gt;<br/>fills → fill inner shadows → strokes → stroke inner shadows"]
v4["one Skia PDF canvas<br/>final z = draw call order"]
v4["one Skia canvas (PDF doc or CPU bitmap)<br/>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` |

View File

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

View File

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

View File

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

View File

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

View File

@ -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<RenderState> {
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<RenderState> {
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,

View File

@ -61,7 +61,9 @@ enum StoredImage {
pub struct ImageStore {
images: HashMap<(Uuid, bool), StoredImage>,
context: Box<DirectContext>,
/// GPU context for decoding images to textures. `None` in headless mode,
/// where image fills are skipped (other shapes render normally).
context: Option<Box<DirectContext>>,
}
/// 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<Image> {
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<Image> {
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 {

View File

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

View File

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

View File

@ -86,6 +86,12 @@ pub struct DocAtlas {
pub tile_doc_rects: HashMap<Tile, skia::Rect>,
}
/// CPU raster surface (no GPU/WebGL), for the headless render state.
fn headless_surface(width: i32, height: i32) -> Result<skia::Surface> {
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<Self> {
// 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<Self> {
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<Self> {
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;
}

View File

@ -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,
}
// ---------------------------------------------------------------------------

View File

@ -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<FontFamily> {
match &self.shape_type {
Type::Text(content) => content.font_families(),
_ => Vec::new(),
}
}
#[allow(dead_code)]
pub fn mask_filter(&self, scale: f32) -> Option<skia::MaskFilter> {
self.blur

View File

@ -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<FontFamily> {
let mut seen: Vec<FontFamily> = 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);

View File

@ -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<u8>, 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<FontFamily> {
let Some(root) = self.shapes.get(id) else {
return Vec::new();
};
let mut result: Vec<FontFamily> = 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<FrameType> {
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;

View File

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

View File

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