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