Skip to main content

excalidraw_render/
lib.rs

1//! Renders [Excalidraw](https://excalidraw.com) scenes to SVG, PNG and PDF,
2//! matching Excalidraw 0.18.1's exporter as closely as possible.
3//!
4//! It is pure Rust with every font bundled: rendering needs no browser,
5//! Node.js, network access or installed fonts. Export a whole scene or each
6//! frame on its own. Every render also returns a compatibility report that
7//! lists what the renderer substituted, omitted or approximated, so
8//! differences from Excalidraw are reported rather than silent.
9//!
10//! This release reproduces Excalidraw 0.18.1. In 0.x, any release may change
11//! output and diagnostics; pin the renderer version and dependency lockfile
12//! for reproducible output.
13//! Linux x86-64 GNU is release-qualified; browser WASM is experimental.
14//!
15//! Throughout this documentation, *reference* means Excalidraw's own exporter
16//! at the pinned release, v0.18.1: "reference frame-export semantics" and
17//! "reference dark-mode colors" describe what Excalidraw itself produces. An
18//! *admitted* scene is one [`SceneDocument::parse`] accepted.
19//!
20//! # Quick start
21//!
22//! Admit the scene once with [`SceneDocument::parse`], describe one output
23//! with a [`RenderRequest`], and render it with a [`Renderer`]. A successful
24//! render returns the complete [`Artifact`] together with a
25//! [`CompatibilityReport`] listing every repair, substitution or omission the
26//! renderer made to keep the artifact meaningful; an empty report means none
27//! were needed.
28//!
29//! ```
30//! use excalidraw_render::{
31//!     FrameRenderRequest, OutputFormat, RenderRequest, Renderer, SceneDocument, Sizing,
32//! };
33//!
34//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
35//! // The contents of an `.excalidraw` file.
36//! let bytes = br##"{
37//!     "type": "excalidraw",
38//!     "version": 2,
39//!     "elements": [
40//!         {"id": "overview", "type": "frame", "name": "Overview",
41//!          "x": 0, "y": 0, "width": 200, "height": 140},
42//!         {"id": "box", "type": "rectangle", "frameId": "overview",
43//!          "x": 40, "y": 30, "width": 120, "height": 80}
44//!     ],
45//!     "appState": {"viewBackgroundColor": "#ffffff"},
46//!     "files": {}
47//! }"##;
48//! let scene = SceneDocument::parse(bytes)?;
49//! let renderer = Renderer::new();
50//!
51//! // Export the whole scene as SVG with the reference export defaults.
52//! let request = RenderRequest::builder(OutputFormat::Svg).build()?;
53//! let outcome = renderer.render(&scene, &request)?;
54//! for diagnostic in outcome.report().diagnostics() {
55//!     eprintln!(
56//!         "{} {} at {}: {}",
57//!         diagnostic.severity(),
58//!         diagnostic.code(),
59//!         diagnostic.json_pointer(),
60//!         diagnostic.message()
61//!     );
62//! }
63//! let svg = outcome.artifact().bytes();
64//! assert!(svg.starts_with(b"<svg"));
65//!
66//! // Export every frame as a PNG no larger than 1024 pixels on either side.
67//! # #[cfg(feature = "png")] {
68//! let request = FrameRenderRequest::builder(OutputFormat::Png)
69//!     .sizing(Sizing::MaxDimension(1024.0))
70//!     .build()?;
71//! for frame in scene.frames() {
72//!     let outcome = renderer.render_frame(frame, &request)?;
73//!     let png = outcome.artifact().bytes();
74//!     println!("{}: {} bytes", frame.name().unwrap_or("unnamed"), png.len());
75//!     assert!(png.starts_with(b"\x89PNG"));
76//! }
77//! # }
78//! # Ok(())
79//! # }
80//! ```
81//!
82//! Each failure type ([`SceneParseError`], [`RequestError`], [`RenderError`],
83//! [`FrameBatchError`]) carries a stable [`ErrorCode`], and `descriptor()` gives
84//! its serializable form; the first three also carry typed details.
85//! [`Renderer::render_frames`] renders a frame batch that shares scene
86//! preparation across frames.
87//!
88//! # Serving untrusted input
89//!
90//! The renderer is built to render scene documents from untrusted sources, but
91//! a service still has to decide how much CPU time and memory one request may
92//! take.
93//!
94//! - **Share and move freely.** [`Renderer`] and [`SceneDocument`] are
95//!   immutable, `Clone`, `Send` and `Sync`; one renderer can serve every
96//!   request concurrently, and an admitted scene can be rendered to several
97//!   formats from different threads.
98//! - **Rendering is synchronous and CPU-bound.** In an async server, run it on
99//!   a blocking pool (for example `tokio::task::spawn_blocking`) rather than on
100//!   the executor's worker threads.
101//! - **There is no deadline or cancellation.** Every render terminates, because
102//!   all work is bounded by [`ResourceLimits`], but a render cannot be
103//!   interrupted once started. When you need a hard wall-clock deadline, render
104//!   in a separate process you can kill.
105//! - **Bound raster sizes from the request.** For PNG and PDF, prefer
106//!   [`Sizing::MaxDimension`] over a caller-chosen [`Sizing::Scale`] so a
107//!   large scene cannot request an enormous PNG or page; then lower
108//!   `max_raster_dimension` and `max_raster_surface_pixels` to what you are
109//!   willing to allocate. SVG ignores `MaxDimension` and keeps the scene's
110//!   logical size, which `max_svg_dimension` bounds instead.
111//! - **`max_working_bytes` is not a memory cap.** It budgets the renderer's
112//!   deterministic, logical working set so that the same input fails the same
113//!   way on every machine; it does not measure or limit allocator usage.
114//!   Actual peak memory is higher, dominated by raster surfaces: PNG
115//!   rasterizes a 4× supersampled canvas, about 65 bytes per output pixel, so
116//!   the default `max_raster_surface_pixels` (33,554,432) admits renders that
117//!   need over 2 GiB. A ceiling of 4,194,304 pixels (2048 × 2048) keeps one
118//!   render near 300 MB. Size worker memory (or a container limit) for your
119//!   raster ceilings and concurrency.
120//! - **Parsing has its own ceilings.** [`SceneDocument::parse`] accepts up to
121//!   64 MiB of JSON whatever `max_scene_bytes` says; that limit is checked
122//!   when rendering. Bound request bodies before parsing for a lower ceiling.
123//! - **Limits apply per render.** [`Renderer::render_frames`] renders every
124//!   selected frame within its own limits, so bound the frame count of an
125//!   untrusted batch.
126//! - **Build with optimizations.** Unoptimized builds are roughly 50 times
127//!   slower, which turns ordinary scenes into multi-second renders and makes
128//!   resource limits much easier to exhaust in wall-clock terms. Most of the
129//!   time goes to dependencies (rasterization, compression, font decoding), so
130//!   in a debug build of your service optimize every dependency:
131//!
132//!   ```toml
133//!   [profile.dev.package."*"]
134//!   opt-level = 2
135//!   ```
136//!
137//! # Cargo features
138//!
139//! All three features are on by default. The API is the same with any
140//! combination of them, so Cargo's feature unification can never change what
141//! compiles; a feature only decides which requests a build can serve, and
142//! turning one off never changes the bytes of the output that remains.
143//!
144//! - **`png`** renders [`OutputFormat::Png`]. It brings the `resvg`
145//!   rasterizer and `tiny-skia`.
146//! - **`pdf`** renders [`OutputFormat::Pdf`]. It brings the Krilla PDF writer,
147//!   and the rasterizer for the isolated raster fallbacks of embedded SVG
148//!   effects.
149//! - **`cjk`** bundles Xiaolai, Excalifont's Chinese, Japanese and Korean
150//!   fallback font (about 12 MiB). Without it, a render whose text needs
151//!   Xiaolai fails with [`ErrorCode::RENDER_FONT_UNAVAILABLE`]
152//!   (`render.font-unavailable`, a [`RenderErrorCategory::Capability`]
153//!   failure) rather than substituting another font. Every other font,
154//!   including Noto Emoji, is always bundled.
155//!
156//! SVG output is always available. Without `png` or `pdf`, building a
157//! [`RenderRequest`] or [`FrameRenderRequest`] for that format fails with
158//! [`ErrorCode::REQUEST_FORMAT_UNAVAILABLE`] (`request.format-unavailable`),
159//! whose message names the feature to enable. An SVG-only build, for example
160//! for WebAssembly, depends on the renderer like this:
161//!
162//! ```toml
163//! [dependencies]
164//! excalidraw-render = { version = "0.1", default-features = false, features = ["cjk"] }
165//! ```
166
167#![warn(missing_docs)]
168#![warn(clippy::missing_errors_doc)]
169
170mod catalog;
171#[cfg(test)]
172mod cms_failure_contract;
173#[cfg(test)]
174#[path = "../tests/common/codec_fixtures.rs"]
175mod codec_fixtures;
176mod color;
177mod compatibility;
178mod css_syntax;
179mod diagnostic_registry;
180mod font_metadata;
181mod fonts;
182mod frame;
183#[cfg(test)]
184#[path = "../tests/common/fuzz_seed.rs"]
185mod fuzz_seed;
186#[cfg(test)]
187mod metadata_failure_contract;
188mod normalization;
189mod outcome;
190#[cfg(test)]
191#[allow(dead_code)]
192#[path = "../tests/common/png_color_reference.rs"]
193mod png_color_reference;
194#[cfg(test)]
195#[allow(dead_code)]
196#[path = "../tests/common/png_icc_safety.rs"]
197mod png_icc_safety;
198#[cfg(test)]
199mod png_projection_contract;
200#[cfg(test)]
201#[allow(dead_code)]
202#[path = "../tests/common/png_stage4.rs"]
203mod png_stage4;
204#[cfg(all(test, target_arch = "wasm32"))]
205#[path = "../tests/common/png_stage4_composition.rs"]
206mod png_stage4_composition;
207#[cfg(test)]
208#[allow(dead_code)]
209#[path = "../tests/common/png_stage4_reference.rs"]
210mod png_stage4_reference;
211#[cfg(test)]
212#[allow(dead_code)]
213#[path = "../tests/common/png_stage4_review.rs"]
214mod png_stage4_review;
215mod portable_math;
216mod relationships;
217mod render;
218mod request;
219mod resource_accounting;
220mod resource_limits;
221mod scene;
222#[cfg(test)]
223mod scene_fuzz;
224mod scene_surface;
225mod shaping;
226#[cfg(test)]
227mod stage4_diagnostic_contract;
228
229/// Compiles the crate README's examples, so the crates.io page stays in step
230/// with the API.
231#[cfg(doctest)]
232#[doc = include_str!("../README.md")]
233struct ReadmeDoctests;
234
235pub use compatibility::{
236    AssetChainEntry, DiagnosticDetails, ErrorCode, ErrorDescriptor, ErrorFamily, FiniteValue,
237    LogicalBounds, Measurement, MetadataComponent, MetadataOperation, NestedPath,
238    NestedPathSegment, NestedSegmentKind, Recovery, Renderer, ResourceMeasurement, ResourceValue,
239    SvgFontSubsetStage, ValueKind,
240};
241#[doc(hidden)]
242pub use compatibility::{CompatibilityRevision, ReferenceProfileIdentity};
243pub use frame::{
244    FrameBatchError, FrameBatchErrorKind, FrameKind, FrameRef, FrameRefs, FrameRenderCursor,
245    FrameRenderEntry,
246};
247pub use outcome::{
248    Artifact, CompatibilityDiagnostic, CompatibilityReport, DiagnosticCode, DiagnosticEffect,
249    DiagnosticSeverity, RenderError, RenderErrorCategory, RenderOutcome,
250};
251pub use request::{
252    ColorMode, FrameOptions, FrameRenderRequest, FrameRenderRequestBuilder, MetadataOptions,
253    OutputFormat, PdfOptions, PdfRasterFallback, RenderRequest, RenderRequestBuilder, RequestError,
254    Sizing, SupplementalAsset, SupplementalAssets, SupplementalAssetsBuilder,
255};
256pub use resource_limits::{ResourceLimits, ResourceLimitsBuilder};
257pub use scene::{SceneDocument, SceneParseError};
258
259#[cfg(all(test, target_arch = "wasm32"))]
260pub mod wasm_tests {
261    use wasm_bindgen_test::wasm_bindgen_test;
262
263    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker);
264
265    #[wasm_bindgen_test]
266    fn png_sdr_color_reference_in_wasm() {
267        png_color_cases_in_wasm(crate::png_color_reference::evidence());
268    }
269
270    #[wasm_bindgen_test]
271    fn png_gain_map_reference_in_wasm() {
272        png_color_cases_in_wasm(crate::png_color_reference::gain_map_evidence());
273    }
274
275    #[wasm_bindgen_test]
276    fn png_stage4_icc_reference_in_wasm() {
277        png_color_cases_in_wasm(crate::png_stage4_reference::evidence());
278    }
279    #[wasm_bindgen_test]
280    fn cms_allocation_failures_in_wasm() {
281        crate::cms_failure_contract::cms_out_of_memory_is_fatal_at_every_operation_boundary();
282    }
283    #[wasm_bindgen_test]
284    fn png_stage4_profile_composition_in_wasm() {
285        png_color_cases_in_wasm(crate::png_stage4_composition::evidence());
286    }
287    #[wasm_bindgen_test]
288    fn png_stage4_review_regressions_in_wasm() {
289        png_stage4_bytes(crate::png_icc_safety::headers());
290        png_color_cases_in_wasm(crate::png_icc_safety::sampled_evidence());
291        png_stage4_bytes(crate::png_stage4_review::adam7());
292        png_stage4_bytes(crate::png_stage4_review::icc_extents());
293        png_stage4_bytes(crate::png_stage4_review::lower_keys());
294        png_color_cases_in_wasm(crate::png_stage4_review::gray_lut_equivalence());
295    }
296    #[wasm_bindgen_test]
297    fn png_profile_projection_limits_in_wasm() {
298        crate::png_projection_contract::profile_limits(Some(0));
299    }
300    #[wasm_bindgen_test]
301    fn png_profile_rounded_limits_in_wasm() {
302        crate::png_projection_contract::profile_limits(Some(1));
303    }
304    #[wasm_bindgen_test]
305    fn png_profile_frame_mask_limits_in_wasm() {
306        crate::png_projection_contract::profile_limits(Some(2));
307    }
308
309    #[wasm_bindgen_test]
310    fn png_stage4_byte_contracts_in_wasm() {
311        use crate::png_stage4 as v;
312        for rows in [
313            v::orientations(),
314            v::invalid_orientation(),
315            v::chunk_cases(),
316            v::scanline_cases(),
317            v::palette_cases(),
318            v::apng_controls(),
319            v::apng_malformed(),
320            v::invalid_cicp(),
321            v::large_streams(),
322            v::composition(&[(*b"gAMA", 100000_u32.to_be_bytes().to_vec())], true),
323            v::hdr_composition(16),
324            v::hdr_composition(18),
325        ] {
326            png_stage4_bytes(rows);
327        }
328    }
329
330    #[wasm_bindgen_test]
331    fn png_stage4_ihdr_contracts_in_wasm() {
332        png_stage4_bytes(crate::png_stage4::ihdr_cases());
333    }
334
335    fn png_stage4_bytes(rows: Vec<crate::png_stage4::Case>) {
336        use crate::png_stage4::{self as v, Outcome};
337        for row in rows {
338            let source = v::scene(&row);
339            let scene = crate::SceneDocument::parse(&serde_json::to_vec(&source).unwrap()).unwrap();
340            let request = crate::RenderRequest::builder(crate::OutputFormat::Png)
341                .padding(0.0)
342                .include_background(false)
343                .build()
344                .unwrap();
345            let first = crate::Renderer::new().render(&scene, &request).unwrap();
346            let second = crate::Renderer::new().render(&scene, &request).unwrap();
347            assert_eq!(first.artifact().bytes(), second.artifact().bytes());
348            assert_eq!(first.report(), second.report());
349            v::report(&row, "png", serde_json::to_value(first.report()).unwrap());
350            if matches!(row.outcome, Outcome::Accepted(_)) {
351                let actual = crate::png_color_reference::png(first.artifact().bytes());
352                assert_eq!(
353                    (actual.width() as usize, actual.height() as usize),
354                    (row.width, row.height)
355                );
356                assert_eq!(actual.rgba(), row.pixels, "{}", row.id);
357            } else {
358                let mut source = source;
359                source["files"] = serde_json::json!({});
360                let control =
361                    crate::SceneDocument::parse(&serde_json::to_vec(&source).unwrap()).unwrap();
362                assert_eq!(
363                    first.artifact().bytes(),
364                    crate::Renderer::new()
365                        .render(&control, &request)
366                        .unwrap()
367                        .artifact()
368                        .bytes()
369                );
370            }
371        }
372    }
373
374    fn png_color_cases_in_wasm(evidence: serde_json::Value) {
375        for row in evidence["cases"].as_array().unwrap() {
376            let scene =
377                crate::SceneDocument::parse(&serde_json::to_vec(&row["scene"]).unwrap()).unwrap();
378            let request = crate::RenderRequest::builder(crate::OutputFormat::Png)
379                .padding(0.0)
380                .include_background(false)
381                .build()
382                .unwrap();
383            let first = crate::Renderer::new().render(&scene, &request).unwrap();
384            let second = crate::Renderer::new().render(&scene, &request).unwrap();
385            assert_eq!(first.artifact().bytes(), second.artifact().bytes());
386            assert_eq!(
387                serde_json::to_vec(first.report()).unwrap(),
388                serde_json::to_vec(second.report()).unwrap()
389            );
390            if row["group"] == "composition" {
391                let late = crate::SceneDocument::parse(
392                    &serde_json::to_vec(&crate::png_stage4_review::late_scene(row)).unwrap(),
393                )
394                .unwrap();
395                let late = crate::Renderer::new().render(&late, &request).unwrap();
396                assert_eq!(first.artifact().bytes(), late.artifact().bytes());
397                assert_eq!(first.report(), late.report());
398            }
399            if row["group"] == "composition" {
400                crate::png_stage4_composition::report(
401                    row,
402                    "png",
403                    serde_json::to_value(first.report()).unwrap(),
404                );
405            } else {
406                crate::png_color_reference::report(
407                    row,
408                    "png",
409                    serde_json::to_value(first.report()).unwrap(),
410                );
411            }
412            if let Some(equivalent) = crate::png_color_reference::bt1886_equivalent(&evidence, row)
413                .or_else(|| crate::png_color_reference::ignored_cicp_equivalent(&evidence, row))
414                .or_else(|| crate::png_color_reference::gain_map_equivalent(&evidence, row))
415            {
416                let scene =
417                    crate::SceneDocument::parse(&serde_json::to_vec(&equivalent["scene"]).unwrap())
418                        .unwrap();
419                let control = crate::Renderer::new().render(&scene, &request).unwrap();
420                assert_eq!(
421                    first.artifact().bytes(),
422                    control.artifact().bytes(),
423                    "{} exact metadata equivalence",
424                    row["id"]
425                );
426                assert_eq!(first.report(), control.report());
427            }
428            if crate::png_color_reference::recovery(row, "png").is_some() {
429                let mut control = row["scene"].clone();
430                control["files"] = serde_json::json!({});
431                let control =
432                    crate::SceneDocument::parse(&serde_json::to_vec(&control).unwrap()).unwrap();
433                assert_eq!(
434                    first.artifact().bytes(),
435                    crate::Renderer::new()
436                        .render(&control, &request)
437                        .unwrap()
438                        .artifact()
439                        .bytes()
440                );
441            } else {
442                crate::png_stage4_reference::anchor(
443                    row,
444                    crate::png_color_reference::png(first.artifact().bytes()).rgba(),
445                );
446                crate::png_color_reference::appearance(
447                    row,
448                    crate::png_color_reference::png(first.artifact().bytes()),
449                    false,
450                );
451            }
452        }
453    }
454
455    #[wasm_bindgen_test]
456    fn png_precision_reference_in_wasm() {
457        use base64::Engine as _;
458        use sha2::{Digest, Sha256};
459        let bytes = include_bytes!("../../../tests/oracle/png-precision/reference.json");
460        let evidence: serde_json::Value = serde_json::from_slice(bytes).unwrap();
461        let decode_png = |bytes: &[u8]| {
462            let mut reader = png::Decoder::new(std::io::Cursor::new(bytes))
463                .read_info()
464                .unwrap();
465            let mut rgba = vec![0; reader.output_buffer_size().expect("PNG buffer fits")];
466            let info = reader.next_frame(&mut rgba).unwrap();
467            assert_eq!(info.color_type, png::ColorType::Rgba);
468            assert_eq!(info.bit_depth, png::BitDepth::Eight);
469            rgba.truncate(info.buffer_size());
470            (info.width, info.height, rgba)
471        };
472        for row in evidence["cases"].as_array().unwrap() {
473            let scene =
474                crate::SceneDocument::parse(&serde_json::to_vec(&row["scene"]).unwrap()).unwrap();
475            let request = crate::RenderRequest::builder(crate::OutputFormat::Png)
476                .padding(0.0)
477                .include_background(row["options"]["exportBackground"].as_bool().unwrap())
478                .build()
479                .unwrap();
480            let first = crate::Renderer::new().render(&scene, &request).unwrap();
481            let second = crate::Renderer::new().render(&scene, &request).unwrap();
482            assert_eq!(first.artifact().bytes(), second.artifact().bytes());
483            assert_eq!(first.report(), second.report());
484            assert_eq!(
485                serde_json::to_value(first.report()).unwrap(),
486                serde_json::json!({"schema_version":1,"diagnostics":[]})
487            );
488            let expected = base64::engine::general_purpose::STANDARD
489                .decode(row["png_base64"].as_str().unwrap())
490                .unwrap();
491            assert_eq!(
492                decode_png(first.artifact().bytes()),
493                decode_png(&expected),
494                "{}",
495                row["id"]
496            );
497        }
498    }
499
500    #[wasm_bindgen_test]
501    fn complete_font_subset_qualification_corpus_in_wasm() {
502        crate::render::assert_complete_font_subset_qualification_corpus();
503    }
504
505    #[wasm_bindgen_test]
506    fn font_subset_fuzz_regression_corpus_in_wasm() {
507        crate::render::assert_font_subset_fuzz_regression_corpus();
508    }
509
510    #[wasm_bindgen_test]
511    fn font_subset_fuzz_fixed_seed_smoke_in_wasm() {
512        crate::render::assert_font_subset_fuzz_smoke();
513    }
514
515    #[wasm_bindgen_test]
516    fn font_subset_resource_trace_matrix_in_wasm() {
517        crate::render::assert_font_subset_resource_trace_matrix();
518    }
519
520    #[wasm_bindgen_test]
521    fn scene_fuzz_regression_corpus_in_wasm() {
522        crate::scene_fuzz::assert_scene_fuzz_regression_corpus();
523    }
524
525    #[wasm_bindgen_test]
526    fn scene_fuzz_fixed_seed_smoke_in_wasm() {
527        crate::scene_fuzz::assert_scene_fuzz_smoke();
528    }
529}
530
531/// Returns the complete package-wide license and attribution bundle.
532pub const fn license_bundle() -> &'static str {
533    include_str!("../LICENSE-BUNDLE.txt")
534}