forme/model/mod.rs
1//! # Document Model
2//!
3//! The input representation for the rendering engine. A document is a tree of
4//! nodes, each with a type, style properties, and children. This is designed
5//! to be easily produced by a React reconciler, an HTML parser, or direct
6//! JSON construction.
7//!
8//! The model is intentionally close to the DOM/React mental model: you have
9//! containers (View), text (Text), images (Image), and tables (Table). But
10//! there is one critical addition: **Page** is a first-class node type.
11
12use crate::style::Style;
13use serde::{Deserialize, Deserializer, Serialize};
14
15/// A complete document ready for rendering.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct Document {
19 /// The root nodes of the document. Typically one or more Page nodes,
20 /// but can also be content nodes that get auto-wrapped in pages.
21 pub children: Vec<Node>,
22
23 /// Document metadata (title, author, etc.)
24 #[serde(default)]
25 pub metadata: Metadata,
26
27 /// Default page configuration used when content overflows or when
28 /// nodes aren't explicitly wrapped in Page nodes.
29 #[serde(default)]
30 pub default_page: PageConfig,
31
32 /// Page configuration for the FIRST page only, when it differs from
33 /// `default_page` (CSS `@page :first`). Margins and background may
34 /// vary; size should match `default_page` — flowing content bakes
35 /// widths at layout time, so per-page size changes are not supported.
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub first_page: Option<PageConfig>,
38
39 /// Page config for LEFT (verso, even 1-based) pages — CSS `@page :left`.
40 /// Flow layout always uses the base horizontal geometry; mirrored
41 /// margins (equal left+right sum) are applied as a constant x
42 /// translation at finalize, never a re-layout — so unequal sums are
43 /// unsupported (the HTML mapper normalizes and warns). `:first`
44 /// outranks parity on page 1.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub left_page: Option<PageConfig>,
47
48 /// Page config for RIGHT (recto, odd 1-based) pages — CSS `@page
49 /// :right`. Page 1 is a right page (left-to-right page progression per
50 /// CSS Paged Media; RTL page progression is not modeled).
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub right_page: Option<PageConfig>,
53
54 /// Named page configs (CSS `@page <name>` + the `page` property).
55 /// A `PageName` marker node switches the active name; a named run
56 /// starts at a forced page break, so its REAL config (`base`) may
57 /// genuinely differ vertically. Horizontal geometry follows the same
58 /// translation rule as `:left`/`:right` (mirrored margins only).
59 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
60 pub named_pages: std::collections::HashMap<String, NamedPageSet>,
61
62 /// Custom fonts to register before layout. Each entry contains
63 /// the font family name, base64-encoded font data, weight, and style.
64 #[serde(default)]
65 pub fonts: Vec<FontEntry>,
66
67 /// Default style applied to the root of the document tree.
68 /// Useful for setting a global `font_family`, `font_size`, `color`, etc.
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub default_style: Option<crate::style::Style>,
71
72 /// Whether to produce a tagged (accessible) PDF with structure tree.
73 ///
74 /// Defaults to `true`: every render emits a structure tree unless the
75 /// caller explicitly sets `tagged: false`. Tagging is layout-neutral —
76 /// the tag tree is built after layout, so geometry and visual output are
77 /// byte-for-byte unchanged; only the structural PDF objects differ.
78 #[serde(default = "default_true")]
79 pub tagged: bool,
80
81 /// PDF/A conformance level. When set, forces `tagged = true` for "2a".
82 #[serde(default)]
83 pub pdfa: Option<PdfAConformance>,
84
85 /// When true, the PDF claims PDF/UA-1 conformance. Forces `tagged = true`.
86 #[serde(default)]
87 pub pdf_ua: bool,
88
89 /// Optional JSON string to embed as an attached file in the PDF.
90 /// Enables round-tripping structured data through PDF files.
91 /// Forbidden under PDF/A-1/-2 (which allow only PDF/A attachments);
92 /// use a PDF/A-3 level (`"3b"` etc.) or drop the conformance claim.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub embedded_data: Option<String>,
95
96 /// Files to embed as PDF attachments (associated files). Under
97 /// PDF/A-3 each becomes a conformant associated file: MIME `/Subtype`
98 /// on the stream, `/F`+`/UF`+`/AFRelationship` on the filespec, and
99 /// membership in the catalog `/AF` array. Forbidden under PDF/A-1/-2.
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub attachments: Vec<Attachment>,
102
103 /// Factur-X / ZUGFeRD e-invoice identification (XMP `fx:` schema).
104 /// Container-level only: the caller supplies the invoice XML as an
105 /// attachment; this drives the XMP that names it. Requires a PDF/A-3
106 /// level and an attachment whose name matches `document_file_name`.
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub zugferd: Option<ZugferdMeta>,
109
110 /// When true, form field values are rendered as static content and no
111 /// interactive AcroForm widgets are emitted. The resulting PDF has no
112 /// fillable fields.
113 #[serde(default)]
114 pub flatten_forms: bool,
115
116 /// Digital certification configuration. When set, the rendered PDF is certified
117 /// with the specified X.509 certificate and RSA private key.
118 #[serde(default, skip_serializing_if = "Option::is_none", alias = "signature")]
119 pub certification: Option<CertificationConfig>,
120}
121
122/// PDF/A conformance level.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub enum PdfAConformance {
125 /// PDF/A-2a: full accessibility (requires tagging).
126 #[serde(rename = "2a")]
127 A2a,
128 /// PDF/A-2b: basic compliance (visual appearance only).
129 #[serde(rename = "2b")]
130 A2b,
131 /// PDF/A-2u: 2b plus a Unicode mapping for all text.
132 #[serde(rename = "2u")]
133 A2u,
134 /// PDF/A-3a: like 2a, plus arbitrary embedded files (ISO 19005-3).
135 #[serde(rename = "3a")]
136 A3a,
137 /// PDF/A-3b: like 2b, plus arbitrary embedded files.
138 #[serde(rename = "3b")]
139 A3b,
140 /// PDF/A-3u: like 2u, plus arbitrary embedded files.
141 #[serde(rename = "3u")]
142 A3u,
143}
144
145impl PdfAConformance {
146 /// Part 3 permits embedded files of any type; parts 1/2 allow only
147 /// other PDF/A files (veraPDF rule 6.8-5), which the engine cannot
148 /// verify — so attachments under a 2x level are refused.
149 pub fn allows_attachments(&self) -> bool {
150 matches!(self, Self::A3a | Self::A3b | Self::A3u)
151 }
152}
153
154/// A file embedded as a PDF attachment (associated file).
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct Attachment {
158 /// Filename recorded in `/F`, `/UF`, and the EmbeddedFiles name tree
159 /// (e.g. `factur-x.xml`).
160 pub name: String,
161 /// File bytes, base64-encoded (a `data:` URI prefix is tolerated).
162 pub src: String,
163 /// MIME type for the stream `/Subtype` (PDF/A-3 requires one);
164 /// defaults to `application/octet-stream`.
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub mime_type: Option<String>,
167 /// How the file relates to the document (`/AFRelationship`).
168 /// Defaults to `Unspecified`; the Factur-X path derives the correct
169 /// value from the profile when unset.
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub relationship: Option<AfRelationship>,
172 /// Optional human-readable `/Desc`.
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub description: Option<String>,
175 /// Modification date for `/Params /ModDate`, as a PDF date string
176 /// (`D:YYYYMMDDHHmmSSZ`). Defaults to a fixed constant — never
177 /// wall-clock — so output stays byte-deterministic.
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub mod_date: Option<String>,
180}
181
182/// `/AFRelationship` values (PDF 2.0 §14.13, used by PDF/A-3).
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184pub enum AfRelationship {
185 Data,
186 Source,
187 Alternative,
188 Supplement,
189 Unspecified,
190}
191
192impl AfRelationship {
193 pub fn pdf_name(&self) -> &'static str {
194 match self {
195 Self::Data => "Data",
196 Self::Source => "Source",
197 Self::Alternative => "Alternative",
198 Self::Supplement => "Supplement",
199 Self::Unspecified => "Unspecified",
200 }
201 }
202}
203
204/// Factur-X / ZUGFeRD XMP identification (the `fx:` extension schema).
205///
206/// Container-level metadata only — names the attached XML and its
207/// profile. The engine does not read or validate the XML itself.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct ZugferdMeta {
211 /// Profile name as it appears in XMP `fx:ConformanceLevel`:
212 /// `MINIMUM`, `BASIC WL`, `BASIC`, `EN 16931`, `EXTENDED`, or
213 /// `XRECHNUNG` (spaces included — Mustang validates these exact
214 /// spellings).
215 pub conformance_level: String,
216 /// XMP `fx:DocumentFileName`; must match an attachment's `name`.
217 /// Defaults to `factur-x.xml` (`xrechnung.xml` for XRECHNUNG).
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub document_file_name: Option<String>,
220 /// XMP `fx:Version` — the Factur-X schema version, default `1.0`.
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub version: Option<String>,
223 /// XMP `fx:DocumentType`, default `INVOICE`.
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub document_type: Option<String>,
226}
227
228/// A rectangular region to redact in an existing PDF.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct RedactionRegion {
231 /// 0-indexed page number.
232 pub page: usize,
233 /// X coordinate in points from the left edge of the page.
234 pub x: f64,
235 /// Y coordinate in points from the top edge (web/screen coordinates).
236 /// The engine converts to PDF bottom-origin internally — do NOT flip before calling.
237 pub y: f64,
238 /// Width of the redaction rectangle in points.
239 pub width: f64,
240 /// Height of the redaction rectangle in points.
241 pub height: f64,
242 /// Fill color as hex string (e.g. "#000000"). Defaults to black.
243 #[serde(default)]
244 pub color: Option<String>,
245}
246
247/// How to interpret a text pattern for redaction search.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub enum PatternType {
250 /// Exact string match (case-insensitive).
251 Literal,
252 /// Regular expression pattern.
253 Regex,
254}
255
256/// A text pattern to search for in a PDF for redaction.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct RedactionPattern {
259 /// The search string (literal text or regex pattern).
260 pub pattern: String,
261 /// Whether to interpret `pattern` as literal text or a regex.
262 pub pattern_type: PatternType,
263 /// Restrict search to a specific page (0-indexed). None = all pages.
264 #[serde(default)]
265 pub page: Option<usize>,
266 /// Fill color for the redaction overlay. Defaults to black.
267 #[serde(default)]
268 pub color: Option<String>,
269}
270
271/// Configuration for digitally certifying a PDF with an X.509 certificate.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct CertificationConfig {
275 /// PEM-encoded X.509 certificate.
276 pub certificate_pem: String,
277 /// PEM-encoded RSA private key (PKCS#8).
278 pub private_key_pem: String,
279 /// Reason for signing (e.g. "Approved").
280 #[serde(default)]
281 pub reason: Option<String>,
282 /// Location of signing (e.g. "New York, NY").
283 #[serde(default)]
284 pub location: Option<String>,
285 /// Contact info for the signer.
286 #[serde(default)]
287 pub contact: Option<String>,
288 /// Whether to show a visible signature annotation on the page.
289 #[serde(default)]
290 pub visible: bool,
291 /// X coordinate in points for visible signature.
292 #[serde(default)]
293 pub x: Option<f64>,
294 /// Y coordinate in points for visible signature.
295 #[serde(default)]
296 pub y: Option<f64>,
297 /// Width in points for visible signature.
298 #[serde(default)]
299 pub width: Option<f64>,
300 /// Height in points for visible signature.
301 #[serde(default)]
302 pub height: Option<f64>,
303}
304
305/// A custom font to register with the engine.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct FontEntry {
308 /// Font family name (e.g. "Inter", "Roboto").
309 pub family: String,
310 /// Base64-encoded font data, or a data URI (e.g. "data:font/ttf;base64,...").
311 pub src: String,
312 /// Font weight (100-900). Defaults to 400.
313 #[serde(default = "default_weight")]
314 pub weight: u32,
315 /// Whether this is an italic variant.
316 #[serde(default)]
317 pub italic: bool,
318}
319
320fn default_weight() -> u32 {
321 400
322}
323
324/// Document metadata embedded in the PDF.
325#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326pub struct Metadata {
327 pub title: Option<String>,
328 pub author: Option<String>,
329 pub subject: Option<String>,
330 pub creator: Option<String>,
331 /// Document language (BCP 47 tag, e.g. "en-US"). Emitted as /Lang in the PDF Catalog.
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub lang: Option<String>,
334}
335
336/// Configuration for a page: size, margins, orientation.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct PageConfig {
340 /// Page size. Defaults to A4.
341 #[serde(default = "PageSize::default")]
342 pub size: PageSize,
343
344 /// Page margins in points (1/72 inch).
345 #[serde(default)]
346 pub margin: Edges,
347
348 /// Whether this page auto-wraps content that overflows.
349 #[serde(default = "default_true")]
350 pub wrap: bool,
351
352 /// Optional background image painted behind the page's content.
353 /// URL, file path, or `data:image/...;base64,` URI.
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub background_image: Option<String>,
356
357 /// Opacity for the background image (0.0–1.0). Defaults to 1.0.
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub background_opacity: Option<f64>,
360
361 /// How the background image is sized within the page.
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub background_size: Option<BackgroundSize>,
364
365 /// Where the background image is positioned within the page.
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub background_position: Option<BackgroundPosition>,
368}
369
370/// How a background image is scaled to fit a page.
371#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
372#[serde(rename_all = "kebab-case")]
373pub enum BackgroundSize {
374 /// Stretch the image to the page's exact dimensions (default).
375 #[default]
376 Fill,
377 /// Scale to fully cover the page; crops if aspect ratio differs.
378 Cover,
379 /// Scale to fit within the page; letterboxes if aspect ratio differs.
380 Contain,
381}
382
383/// Where a background image is positioned on a page (relevant for
384/// `cover` / `contain` when the image doesn't fill the page exactly).
385#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
386#[serde(rename_all = "kebab-case")]
387pub enum BackgroundPosition {
388 Center,
389 #[default]
390 TopLeft,
391 TopRight,
392 BottomLeft,
393 BottomRight,
394}
395
396impl Default for PageConfig {
397 fn default() -> Self {
398 Self {
399 size: PageSize::A4,
400 margin: Edges::uniform(54.0), // ~0.75 inch
401 wrap: true,
402 background_image: None,
403 background_opacity: None,
404 background_size: None,
405 background_position: None,
406 }
407 }
408}
409
410fn default_true() -> bool {
411 true
412}
413
414/// Standard page sizes in points.
415#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
416pub enum PageSize {
417 #[default]
418 A4,
419 A3,
420 A5,
421 Letter,
422 Legal,
423 Tabloid,
424 Custom {
425 width: f64,
426 height: f64,
427 },
428}
429
430impl PageSize {
431 /// Returns (width, height) in points.
432 pub fn dimensions(&self) -> (f64, f64) {
433 match self {
434 PageSize::A4 => (595.28, 841.89),
435 PageSize::A3 => (841.89, 1190.55),
436 PageSize::A5 => (419.53, 595.28),
437 PageSize::Letter => (612.0, 792.0),
438 PageSize::Legal => (612.0, 1008.0),
439 PageSize::Tabloid => (792.0, 1224.0),
440 PageSize::Custom { width, height } => (*width, *height),
441 }
442 }
443}
444
445/// Edge values (top, right, bottom, left) used for padding and page margins.
446#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
447pub struct Edges {
448 pub top: f64,
449 pub right: f64,
450 pub bottom: f64,
451 pub left: f64,
452}
453
454/// A margin edge value — either a fixed point value or auto.
455#[derive(Debug, Clone, Copy, Serialize)]
456pub enum EdgeValue {
457 Pt(f64),
458 Auto,
459}
460
461impl Default for EdgeValue {
462 fn default() -> Self {
463 EdgeValue::Pt(0.0)
464 }
465}
466
467impl EdgeValue {
468 /// Resolve to a concrete value, treating Auto as 0.
469 pub fn resolve(&self) -> f64 {
470 match self {
471 EdgeValue::Pt(v) => *v,
472 EdgeValue::Auto => 0.0,
473 }
474 }
475
476 /// Whether this edge is auto.
477 pub fn is_auto(&self) -> bool {
478 matches!(self, EdgeValue::Auto)
479 }
480}
481
482impl<'de> Deserialize<'de> for EdgeValue {
483 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
484 where
485 D: Deserializer<'de>,
486 {
487 use serde::de;
488
489 struct EdgeValueVisitor;
490
491 impl<'de> de::Visitor<'de> for EdgeValueVisitor {
492 type Value = EdgeValue;
493
494 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
495 f.write_str("a number or the string \"auto\"")
496 }
497
498 fn visit_f64<E: de::Error>(self, v: f64) -> Result<EdgeValue, E> {
499 Ok(EdgeValue::Pt(v))
500 }
501
502 fn visit_i64<E: de::Error>(self, v: i64) -> Result<EdgeValue, E> {
503 Ok(EdgeValue::Pt(v as f64))
504 }
505
506 fn visit_u64<E: de::Error>(self, v: u64) -> Result<EdgeValue, E> {
507 Ok(EdgeValue::Pt(v as f64))
508 }
509
510 fn visit_str<E: de::Error>(self, v: &str) -> Result<EdgeValue, E> {
511 if v == "auto" {
512 Ok(EdgeValue::Auto)
513 } else {
514 Err(de::Error::invalid_value(de::Unexpected::Str(v), &self))
515 }
516 }
517 }
518
519 deserializer.deserialize_any(EdgeValueVisitor)
520 }
521}
522
523/// Margin edges that support auto values.
524#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
525pub struct MarginEdges {
526 pub top: EdgeValue,
527 pub right: EdgeValue,
528 pub bottom: EdgeValue,
529 pub left: EdgeValue,
530}
531
532impl MarginEdges {
533 /// Sum of resolved (non-auto) horizontal margins.
534 pub fn horizontal(&self) -> f64 {
535 self.left.resolve() + self.right.resolve()
536 }
537
538 /// Sum of resolved (non-auto) vertical margins.
539 pub fn vertical(&self) -> f64 {
540 self.top.resolve() + self.bottom.resolve()
541 }
542
543 /// Whether any horizontal margin is auto.
544 pub fn has_auto_horizontal(&self) -> bool {
545 self.left.is_auto() || self.right.is_auto()
546 }
547
548 /// Whether any vertical margin is auto.
549 pub fn has_auto_vertical(&self) -> bool {
550 self.top.is_auto() || self.bottom.is_auto()
551 }
552
553 /// Convert from plain Edges (all Pt values).
554 pub fn from_edges(e: Edges) -> Self {
555 MarginEdges {
556 top: EdgeValue::Pt(e.top),
557 right: EdgeValue::Pt(e.right),
558 bottom: EdgeValue::Pt(e.bottom),
559 left: EdgeValue::Pt(e.left),
560 }
561 }
562
563 /// Convert to plain Edges, resolving auto to 0.
564 pub fn to_edges(&self) -> Edges {
565 Edges {
566 top: self.top.resolve(),
567 right: self.right.resolve(),
568 bottom: self.bottom.resolve(),
569 left: self.left.resolve(),
570 }
571 }
572}
573
574impl Edges {
575 pub fn uniform(v: f64) -> Self {
576 Self {
577 top: v,
578 right: v,
579 bottom: v,
580 left: v,
581 }
582 }
583
584 pub fn symmetric(vertical: f64, horizontal: f64) -> Self {
585 Self {
586 top: vertical,
587 right: horizontal,
588 bottom: vertical,
589 left: horizontal,
590 }
591 }
592
593 pub fn horizontal(&self) -> f64 {
594 self.left + self.right
595 }
596
597 pub fn vertical(&self) -> f64 {
598 self.top + self.bottom
599 }
600}
601
602/// A node in the document tree.
603#[derive(Debug, Clone, Serialize, Deserialize)]
604#[serde(rename_all = "camelCase")]
605pub struct Node {
606 /// What kind of node this is.
607 pub kind: NodeKind,
608
609 /// Style properties for this node.
610 #[serde(default)]
611 pub style: Style,
612
613 /// Child nodes.
614 #[serde(default)]
615 pub children: Vec<Node>,
616
617 /// A unique identifier for this node (optional, useful for debugging).
618 #[serde(default)]
619 pub id: Option<String>,
620
621 /// Source code location for click-to-source in the dev inspector.
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub source_location: Option<SourceLocation>,
624
625 /// Bookmark title for this node (creates a PDF outline entry).
626 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub bookmark: Option<String>,
628
629 /// Optional hyperlink URL for this node (creates a PDF link annotation).
630 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub href: Option<String>,
632
633 /// Optional alt text for images and SVGs (accessibility).
634 #[serde(default, skip_serializing_if = "Option::is_none")]
635 pub alt: Option<String>,
636}
637
638/// The different kinds of nodes in the document tree.
639#[derive(Debug, Clone, Serialize, Deserialize)]
640#[serde(tag = "type")]
641pub enum NodeKind {
642 /// A page boundary. Content inside flows according to page config.
643 Page {
644 #[serde(default)]
645 config: PageConfig,
646 },
647
648 /// A generic container, analogous to a <div> or React <View>.
649 View,
650
651 /// A text node with string content.
652 Text {
653 content: String,
654 /// Optional hyperlink URL.
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 href: Option<String>,
657 /// Inline styled runs. When non-empty, `content` is ignored.
658 #[serde(default, skip_serializing_if = "Vec::is_empty")]
659 runs: Vec<TextRun>,
660 },
661
662 /// A semantic heading (H1-H6). Lays out as text but carries a level
663 /// so the tagged-PDF builder can emit the right `/H1`...`/H6`
664 /// structure element. The React layer provides sensible default
665 /// styles per level; users can override via `style`.
666 Heading {
667 level: u8,
668 content: String,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
670 href: Option<String>,
671 #[serde(default, skip_serializing_if = "Vec::is_empty")]
672 runs: Vec<TextRun>,
673 },
674
675 /// An ordered or unordered list. Children should be `ListItem` nodes.
676 /// Marker numbering continues across page breaks.
677 List {
678 /// Whether items are numbered (true) or use a bullet glyph (false).
679 ordered: bool,
680 /// Which marker style to render.
681 marker_type: ListMarkerType,
682 /// Starting index for ordered lists (default 1). Ignored when
683 /// `ordered = false`.
684 #[serde(default = "default_list_start")]
685 start: u32,
686 },
687
688 /// One item inside a `List`. Children are the item content.
689 ListItem,
690
691 /// An image node.
692 Image {
693 /// Base64-encoded image data, or a file path.
694 src: String,
695 /// Image width in points (optional, will use intrinsic if not set).
696 width: Option<f64>,
697 /// Image height in points (optional, will use intrinsic if not set).
698 height: Option<f64>,
699 },
700
701 /// A table container. Children should be TableRow nodes.
702 Table {
703 /// Column width definitions. If omitted, columns distribute evenly.
704 #[serde(default)]
705 columns: Vec<ColumnDef>,
706 },
707
708 /// A row inside a Table.
709 TableRow {
710 /// If true, this row repeats at the top of each page when the table
711 /// breaks across pages. This is the killer feature.
712 #[serde(default)]
713 is_header: bool,
714 },
715
716 /// A cell inside a TableRow.
717 TableCell {
718 /// Column span.
719 #[serde(default = "default_one")]
720 col_span: u32,
721 /// Row span.
722 #[serde(default = "default_one")]
723 row_span: u32,
724 },
725
726 /// A fixed element that repeats on pages (headers, footers, page numbers).
727 Fixed {
728 /// Where to place this element on the page.
729 position: FixedPosition,
730 /// Which pages this element appears on (CSS `@page :first`
731 /// suppression maps to `NotFirst`). Defaults to all pages.
732 #[serde(default)]
733 pages: FixedPageFilter,
734 /// Restrict to pages carrying this page name (CSS `@page <name>`
735 /// margin boxes). `None` = no name restriction.
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 page_name: Option<String>,
738 /// Skip pages carrying any of these names (a named `@page` rule
739 /// that overrides or suppresses this edge's boxes).
740 #[serde(default, skip_serializing_if = "Vec::is_empty")]
741 exclude_page_names: Vec<String>,
742 },
743
744 /// An explicit page break.
745 PageBreak,
746
747 /// A marker switching the active page NAME (CSS `page` property).
748 /// When the name changes, the current page is finalized (if it has
749 /// content) and subsequent content flows onto pages using the named
750 /// config from `Document::named_pages`. `None` restores unnamed flow.
751 PageName {
752 #[serde(default, skip_serializing_if = "Option::is_none")]
753 name: Option<String>,
754 },
755
756 /// An SVG element rendered as vector graphics.
757 Svg {
758 /// Display width in points.
759 width: f64,
760 /// Display height in points.
761 height: f64,
762 /// Optional viewBox (e.g. "0 0 100 100").
763 #[serde(default, skip_serializing_if = "Option::is_none")]
764 view_box: Option<String>,
765 /// SVG markup content (the inner XML).
766 content: String,
767 },
768
769 /// A canvas drawing primitive with arbitrary vector operations.
770 Canvas {
771 /// Display width in points.
772 width: f64,
773 /// Display height in points.
774 height: f64,
775 /// Drawing operations to execute.
776 operations: Vec<CanvasOp>,
777 },
778
779 /// A 1D barcode rendered as vector rectangles.
780 Barcode {
781 /// The data to encode.
782 data: String,
783 /// Barcode format (Code128, Code39, EAN13, EAN8, Codabar). Default: Code128.
784 #[serde(default)]
785 format: crate::barcode::BarcodeFormat,
786 /// Width in points. Defaults to available width.
787 #[serde(default, skip_serializing_if = "Option::is_none")]
788 width: Option<f64>,
789 /// Height in points. Default: 60.
790 #[serde(default = "default_barcode_height")]
791 height: f64,
792 },
793
794 /// A QR code rendered as vector rectangles.
795 QrCode {
796 /// The data to encode (URL, text, etc.).
797 data: String,
798 /// Display size in points (QR codes are always square).
799 /// Defaults to available width if omitted.
800 #[serde(default, skip_serializing_if = "Option::is_none")]
801 size: Option<f64>,
802 },
803
804 /// A bar chart rendered as native vector graphics.
805 BarChart {
806 /// Data points with labels and values.
807 data: Vec<ChartDataPoint>,
808 /// Chart width in points.
809 width: f64,
810 /// Chart height in points.
811 height: f64,
812 /// Bar color (hex string). Defaults to "#1a365d".
813 #[serde(default, skip_serializing_if = "Option::is_none")]
814 color: Option<String>,
815 /// Show X-axis labels below bars.
816 #[serde(default = "default_true")]
817 show_labels: bool,
818 /// Show value labels above bars.
819 #[serde(default)]
820 show_values: bool,
821 /// Show horizontal grid lines.
822 #[serde(default)]
823 show_grid: bool,
824 /// Optional chart title.
825 #[serde(default, skip_serializing_if = "Option::is_none")]
826 title: Option<String>,
827 },
828
829 /// A line chart rendered as native vector graphics.
830 LineChart {
831 /// Data series (each with name, data points, optional color).
832 series: Vec<ChartSeries>,
833 /// X-axis labels.
834 labels: Vec<String>,
835 /// Chart width in points.
836 width: f64,
837 /// Chart height in points.
838 height: f64,
839 /// Show dots at data points.
840 #[serde(default)]
841 show_points: bool,
842 /// Show horizontal grid lines.
843 #[serde(default)]
844 show_grid: bool,
845 /// Optional chart title.
846 #[serde(default, skip_serializing_if = "Option::is_none")]
847 title: Option<String>,
848 },
849
850 /// A pie chart rendered as native vector graphics.
851 PieChart {
852 /// Data points with labels, values, and optional colors.
853 data: Vec<ChartDataPoint>,
854 /// Chart width in points.
855 width: f64,
856 /// Chart height in points.
857 height: f64,
858 /// Whether to render as donut (hollow center).
859 #[serde(default)]
860 donut: bool,
861 /// Show legend.
862 #[serde(default)]
863 show_legend: bool,
864 /// Optional chart title.
865 #[serde(default, skip_serializing_if = "Option::is_none")]
866 title: Option<String>,
867 },
868
869 /// An area chart rendered as native vector graphics.
870 AreaChart {
871 /// Data series (each with name, data points, optional color).
872 series: Vec<ChartSeries>,
873 /// X-axis labels.
874 labels: Vec<String>,
875 /// Chart width in points.
876 width: f64,
877 /// Chart height in points.
878 height: f64,
879 /// Show horizontal grid lines.
880 #[serde(default)]
881 show_grid: bool,
882 /// Optional chart title.
883 #[serde(default, skip_serializing_if = "Option::is_none")]
884 title: Option<String>,
885 },
886
887 /// A dot plot (scatter plot) rendered as native vector graphics.
888 DotPlot {
889 /// Groups of data points.
890 groups: Vec<DotPlotGroup>,
891 /// Chart width in points.
892 width: f64,
893 /// Chart height in points.
894 height: f64,
895 /// Minimum X value. Auto-computed if not set.
896 #[serde(default, skip_serializing_if = "Option::is_none")]
897 x_min: Option<f64>,
898 /// Maximum X value. Auto-computed if not set.
899 #[serde(default, skip_serializing_if = "Option::is_none")]
900 x_max: Option<f64>,
901 /// Minimum Y value. Auto-computed if not set.
902 #[serde(default, skip_serializing_if = "Option::is_none")]
903 y_min: Option<f64>,
904 /// Maximum Y value. Auto-computed if not set.
905 #[serde(default, skip_serializing_if = "Option::is_none")]
906 y_max: Option<f64>,
907 /// X-axis label.
908 #[serde(default, skip_serializing_if = "Option::is_none")]
909 x_label: Option<String>,
910 /// Y-axis label.
911 #[serde(default, skip_serializing_if = "Option::is_none")]
912 y_label: Option<String>,
913 /// Show legend.
914 #[serde(default)]
915 show_legend: bool,
916 /// Dot radius in points.
917 #[serde(default = "default_dot_size")]
918 dot_size: f64,
919 },
920
921 /// A watermark rendered as rotated text behind page content.
922 Watermark {
923 /// The watermark text (e.g. "DRAFT", "CONFIDENTIAL").
924 text: String,
925 /// Font size in points. Default: 60.
926 #[serde(default = "default_watermark_font_size")]
927 font_size: f64,
928 /// Rotation angle in degrees (negative = counterclockwise). Default: -45.
929 #[serde(default = "default_watermark_angle")]
930 angle: f64,
931 },
932
933 /// An interactive text input field (PDF AcroForm widget).
934 TextField {
935 /// Field name, used for data extraction.
936 name: String,
937 /// Default/current value.
938 #[serde(default, skip_serializing_if = "Option::is_none")]
939 value: Option<String>,
940 /// Placeholder text displayed when empty.
941 #[serde(default, skip_serializing_if = "Option::is_none")]
942 placeholder: Option<String>,
943 /// Field width in points.
944 width: f64,
945 /// Field height in points. Default: 24.
946 #[serde(default = "default_form_field_height")]
947 height: f64,
948 /// Allow multiple lines of input.
949 #[serde(default)]
950 multiline: bool,
951 /// Mask input as password dots.
952 #[serde(default)]
953 password: bool,
954 /// Prevent editing.
955 #[serde(default)]
956 read_only: bool,
957 /// Maximum number of characters.
958 #[serde(default, skip_serializing_if = "Option::is_none")]
959 max_length: Option<u32>,
960 /// Font size in points. Default: 12.
961 #[serde(default = "default_form_font_size")]
962 font_size: f64,
963 },
964
965 /// An interactive checkbox (PDF AcroForm widget).
966 Checkbox {
967 /// Field name, used for data extraction.
968 name: String,
969 /// Default checked state.
970 #[serde(default)]
971 checked: bool,
972 /// Checkbox width in points. Default: 14.
973 #[serde(default = "default_checkbox_size")]
974 width: f64,
975 /// Checkbox height in points. Default: 14.
976 #[serde(default = "default_checkbox_size")]
977 height: f64,
978 /// Prevent editing.
979 #[serde(default)]
980 read_only: bool,
981 },
982
983 /// An interactive dropdown/combo box (PDF AcroForm widget).
984 Dropdown {
985 /// Field name, used for data extraction.
986 name: String,
987 /// Available options.
988 options: Vec<String>,
989 /// Default selected value.
990 #[serde(default, skip_serializing_if = "Option::is_none")]
991 value: Option<String>,
992 /// Field width in points.
993 width: f64,
994 /// Field height in points. Default: 24.
995 #[serde(default = "default_form_field_height")]
996 height: f64,
997 /// Prevent editing.
998 #[serde(default)]
999 read_only: bool,
1000 /// Font size in points. Default: 12.
1001 #[serde(default = "default_form_font_size")]
1002 font_size: f64,
1003 },
1004
1005 /// An interactive radio button (PDF AcroForm widget).
1006 /// Multiple RadioButtons with the same `name` form a mutually exclusive group.
1007 RadioButton {
1008 /// Group name shared by all buttons in the group.
1009 name: String,
1010 /// This button's export value.
1011 value: String,
1012 /// Default selected state.
1013 #[serde(default)]
1014 checked: bool,
1015 /// Button width in points. Default: 14.
1016 #[serde(default = "default_checkbox_size")]
1017 width: f64,
1018 /// Button height in points. Default: 14.
1019 #[serde(default = "default_checkbox_size")]
1020 height: f64,
1021 /// Prevent editing.
1022 #[serde(default)]
1023 read_only: bool,
1024 },
1025}
1026
1027/// A data point for bar charts and pie charts.
1028#[derive(Debug, Clone, Serialize, Deserialize)]
1029pub struct ChartDataPoint {
1030 pub label: String,
1031 pub value: f64,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1033 pub color: Option<String>,
1034}
1035
1036/// A data series for line charts and area charts.
1037#[derive(Debug, Clone, Serialize, Deserialize)]
1038pub struct ChartSeries {
1039 pub name: String,
1040 pub data: Vec<f64>,
1041 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 pub color: Option<String>,
1043}
1044
1045/// A group of data points for dot plots.
1046#[derive(Debug, Clone, Serialize, Deserialize)]
1047pub struct DotPlotGroup {
1048 pub name: String,
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1050 pub color: Option<String>,
1051 pub data: Vec<(f64, f64)>,
1052}
1053
1054/// A canvas drawing operation.
1055#[derive(Debug, Clone, Serialize, Deserialize)]
1056#[serde(tag = "op")]
1057pub enum CanvasOp {
1058 MoveTo {
1059 x: f64,
1060 y: f64,
1061 },
1062 LineTo {
1063 x: f64,
1064 y: f64,
1065 },
1066 BezierCurveTo {
1067 cp1x: f64,
1068 cp1y: f64,
1069 cp2x: f64,
1070 cp2y: f64,
1071 x: f64,
1072 y: f64,
1073 },
1074 QuadraticCurveTo {
1075 cpx: f64,
1076 cpy: f64,
1077 x: f64,
1078 y: f64,
1079 },
1080 ClosePath,
1081 Rect {
1082 x: f64,
1083 y: f64,
1084 width: f64,
1085 height: f64,
1086 },
1087 Circle {
1088 cx: f64,
1089 cy: f64,
1090 r: f64,
1091 },
1092 Ellipse {
1093 cx: f64,
1094 cy: f64,
1095 rx: f64,
1096 ry: f64,
1097 },
1098 Arc {
1099 cx: f64,
1100 cy: f64,
1101 r: f64,
1102 start_angle: f64,
1103 end_angle: f64,
1104 #[serde(default)]
1105 counterclockwise: bool,
1106 },
1107 Stroke,
1108 Fill,
1109 FillAndStroke,
1110 SetFillColor {
1111 r: f64,
1112 g: f64,
1113 b: f64,
1114 },
1115 SetStrokeColor {
1116 r: f64,
1117 g: f64,
1118 b: f64,
1119 },
1120 SetLineWidth {
1121 width: f64,
1122 },
1123 SetLineCap {
1124 cap: u32,
1125 },
1126 SetLineJoin {
1127 join: u32,
1128 },
1129 Save,
1130 Restore,
1131}
1132
1133/// An inline styled run within a Text node.
1134#[derive(Debug, Clone, Serialize, Deserialize)]
1135#[serde(rename_all = "camelCase")]
1136pub struct TextRun {
1137 pub content: String,
1138 #[serde(default)]
1139 pub style: crate::style::Style,
1140 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub href: Option<String>,
1142}
1143
1144/// Positioning mode for a node.
1145#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1146pub enum Position {
1147 #[default]
1148 Relative,
1149 Absolute,
1150}
1151
1152fn default_one() -> u32 {
1153 1
1154}
1155
1156fn default_barcode_height() -> f64 {
1157 60.0
1158}
1159
1160fn default_dot_size() -> f64 {
1161 4.0
1162}
1163
1164fn default_watermark_font_size() -> f64 {
1165 60.0
1166}
1167
1168fn default_watermark_angle() -> f64 {
1169 -45.0
1170}
1171
1172fn default_form_field_height() -> f64 {
1173 24.0
1174}
1175
1176fn default_form_font_size() -> f64 {
1177 12.0
1178}
1179
1180fn default_checkbox_size() -> f64 {
1181 14.0
1182}
1183
1184/// Column definition for tables.
1185#[derive(Debug, Clone, Serialize, Deserialize)]
1186pub struct ColumnDef {
1187 /// Width as a fraction (0.0-1.0) of available table width, or fixed points.
1188 pub width: ColumnWidth,
1189}
1190
1191#[derive(Debug, Clone, Serialize, Deserialize)]
1192pub enum ColumnWidth {
1193 /// Fraction of available width (0.0-1.0).
1194 Fraction(f64),
1195 /// Fixed width in points.
1196 Fixed(f64),
1197 /// Distribute remaining space evenly among Auto columns.
1198 Auto,
1199}
1200
1201/// Marker style for a `List`. Maps to CSS `list-style-type`:
1202/// - `Disc` / `Circle` / `Square` / `None` for unordered lists
1203/// - `Decimal` / `LowerAlpha` / `UpperAlpha` / `LowerRoman` / `UpperRoman`
1204/// for ordered lists
1205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1206#[serde(rename_all = "camelCase")]
1207pub enum ListMarkerType {
1208 Disc,
1209 Circle,
1210 Square,
1211 None,
1212 Decimal,
1213 LowerAlpha,
1214 UpperAlpha,
1215 LowerRoman,
1216 UpperRoman,
1217}
1218
1219fn default_list_start() -> u32 {
1220 1
1221}
1222
1223/// Where a fixed element is placed on the page.
1224#[derive(Debug, Clone, Serialize, Deserialize)]
1225pub enum FixedPosition {
1226 /// Top of the content area (below margin).
1227 Header,
1228 /// Bottom of the content area (above margin).
1229 Footer,
1230}
1231
1232/// Which pages a fixed element repeats on.
1233#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1234pub enum FixedPageFilter {
1235 /// Every page (the default).
1236 #[default]
1237 All,
1238 /// The first page only.
1239 First,
1240 /// Every page except the first.
1241 NotFirst,
1242 /// LEFT (verso) pages only — even 1-based page numbers.
1243 Left,
1244 /// RIGHT (recto) pages only — odd 1-based page numbers, including 1.
1245 Right,
1246 /// RIGHT pages except the first (used when `:first` overrides or
1247 /// suppresses a margin-box slot that `:right`/base would otherwise
1248 /// fill — `:first` outranks parity per CSS Paged Media specificity).
1249 RightNotFirst,
1250}
1251
1252impl FixedPageFilter {
1253 /// Does a fixed element with this filter appear on `page_index`
1254 /// (0-based)?
1255 pub fn applies(self, page_index: usize) -> bool {
1256 // Parity is 1-based per CSS Paged Media: page 1 (index 0) is a
1257 // right page in left-to-right page progression.
1258 let right = (page_index + 1) % 2 == 1;
1259 match self {
1260 FixedPageFilter::All => true,
1261 FixedPageFilter::First => page_index == 0,
1262 FixedPageFilter::NotFirst => page_index > 0,
1263 FixedPageFilter::Left => !right,
1264 FixedPageFilter::Right => right,
1265 FixedPageFilter::RightNotFirst => right && page_index > 0,
1266 }
1267 }
1268}
1269
1270/// The page-config family for one named page (CSS `@page <name>`).
1271///
1272/// `base` is the REAL layout config for pages in the named run — the run
1273/// starts at a forced break, so vertical margins may genuinely differ
1274/// from the document base. Horizontal margins in `base` must equal the
1275/// document base's (flow width is baked); horizontal variation is
1276/// expressed by the `display*` configs, applied as a constant x
1277/// translation at finalize exactly like `:left`/`:right`.
1278#[derive(Debug, Clone, Serialize, Deserialize)]
1279#[serde(rename_all = "camelCase")]
1280pub struct NamedPageSet {
1281 /// Real config for the run's pages (vertical real, horizontal base).
1282 pub base: PageConfig,
1283 /// Display config for every page of the run (mirrored horizontal).
1284 #[serde(default, skip_serializing_if = "Option::is_none")]
1285 pub display: Option<PageConfig>,
1286 /// Display when the run's page is the DOCUMENT first page
1287 /// (`@page <name>:first` — `:first` means page 1, per spec).
1288 #[serde(default, skip_serializing_if = "Option::is_none")]
1289 pub display_first: Option<PageConfig>,
1290 /// Display for LEFT (even 1-based) pages of the run.
1291 #[serde(default, skip_serializing_if = "Option::is_none")]
1292 pub display_left: Option<PageConfig>,
1293 /// Display for RIGHT (odd 1-based) pages of the run.
1294 #[serde(default, skip_serializing_if = "Option::is_none")]
1295 pub display_right: Option<PageConfig>,
1296}
1297
1298/// Source code location for click-to-source in the dev server inspector.
1299#[derive(Debug, Clone, Serialize, Deserialize)]
1300#[serde(rename_all = "camelCase")]
1301pub struct SourceLocation {
1302 pub file: String,
1303 pub line: u32,
1304 pub column: u32,
1305}
1306
1307impl Node {
1308 /// Create a View node with children.
1309 pub fn view(style: Style, children: Vec<Node>) -> Self {
1310 Self {
1311 kind: NodeKind::View,
1312 style,
1313 children,
1314 id: None,
1315 source_location: None,
1316 bookmark: None,
1317 href: None,
1318 alt: None,
1319 }
1320 }
1321
1322 /// Create a Text node.
1323 pub fn text(content: &str, style: Style) -> Self {
1324 Self {
1325 kind: NodeKind::Text {
1326 content: content.to_string(),
1327 href: None,
1328 runs: vec![],
1329 },
1330 style,
1331 children: vec![],
1332 id: None,
1333 source_location: None,
1334 bookmark: None,
1335 href: None,
1336 alt: None,
1337 }
1338 }
1339
1340 /// Create a Page node.
1341 pub fn page(config: PageConfig, style: Style, children: Vec<Node>) -> Self {
1342 Self {
1343 kind: NodeKind::Page { config },
1344 style,
1345 children,
1346 id: None,
1347 source_location: None,
1348 bookmark: None,
1349 href: None,
1350 alt: None,
1351 }
1352 }
1353
1354 /// Is this node breakable across pages?
1355 pub fn is_breakable(&self) -> bool {
1356 match &self.kind {
1357 NodeKind::View
1358 | NodeKind::Table { .. }
1359 | NodeKind::Text { .. }
1360 | NodeKind::Heading { .. }
1361 | NodeKind::List { .. }
1362 | NodeKind::ListItem => self.style.wrap.unwrap_or(true),
1363 NodeKind::TableRow { .. } => true,
1364 NodeKind::Image { .. } => false,
1365 NodeKind::Svg { .. } => false,
1366 NodeKind::Canvas { .. } => false,
1367 NodeKind::Barcode { .. } => false,
1368 NodeKind::QrCode { .. } => false,
1369 NodeKind::BarChart { .. } => false,
1370 NodeKind::LineChart { .. } => false,
1371 NodeKind::PieChart { .. } => false,
1372 NodeKind::AreaChart { .. } => false,
1373 NodeKind::DotPlot { .. } => false,
1374 NodeKind::Watermark { .. } => false,
1375 NodeKind::TextField { .. } => false,
1376 NodeKind::Checkbox { .. } => false,
1377 NodeKind::Dropdown { .. } => false,
1378 NodeKind::RadioButton { .. } => false,
1379 NodeKind::PageBreak | NodeKind::PageName { .. } => false,
1380 NodeKind::Fixed { .. } => false,
1381 NodeKind::Page { .. } => true,
1382 NodeKind::TableCell { .. } => true,
1383 }
1384 }
1385}