mirror of
https://github.com/penpot/penpot.git
synced 2026-06-16 04:12:03 +00:00
✨ 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:
parent
8195f0f763
commit
95ab2ed54d
@ -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 \
|
||||
|
||||
@ -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<R: ShapeRenderer><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` |
|
||||
|
||||
@ -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;
|
||||
|
||||
63
render-wasm/src/globals_tests.rs
Normal file
63
render-wasm/src/globals_tests.rs
Normal 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");
|
||||
}
|
||||
81
render-wasm/src/js/render-headless.mjs
Normal file
81
render-wasm/src/js/render-headless.mjs
Normal 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);
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
57
render-wasm/src/render/raster.rs
Normal file
57
render-wasm/src/render/raster.rs
Normal 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;
|
||||
52
render-wasm/src/render/raster_tests.rs
Normal file
52
render-wasm/src/render/raster_tests.rs
Normal 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));
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
79
render-wasm/src/state_tests.rs
Normal file
79
render-wasm/src/state_tests.rs
Normal 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");
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user