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