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}