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