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