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`].
235/// PDF 2.0 contradicts every ISO 32000-1 (PDF 1.7) based conformance
236/// claim: PDF/A-2 and A-3 are defined over 1.7, as is PDF/UA-1. Error by
237/// name rather than emit a file whose header falsifies its own claim.
238/// PDF/A-4 is defined over ISO 32000-2: claiming it IMPLIES PDF 2.0
239/// output, whatever pdfVersion says (the serde default cannot be told
240/// apart from an explicit "1.7", so the claim wins — the alternative is
241/// a file whose header falsifies its own conformance).
242fn effective_pdf_version(document: &Document) -> crate::model::PdfVersion {
243    use crate::model::{PdfAConformance, PdfVersion};
244    if document.pdf_ua2 {
245        return PdfVersion::V2_0;
246    }
247    match &document.pdfa {
248        Some(PdfAConformance::A4 | PdfAConformance::A4f) => PdfVersion::V2_0,
249        _ => document.pdf_version,
250    }
251}
252
253fn validate_pdf_version(document: &Document) -> Result<(), FormeError> {
254    use crate::model::{PdfAConformance, PdfVersion};
255    if document.pdf_version != PdfVersion::V2_0 {
256        return Ok(());
257    }
258    if let Some(level) = &document.pdfa {
259        let family = match level {
260            PdfAConformance::A2a | PdfAConformance::A2b | PdfAConformance::A2u => "PDF/A-2",
261            PdfAConformance::A3a | PdfAConformance::A3b | PdfAConformance::A3u => "PDF/A-3",
262            // A-4 is the 2.0-based standard: no contradiction — it
263            // IMPLIES pdfVersion 2.0 (applied at render time below).
264            PdfAConformance::A4 | PdfAConformance::A4f => return Ok(()),
265        };
266        return Err(FormeError::RenderError(format!(
267            "pdfVersion \"2.0\" contradicts pdfa: {family} is defined over ISO 32000-1              (PDF 1.7). Drop the pdfa claim, or keep pdfVersion \"1.7\" (the default).              PDF/A-4 is the 2.0-based archival standard."
268        )));
269    }
270    if document.pdf_ua {
271        return Err(FormeError::RenderError(
272            "pdfVersion \"2.0\" contradicts pdfUa: PDF/UA-1 is defined over ISO 32000-1              (PDF 1.7). Drop the pdfUa claim, or keep pdfVersion \"1.7\" (the default).              PDF/UA-2 is the 2.0-based accessibility standard (pdfUa2)."
273                .to_string(),
274        ));
275    }
276    Ok(())
277}
278
279/// PDF/UA-2 (ISO 14289-2:2024) is defined over PDF 2.0: it implies the
280/// 2.0 output version and tagging, contradicts UA-1 and the 1.7 pdfa
281/// levels, and composes with pdfa "4"/"4f".
282fn validate_pdf_ua2(document: &Document) -> Result<(), FormeError> {
283    use crate::model::PdfAConformance;
284    if !document.pdf_ua2 {
285        return Ok(());
286    }
287    if document.pdf_ua {
288        return Err(FormeError::RenderError(
289            "pdfUa2 contradicts pdfUa: a file claims PDF/UA-1 (ISO 32000-1) or PDF/UA-2 \
290             (PDF 2.0), not both. Keep exactly one."
291                .to_string(),
292        ));
293    }
294    if let Some(
295        PdfAConformance::A2a
296        | PdfAConformance::A2b
297        | PdfAConformance::A2u
298        | PdfAConformance::A3a
299        | PdfAConformance::A3b
300        | PdfAConformance::A3u,
301    ) = document.pdfa
302    {
303        return Err(FormeError::RenderError(
304            "pdfUa2 contradicts a 1.7-based pdfa level (2x/3x are ISO 32000-1). \
305             Compose PDF/UA-2 with pdfa: \"4\" or \"4f\", or drop one claim."
306                .to_string(),
307        ));
308    }
309    Ok(())
310}
311
312pub fn render_with_options(
313    document: &Document,
314    options: RenderOptions,
315) -> Result<(Vec<u8>, Vec<String>, u32), FormeError> {
316    validate_pdf_version(document)?;
317    validate_pdf_ua2(document)?;
318    // Coarse phase profiling behind FORME_PROFILE (native only in practice —
319    // `env::var` is Err under wasm, so the timer is never constructed there and
320    // `Instant::now` is never called). Prints layout vs serialize to stderr.
321    let profile = std::env::var("FORME_PROFILE").is_ok();
322    let t_layout = if profile {
323        Some(std::time::Instant::now())
324    } else {
325        None
326    };
327    let (pages, font_context, passes, layout_warnings) =
328        layout_with_sentinel_passes_audited(document, options.audit_content);
329    let layout_ms = t_layout.map(|t| t.elapsed().as_secs_f64() * 1000.0);
330
331    let writer = PdfWriter::new();
332    let tagged = document.tagged
333        || document.pdf_ua
334        || document.pdf_ua2
335        || matches!(
336            document.pdfa,
337            Some(model::PdfAConformance::A2a) | Some(model::PdfAConformance::A3a)
338        );
339    let t_ser = if profile {
340        Some(std::time::Instant::now())
341    } else {
342        None
343    };
344    let (pdf, warnings) = writer.write(
345        &pages,
346        &document.metadata,
347        &font_context,
348        tagged,
349        document.pdfa.as_ref(),
350        document.pdf_ua,
351        document.embedded_data.as_deref(),
352        &document.attachments,
353        document.zugferd.as_ref(),
354        document.flatten_forms,
355        effective_pdf_version(document),
356        document.pdf_ua2,
357    )?;
358    let warnings = {
359        let mut all = layout_warnings;
360        all.extend(warnings);
361        all
362    };
363    let serialize_ms = t_ser.map(|t| t.elapsed().as_secs_f64() * 1000.0);
364    let pdf = if let Some(ref sig_config) = document.certification {
365        pdf::certify::certify_pdf(&pdf, sig_config)?
366    } else {
367        pdf
368    };
369    if let (Some(l), Some(s)) = (layout_ms, serialize_ms) {
370        eprintln!(
371            "FORME_PROFILE pages={} passes={passes} layout_ms={l:.1} serialize_ms={s:.1}",
372            pages.len()
373        );
374    }
375    Ok((pdf, warnings, passes))
376}
377
378/// Render a document to PDF bytes along with layout metadata.
379///
380/// Same as `render()` but also returns `LayoutInfo` describing the
381/// position and dimensions of every element on every page.
382/// If the document has a `certification` configuration, the output PDF
383/// is digitally signed.
384pub fn render_with_layout(
385    document: &Document,
386) -> Result<(Vec<u8>, LayoutInfo, Vec<String>), FormeError> {
387    render_with_layout_and_passes(document)
388        .map(|(pdf, layout, warnings, _passes)| (pdf, layout, warnings))
389}
390
391/// Like [`render_with_layout`], but also returns the number of layout passes
392/// the render took (1 for the common case; 2-3 when a page-number sentinel's
393/// reserved width needed correction). The plain variant discarded this value,
394/// which is how `renderHtmlWithLayout` shipped without `passes` on every
395/// target while its declared type promised one.
396pub fn render_with_layout_and_passes(
397    document: &Document,
398) -> Result<(Vec<u8>, LayoutInfo, Vec<String>, u32), FormeError> {
399    render_with_layout_and_options(document, RenderOptions::default())
400}
401
402/// Like [`render_with_layout_and_passes`], with opt-in [`RenderOptions`].
403pub fn render_with_layout_and_options(
404    document: &Document,
405    options: RenderOptions,
406) -> Result<(Vec<u8>, LayoutInfo, Vec<String>, u32), FormeError> {
407    validate_pdf_version(document)?;
408    validate_pdf_ua2(document)?;
409    let (pages, font_context, passes, layout_warnings) =
410        layout_with_sentinel_passes_audited(document, options.audit_content);
411    let layout_info = LayoutInfo::from_pages(&pages);
412    let writer = PdfWriter::new();
413    let tagged = document.tagged
414        || document.pdf_ua
415        || document.pdf_ua2
416        || matches!(
417            document.pdfa,
418            Some(model::PdfAConformance::A2a) | Some(model::PdfAConformance::A3a)
419        );
420    let (pdf, warnings) = writer.write(
421        &pages,
422        &document.metadata,
423        &font_context,
424        tagged,
425        document.pdfa.as_ref(),
426        document.pdf_ua,
427        document.embedded_data.as_deref(),
428        &document.attachments,
429        document.zugferd.as_ref(),
430        document.flatten_forms,
431        effective_pdf_version(document),
432        document.pdf_ua2,
433    )?;
434    let pdf = if let Some(ref sig_config) = document.certification {
435        pdf::certify::certify_pdf(&pdf, sig_config)?
436    } else {
437        pdf
438    };
439    let warnings = {
440        let mut all = layout_warnings;
441        all.extend(warnings);
442        all
443    };
444    Ok((pdf, layout_info, warnings, passes))
445}
446
447/// Return the number of digits needed to display `n` as a decimal string.
448fn digits_for_count(n: usize) -> u32 {
449    if n < 10 {
450        1
451    } else if n < 100 {
452        2
453    } else if n < 1000 {
454        3
455    } else {
456        4
457    }
458}
459
460/// Register custom fonts from the document's `fonts` array.
461fn register_document_fonts(font_context: &mut FontContext, fonts: &[FontEntry]) {
462    use base64::Engine as _;
463    let b64 = base64::engine::general_purpose::STANDARD;
464
465    for entry in fonts {
466        let bytes = if let Some(comma_pos) = entry.src.find(',') {
467            // data URI: "data:font/ttf;base64,AAAA..."
468            b64.decode(&entry.src[comma_pos + 1..]).ok()
469        } else {
470            // raw base64 string
471            b64.decode(&entry.src).ok()
472        };
473
474        if let Some(data) = bytes {
475            font_context
476                .registry_mut()
477                .register(&entry.family, entry.weight, entry.italic, data);
478        }
479    }
480}
481
482/// Render a document described as JSON to PDF bytes.
483pub fn render_json(json: &str) -> Result<Vec<u8>, FormeError> {
484    let document: Document = serde_json::from_str(json)?;
485    render(&document)
486}
487
488/// Render a document described as JSON to PDF bytes along with layout metadata.
489pub fn render_json_with_layout(
490    json: &str,
491) -> Result<(Vec<u8>, LayoutInfo, Vec<String>), FormeError> {
492    let document: Document = serde_json::from_str(json)?;
493    render_with_layout(&document)
494}
495
496/// Like [`render_json_with_layout`], with opt-in [`RenderOptions`] — the
497/// JSON-input mirror of [`render_with_layout_and_options`], used by the
498/// WASM bindings so `@formepdf/core` callers can opt into the content
499/// audit the way the HTML path already can.
500pub fn render_json_with_layout_and_options(
501    json: &str,
502    options: RenderOptions,
503) -> Result<(Vec<u8>, LayoutInfo, Vec<String>, u32), FormeError> {
504    let document: Document = serde_json::from_str(json)?;
505    render_with_layout_and_options(&document, options)
506}
507
508/// Render a template with data to PDF bytes.
509///
510/// Takes a template JSON tree (with `$ref`, `$each`, `$if`, operators) and
511/// a data JSON object. Evaluates all expressions, then renders the resulting
512/// document to PDF.
513pub fn render_template(template_json: &str, data_json: &str) -> Result<Vec<u8>, FormeError> {
514    let template: serde_json::Value = serde_json::from_str(template_json)?;
515    let data: serde_json::Value = serde_json::from_str(data_json)?;
516    let resolved = template::evaluate_template(&template, &data)?;
517    let document: Document = serde_json::from_value(resolved)?;
518    render(&document)
519}
520
521/// Render a template with data to PDF bytes along with layout metadata and
522/// the render's warnings.
523///
524/// The warnings are part of the return tuple rather than an `_and_warnings`
525/// variant on purpose: this function discarded them for its whole life, which
526/// made `renderTemplateWithLayout` report "warnings: none" on every render
527/// while its declared type promised a list. Matching [`render_with_layout`]'s
528/// shape means a caller has to name the value to drop it.
529pub fn render_template_with_layout(
530    template_json: &str,
531    data_json: &str,
532) -> Result<(Vec<u8>, LayoutInfo, Vec<String>), FormeError> {
533    let template: serde_json::Value = serde_json::from_str(template_json)?;
534    let data: serde_json::Value = serde_json::from_str(data_json)?;
535    let resolved = template::evaluate_template(&template, &data)?;
536    let document: Document = serde_json::from_value(resolved)?;
537    render_with_layout(&document)
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn test_digits_for_count() {
546        assert_eq!(digits_for_count(0), 1);
547        assert_eq!(digits_for_count(1), 1);
548        assert_eq!(digits_for_count(9), 1);
549        assert_eq!(digits_for_count(10), 2);
550        assert_eq!(digits_for_count(99), 2);
551        assert_eq!(digits_for_count(100), 3);
552        assert_eq!(digits_for_count(999), 3);
553        assert_eq!(digits_for_count(1000), 4);
554    }
555}