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/// Render a document to PDF bytes plus any non-fatal warnings (e.g. pdfUa
127/// requested without an embeddable font registered). Same output as `render`;
128/// the warnings surface through the WASM bindings and the HTML wrapper.
129pub fn render_with_warnings(document: &Document) -> Result<(Vec<u8>, Vec<String>), FormeError> {
130    let mut font_context = FontContext::new();
131    register_document_fonts(&mut font_context, &document.fonts);
132    let engine = LayoutEngine::new();
133    let mut pages = engine.layout(document, &font_context);
134
135    // Re-layout if sentinel digit count was wrong (up to 3 total passes)
136    for _ in 0..2 {
137        let needed = digits_for_count(pages.len());
138        if needed == font_context.sentinel_digit_count() {
139            break;
140        }
141        font_context.set_sentinel_digit_count(needed);
142        pages = engine.layout(document, &font_context);
143    }
144
145    let writer = PdfWriter::new();
146    let tagged = document.tagged
147        || document.pdf_ua
148        || matches!(document.pdfa, Some(model::PdfAConformance::A2a));
149    let (pdf, warnings) = writer.write(
150        &pages,
151        &document.metadata,
152        &font_context,
153        tagged,
154        document.pdfa.as_ref(),
155        document.pdf_ua,
156        document.embedded_data.as_deref(),
157        document.flatten_forms,
158    )?;
159    let pdf = if let Some(ref sig_config) = document.certification {
160        pdf::certify::certify_pdf(&pdf, sig_config)?
161    } else {
162        pdf
163    };
164    Ok((pdf, warnings))
165}
166
167/// Render a document to PDF bytes along with layout metadata.
168///
169/// Same as `render()` but also returns `LayoutInfo` describing the
170/// position and dimensions of every element on every page.
171/// If the document has a `certification` configuration, the output PDF
172/// is digitally signed.
173pub fn render_with_layout(
174    document: &Document,
175) -> Result<(Vec<u8>, LayoutInfo, Vec<String>), FormeError> {
176    let mut font_context = FontContext::new();
177    register_document_fonts(&mut font_context, &document.fonts);
178    let engine = LayoutEngine::new();
179    let mut pages = engine.layout(document, &font_context);
180
181    // Re-layout if sentinel digit count was wrong (up to 3 total passes)
182    for _ in 0..2 {
183        let needed = digits_for_count(pages.len());
184        if needed == font_context.sentinel_digit_count() {
185            break;
186        }
187        font_context.set_sentinel_digit_count(needed);
188        pages = engine.layout(document, &font_context);
189    }
190
191    let layout_info = LayoutInfo::from_pages(&pages);
192    let writer = PdfWriter::new();
193    let tagged = document.tagged
194        || document.pdf_ua
195        || matches!(document.pdfa, Some(model::PdfAConformance::A2a));
196    let (pdf, warnings) = writer.write(
197        &pages,
198        &document.metadata,
199        &font_context,
200        tagged,
201        document.pdfa.as_ref(),
202        document.pdf_ua,
203        document.embedded_data.as_deref(),
204        document.flatten_forms,
205    )?;
206    let pdf = if let Some(ref sig_config) = document.certification {
207        pdf::certify::certify_pdf(&pdf, sig_config)?
208    } else {
209        pdf
210    };
211    Ok((pdf, layout_info, warnings))
212}
213
214/// Return the number of digits needed to display `n` as a decimal string.
215fn digits_for_count(n: usize) -> u32 {
216    if n < 10 {
217        1
218    } else if n < 100 {
219        2
220    } else if n < 1000 {
221        3
222    } else {
223        4
224    }
225}
226
227/// Register custom fonts from the document's `fonts` array.
228fn register_document_fonts(font_context: &mut FontContext, fonts: &[FontEntry]) {
229    use base64::Engine as _;
230    let b64 = base64::engine::general_purpose::STANDARD;
231
232    for entry in fonts {
233        let bytes = if let Some(comma_pos) = entry.src.find(',') {
234            // data URI: "data:font/ttf;base64,AAAA..."
235            b64.decode(&entry.src[comma_pos + 1..]).ok()
236        } else {
237            // raw base64 string
238            b64.decode(&entry.src).ok()
239        };
240
241        if let Some(data) = bytes {
242            font_context
243                .registry_mut()
244                .register(&entry.family, entry.weight, entry.italic, data);
245        }
246    }
247}
248
249/// Render a document described as JSON to PDF bytes.
250pub fn render_json(json: &str) -> Result<Vec<u8>, FormeError> {
251    let document: Document = serde_json::from_str(json)?;
252    render(&document)
253}
254
255/// Render a document described as JSON to PDF bytes along with layout metadata.
256pub fn render_json_with_layout(
257    json: &str,
258) -> Result<(Vec<u8>, LayoutInfo, Vec<String>), FormeError> {
259    let document: Document = serde_json::from_str(json)?;
260    render_with_layout(&document)
261}
262
263/// Render a template with data to PDF bytes.
264///
265/// Takes a template JSON tree (with `$ref`, `$each`, `$if`, operators) and
266/// a data JSON object. Evaluates all expressions, then renders the resulting
267/// document to PDF.
268pub fn render_template(template_json: &str, data_json: &str) -> Result<Vec<u8>, FormeError> {
269    let template: serde_json::Value = serde_json::from_str(template_json)?;
270    let data: serde_json::Value = serde_json::from_str(data_json)?;
271    let resolved = template::evaluate_template(&template, &data)?;
272    let document: Document = serde_json::from_value(resolved)?;
273    render(&document)
274}
275
276/// Render a template with data to PDF bytes along with layout metadata.
277pub fn render_template_with_layout(
278    template_json: &str,
279    data_json: &str,
280) -> Result<(Vec<u8>, LayoutInfo), FormeError> {
281    let template: serde_json::Value = serde_json::from_str(template_json)?;
282    let data: serde_json::Value = serde_json::from_str(data_json)?;
283    let resolved = template::evaluate_template(&template, &data)?;
284    let document: Document = serde_json::from_value(resolved)?;
285    render_with_layout(&document).map(|(pdf, layout, _warnings)| (pdf, layout))
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn test_digits_for_count() {
294        assert_eq!(digits_for_count(0), 1);
295        assert_eq!(digits_for_count(1), 1);
296        assert_eq!(digits_for_count(9), 1);
297        assert_eq!(digits_for_count(10), 2);
298        assert_eq!(digits_for_count(99), 2);
299        assert_eq!(digits_for_count(100), 3);
300        assert_eq!(digits_for_count(999), 3);
301        assert_eq!(digits_for_count(1000), 4);
302    }
303}