Skip to main content

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}