Skip to main content

forme/
lib.rs

1//! # Forme
2//!
3//! A page-native PDF rendering engine.
4//!
5//! Most PDF renderers treat a document as an infinite vertical canvas and then
6//! slice it into pages after layout. This produces broken tables, orphaned
7//! headers, collapsed flex layouts on page boundaries, and years of GitHub
8//! issues begging for fixes.
9//!
10//! Forme does the opposite: **the page is the fundamental unit of layout.**
11//! Every layout decision—every flex calculation, every line break, every table
12//! row placement—is made with the page boundary as a hard constraint. Content
13//! doesn't get "sliced" after the fact. It flows *into* pages.
14//!
15//! ## Architecture
16//!
17//! ```text
18//! Input (JSON/API)
19//!       ↓
20//!   [model]    — Document tree: nodes, styles, content
21//!       ↓
22//!   [style]    — Resolve cascade, inheritance, defaults
23//!       ↓
24//!   [layout]   — Page-aware layout engine
25//!       ↓
26//!   [pdf]      — Serialize to PDF bytes
27//! ```
28
29pub mod barcode;
30pub mod chart;
31pub mod error;
32pub mod font;
33pub mod image_loader;
34pub mod layout;
35pub mod model;
36pub mod pdf;
37pub mod qrcode;
38pub mod style;
39pub mod svg;
40pub mod template;
41pub mod text;
42
43#[cfg(feature = "wasm")]
44pub mod wasm;
45
46#[cfg(feature = "wasm-raw")]
47pub mod wasm_raw;
48
49pub use error::FormeError;
50pub use layout::LayoutInfo;
51pub use model::{
52    CertificationConfig, ColumnDef, ColumnWidth, FontEntry, PatternType, RedactionPattern,
53    RedactionRegion, TextRun,
54};
55pub use model::{ChartDataPoint, ChartSeries, DotPlotGroup};
56pub use model::{Document, Metadata, Node, NodeKind, PageConfig, PageSize};
57pub use style::Style;
58
59use font::FontContext;
60use layout::LayoutEngine;
61use pdf::PdfWriter;
62
63/// Certify PDF bytes with an X.509 certificate.
64///
65/// Takes arbitrary PDF bytes and a certification configuration, and returns
66/// new PDF bytes with a valid digital signature. Uses incremental update
67/// to preserve the original PDF content.
68pub fn certify_pdf(
69    pdf_bytes: &[u8],
70    config: &model::CertificationConfig,
71) -> Result<Vec<u8>, FormeError> {
72    pdf::certify::certify_pdf(pdf_bytes, config)
73}
74
75/// Redact regions of a PDF by overlaying opaque rectangles.
76///
77/// Takes arbitrary PDF bytes and a list of redaction regions (page, x, y,
78/// width, height in top-origin coordinates). Returns new PDF bytes with
79/// the redaction rectangles drawn on top via incremental update.
80pub fn redact_pdf(
81    pdf_bytes: &[u8],
82    regions: &[model::RedactionRegion],
83) -> Result<Vec<u8>, FormeError> {
84    pdf::redaction::redact_pdf(pdf_bytes, regions)
85}
86
87/// Find text regions matching patterns in a PDF.
88///
89/// Searches PDF content streams for literal or regex patterns and returns
90/// redaction regions (in web top-origin coordinates) for each match.
91pub fn find_text_regions(
92    pdf_bytes: &[u8],
93    patterns: &[model::RedactionPattern],
94) -> Result<Vec<RedactionRegion>, FormeError> {
95    pdf::redaction::find_text_regions(pdf_bytes, patterns)
96}
97
98/// Redact text matching patterns from a PDF.
99///
100/// Convenience wrapper: finds text regions matching the patterns, then
101/// applies coordinate-based redaction to all matches.
102pub fn redact_text(
103    pdf_bytes: &[u8],
104    patterns: &[model::RedactionPattern],
105) -> Result<Vec<u8>, FormeError> {
106    pdf::redaction::redact_text(pdf_bytes, patterns)
107}
108
109/// Merge multiple PDFs into a single document.
110///
111/// Takes a slice of PDF byte slices and returns merged PDF bytes containing
112/// all pages in order. Requires at least 2 input PDFs.
113pub fn merge_pdfs(pdfs: &[&[u8]]) -> Result<Vec<u8>, FormeError> {
114    pdf::merge::merge_pdfs(pdfs)
115}
116
117/// Render a document to PDF bytes.
118///
119/// This is the primary entry point. Takes a document tree and returns
120/// the raw bytes of a valid PDF file. If the document has a `certification`
121/// configuration, the output PDF is digitally signed.
122pub fn render(document: &Document) -> Result<Vec<u8>, FormeError> {
123    render_with_warnings(document).map(|(pdf, _warnings)| pdf)
124}
125
126/// Lay out a document, running the page-number sentinel re-layout loop **only
127/// when the document actually places a `{{pageNumber}}`/`{{totalPages}}`
128/// sentinel**. Without one, the reserved sentinel width is never consumed, so a
129/// re-layout reproduces byte-identical pages — pure wasted work (measured as a
130/// 2x render cost above 100 pages, where the total-page digit count first
131/// crosses 2->3; see `benchmarks/`). The sentinel presence is detected at the
132/// exhaustive chokepoint — every sentinel glyph is measured in
133/// `FontContext::char_width`, from any source (HTML `counter()`, margin boxes,
134/// JSX literals). Returns the laid-out pages, the populated font context, and
135/// the number of full layout passes (1, or 2–3 when the width needed fixing).
136///
137/// SCOPING NOTE (investigated 2026-09, abandoned — see benchmarks/): re-laying
138/// out ONLY the running element on a digit-width change, instead of the whole
139/// document, looks like a clean ~2x win but is NOT scopable as written. The
140/// sentinel width is consumed during INJECTION — `inject_fixed_elements` lays
141/// out the footer/margin content — NOT during the flow pass; the flow-time
142/// `measure_node_height` height reservation doesn't touch it. And HTML `@page`
143/// margin boxes are mapped to Fixed nodes (see the `html` crate), so there is no
144/// purely out-of-flow page-number case. Consequently the guard signals (is there
145/// a sentinel? in flow or in a running element? does its height change?) don't
146/// exist yet after a flow-only pass, and a naive "reuse flow + re-inject" split
147/// silently SKIPS the digit-width correction entirely — a correctness regression
148/// that byte-identity caught. Any future attempt must derive those signals from
149/// injection or a document-model scan, not from the flow pass.
150fn layout_with_sentinel_passes(
151    document: &Document,
152) -> (
153    Vec<crate::layout::LayoutPage>,
154    FontContext,
155    u32,
156    Vec<String>,
157) {
158    layout_with_sentinel_passes_audited(document, false)
159}
160
161fn layout_with_sentinel_passes_audited(
162    document: &Document,
163    audit: bool,
164) -> (
165    Vec<crate::layout::LayoutPage>,
166    FontContext,
167    u32,
168    Vec<String>,
169) {
170    let mut font_context = FontContext::new();
171    register_document_fonts(&mut font_context, &document.fonts);
172    let engine = LayoutEngine::new();
173    font_context.reset_page_sentinel();
174    let mut pages = engine.layout(document, &font_context);
175    let mut passes = 1u32;
176
177    if font_context.saw_page_sentinel() {
178        for _ in 0..2 {
179            let needed = digits_for_count(pages.len());
180            if needed == font_context.sentinel_digit_count() {
181                break;
182            }
183            font_context.set_sentinel_digit_count(needed);
184            pages = engine.layout(document, &font_context);
185            passes += 1;
186        }
187    }
188    let mut layout_warnings = engine.take_warnings();
189    if audit {
190        crate::layout::audit::audit_content(document, &pages, &mut layout_warnings);
191    }
192    (pages, font_context, passes, layout_warnings)
193}
194
195/// Number of full layout passes a document needs (1 for the common case; 2–3
196/// only when a page-number sentinel's reserved width must be corrected).
197/// Exposed as the regression guard for the sentinel re-layout optimization.
198pub fn count_layout_passes(document: &Document) -> u32 {
199    layout_with_sentinel_passes(document).2
200}
201
202/// Render a document to PDF bytes plus any non-fatal warnings (e.g. pdfUa
203/// requested without an embeddable font registered). Same output as `render`;
204/// the warnings surface through the WASM bindings and the HTML wrapper.
205pub fn render_with_warnings(document: &Document) -> Result<(Vec<u8>, Vec<String>), FormeError> {
206    render_with_warnings_and_passes(document).map(|(pdf, warnings, _passes)| (pdf, warnings))
207}
208
209/// Opt-in per-render checks. `Default` disables everything, and every
210/// disabled check costs nothing — the flag is tested once per render.
211/// Deserializes from the camelCase JSON the JS wrappers send
212/// (`{"auditContent": true}`), unknown fields ignored so older engines
213/// tolerate newer wrappers.
214#[derive(Debug, Clone, Copy, Default, serde::Deserialize)]
215#[serde(default, rename_all = "camelCase")]
216pub struct RenderOptions {
217    /// Post-render content audit: after layout, verify the laid-out pages
218    /// against the input document and report content that was dropped,
219    /// rendered fully off-page, painted in its background's exact colour,
220    /// or clipped to a zero-size box — through the render-defect channel
221    /// (`render defect: ...` warnings), like every other case where the
222    /// engine's output differs from what the document asked for.
223    pub audit_content: bool,
224}
225
226/// Like [`render_with_warnings`], but also returns the number of layout passes
227/// the render took — surfaced through the HTML wrapper for benchmark evidence.
228pub fn render_with_warnings_and_passes(
229    document: &Document,
230) -> Result<(Vec<u8>, Vec<String>, u32), FormeError> {
231    render_with_options(document, RenderOptions::default())
232}
233
234/// Like [`render_with_warnings_and_passes`], with opt-in [`RenderOptions`].
235pub fn render_with_options(
236    document: &Document,
237    options: RenderOptions,
238) -> Result<(Vec<u8>, Vec<String>, u32), FormeError> {
239    // Coarse phase profiling behind FORME_PROFILE (native only in practice —
240    // `env::var` is Err under wasm, so the timer is never constructed there and
241    // `Instant::now` is never called). Prints layout vs serialize to stderr.
242    let profile = std::env::var("FORME_PROFILE").is_ok();
243    let t_layout = if profile {
244        Some(std::time::Instant::now())
245    } else {
246        None
247    };
248    let (pages, font_context, passes, layout_warnings) =
249        layout_with_sentinel_passes_audited(document, options.audit_content);
250    let layout_ms = t_layout.map(|t| t.elapsed().as_secs_f64() * 1000.0);
251
252    let writer = PdfWriter::new();
253    let tagged = document.tagged
254        || document.pdf_ua
255        || matches!(
256            document.pdfa,
257            Some(model::PdfAConformance::A2a) | Some(model::PdfAConformance::A3a)
258        );
259    let t_ser = if profile {
260        Some(std::time::Instant::now())
261    } else {
262        None
263    };
264    let (pdf, warnings) = writer.write(
265        &pages,
266        &document.metadata,
267        &font_context,
268        tagged,
269        document.pdfa.as_ref(),
270        document.pdf_ua,
271        document.embedded_data.as_deref(),
272        &document.attachments,
273        document.zugferd.as_ref(),
274        document.flatten_forms,
275    )?;
276    let warnings = {
277        let mut all = layout_warnings;
278        all.extend(warnings);
279        all
280    };
281    let serialize_ms = t_ser.map(|t| t.elapsed().as_secs_f64() * 1000.0);
282    let pdf = if let Some(ref sig_config) = document.certification {
283        pdf::certify::certify_pdf(&pdf, sig_config)?
284    } else {
285        pdf
286    };
287    if let (Some(l), Some(s)) = (layout_ms, serialize_ms) {
288        eprintln!(
289            "FORME_PROFILE pages={} passes={passes} layout_ms={l:.1} serialize_ms={s:.1}",
290            pages.len()
291        );
292    }
293    Ok((pdf, warnings, passes))
294}
295
296/// Render a document to PDF bytes along with layout metadata.
297///
298/// Same as `render()` but also returns `LayoutInfo` describing the
299/// position and dimensions of every element on every page.
300/// If the document has a `certification` configuration, the output PDF
301/// is digitally signed.
302pub fn render_with_layout(
303    document: &Document,
304) -> Result<(Vec<u8>, LayoutInfo, Vec<String>), FormeError> {
305    render_with_layout_and_passes(document)
306        .map(|(pdf, layout, warnings, _passes)| (pdf, layout, warnings))
307}
308
309/// Like [`render_with_layout`], but also returns the number of layout passes
310/// the render took (1 for the common case; 2-3 when a page-number sentinel's
311/// reserved width needed correction). The plain variant discarded this value,
312/// which is how `renderHtmlWithLayout` shipped without `passes` on every
313/// target while its declared type promised one.
314pub fn render_with_layout_and_passes(
315    document: &Document,
316) -> Result<(Vec<u8>, LayoutInfo, Vec<String>, u32), FormeError> {
317    render_with_layout_and_options(document, RenderOptions::default())
318}
319
320/// Like [`render_with_layout_and_passes`], with opt-in [`RenderOptions`].
321pub fn render_with_layout_and_options(
322    document: &Document,
323    options: RenderOptions,
324) -> Result<(Vec<u8>, LayoutInfo, Vec<String>, u32), FormeError> {
325    let (pages, font_context, passes, layout_warnings) =
326        layout_with_sentinel_passes_audited(document, options.audit_content);
327    let layout_info = LayoutInfo::from_pages(&pages);
328    let writer = PdfWriter::new();
329    let tagged = document.tagged
330        || document.pdf_ua
331        || matches!(
332            document.pdfa,
333            Some(model::PdfAConformance::A2a) | Some(model::PdfAConformance::A3a)
334        );
335    let (pdf, warnings) = writer.write(
336        &pages,
337        &document.metadata,
338        &font_context,
339        tagged,
340        document.pdfa.as_ref(),
341        document.pdf_ua,
342        document.embedded_data.as_deref(),
343        &document.attachments,
344        document.zugferd.as_ref(),
345        document.flatten_forms,
346    )?;
347    let pdf = if let Some(ref sig_config) = document.certification {
348        pdf::certify::certify_pdf(&pdf, sig_config)?
349    } else {
350        pdf
351    };
352    let warnings = {
353        let mut all = layout_warnings;
354        all.extend(warnings);
355        all
356    };
357    Ok((pdf, layout_info, warnings, passes))
358}
359
360/// Return the number of digits needed to display `n` as a decimal string.
361fn digits_for_count(n: usize) -> u32 {
362    if n < 10 {
363        1
364    } else if n < 100 {
365        2
366    } else if n < 1000 {
367        3
368    } else {
369        4
370    }
371}
372
373/// Register custom fonts from the document's `fonts` array.
374fn register_document_fonts(font_context: &mut FontContext, fonts: &[FontEntry]) {
375    use base64::Engine as _;
376    let b64 = base64::engine::general_purpose::STANDARD;
377
378    for entry in fonts {
379        let bytes = if let Some(comma_pos) = entry.src.find(',') {
380            // data URI: "data:font/ttf;base64,AAAA..."
381            b64.decode(&entry.src[comma_pos + 1..]).ok()
382        } else {
383            // raw base64 string
384            b64.decode(&entry.src).ok()
385        };
386
387        if let Some(data) = bytes {
388            font_context
389                .registry_mut()
390                .register(&entry.family, entry.weight, entry.italic, data);
391        }
392    }
393}
394
395/// Render a document described as JSON to PDF bytes.
396pub fn render_json(json: &str) -> Result<Vec<u8>, FormeError> {
397    let document: Document = serde_json::from_str(json)?;
398    render(&document)
399}
400
401/// Render a document described as JSON to PDF bytes along with layout metadata.
402pub fn render_json_with_layout(
403    json: &str,
404) -> Result<(Vec<u8>, LayoutInfo, Vec<String>), FormeError> {
405    let document: Document = serde_json::from_str(json)?;
406    render_with_layout(&document)
407}
408
409/// Like [`render_json_with_layout`], with opt-in [`RenderOptions`] — the
410/// JSON-input mirror of [`render_with_layout_and_options`], used by the
411/// WASM bindings so `@formepdf/core` callers can opt into the content
412/// audit the way the HTML path already can.
413pub fn render_json_with_layout_and_options(
414    json: &str,
415    options: RenderOptions,
416) -> Result<(Vec<u8>, LayoutInfo, Vec<String>, u32), FormeError> {
417    let document: Document = serde_json::from_str(json)?;
418    render_with_layout_and_options(&document, options)
419}
420
421/// Render a template with data to PDF bytes.
422///
423/// Takes a template JSON tree (with `$ref`, `$each`, `$if`, operators) and
424/// a data JSON object. Evaluates all expressions, then renders the resulting
425/// document to PDF.
426pub fn render_template(template_json: &str, data_json: &str) -> Result<Vec<u8>, FormeError> {
427    let template: serde_json::Value = serde_json::from_str(template_json)?;
428    let data: serde_json::Value = serde_json::from_str(data_json)?;
429    let resolved = template::evaluate_template(&template, &data)?;
430    let document: Document = serde_json::from_value(resolved)?;
431    render(&document)
432}
433
434/// Render a template with data to PDF bytes along with layout metadata.
435pub fn render_template_with_layout(
436    template_json: &str,
437    data_json: &str,
438) -> Result<(Vec<u8>, LayoutInfo), FormeError> {
439    let template: serde_json::Value = serde_json::from_str(template_json)?;
440    let data: serde_json::Value = serde_json::from_str(data_json)?;
441    let resolved = template::evaluate_template(&template, &data)?;
442    let document: Document = serde_json::from_value(resolved)?;
443    render_with_layout(&document).map(|(pdf, layout, _warnings)| (pdf, layout))
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn test_digits_for_count() {
452        assert_eq!(digits_for_count(0), 1);
453        assert_eq!(digits_for_count(1), 1);
454        assert_eq!(digits_for_count(9), 1);
455        assert_eq!(digits_for_count(10), 2);
456        assert_eq!(digits_for_count(99), 2);
457        assert_eq!(digits_for_count(100), 3);
458        assert_eq!(digits_for_count(999), 3);
459        assert_eq!(digits_for_count(1000), 4);
460    }
461}