Skip to main content

lightweight_pdf_core/
document.rs

1use crate::element::Element;
2use std::rc::Rc;
3
4/// Page formats supported for documents. Dimensions in PDF points (1/72 inch).
5#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
7#[derive(Clone, Copy, PartialEq, Debug)]
8pub enum PageFormat {
9    A3,
10    A4,
11    A5,
12    Letter,
13    Legal,
14    Custom(f32, f32),
15}
16
17impl PageFormat {
18    /// (width, height) in points, portrait.
19    pub fn size(&self) -> (f32, f32) {
20        match self {
21            PageFormat::A3 => (841.8898, 1190.5512),
22            PageFormat::A4 => (595.2756, 841.8898),
23            PageFormat::A5 => (419.5276, 595.2756),
24            PageFormat::Letter => (612.0, 792.0),
25            PageFormat::Legal => (612.0, 1008.0),
26            PageFormat::Custom(w, h) => (*w, *h),
27        }
28    }
29}
30
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "snake_case"))]
32#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
33#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
34pub enum Orientation {
35    #[default]
36    Portrait,
37    Landscape,
38}
39
40#[cfg_attr(
41    feature = "serde",
42    derive(serde::Serialize, serde::Deserialize),
43    serde(deny_unknown_fields, default)
44)]
45#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
46#[derive(Clone, Debug, Default)]
47pub struct DocumentMetadata {
48    pub title: Option<String>,
49    pub author: Option<String>,
50    pub subject: Option<String>,
51    pub keywords: Option<String>,
52    pub creator: Option<String>,
53    pub creation_date: Option<PdfDate>,
54    pub mod_date: Option<PdfDate>,
55}
56
57/// A UTC timestamp for `/CreationDate`/`/ModDate`. Always an explicit
58/// caller-supplied value, never read from the system clock: `wasm32-unknown-unknown`
59/// has none, and reproducible output (same `Document` -> byte-identical
60/// PDF) is a feature, not an accident.
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(deny_unknown_fields))]
62#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
63#[derive(Clone, Copy, PartialEq, Eq, Debug)]
64pub struct PdfDate {
65    pub year: u16,
66    pub month: u8,
67    pub day: u8,
68    pub hour: u8,
69    pub minute: u8,
70    pub second: u8,
71}
72
73impl PdfDate {
74    pub fn new(year: u16, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> Self {
75        PdfDate {
76            year,
77            month,
78            day,
79            hour,
80            minute,
81            second,
82        }
83    }
84
85    /// `D:YYYYMMDDHHmmSSZ` — the PDF date string format (ISO/IEC 32000-1
86    /// 7.9.4), UTC only (no offset support needed here).
87    pub fn to_pdf_string(self) -> String {
88        format!(
89            "D:{:04}{:02}{:02}{:02}{:02}{:02}Z",
90            self.year, self.month, self.day, self.hour, self.minute, self.second
91        )
92    }
93
94    /// ISO 8601, as XMP (`xmp:CreateDate`/`xmp:ModifyDate`) wants it — the
95    /// same fields as `to_pdf_string`, just reordered/repunctuated, not a
96    /// second date representation (issue #25).
97    pub fn to_xmp_string(self) -> String {
98        format!(
99            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
100            self.year, self.month, self.day, self.hour, self.minute, self.second
101        )
102    }
103}
104
105#[cfg_attr(
106    feature = "serde",
107    derive(serde::Serialize, serde::Deserialize),
108    serde(deny_unknown_fields, default)
109)]
110#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
111#[derive(Clone, Copy, PartialEq, Debug, Default)]
112pub struct Margin {
113    pub top: f32,
114    pub right: f32,
115    pub bottom: f32,
116    pub left: f32,
117}
118
119impl Margin {
120    pub fn symmetric(horizontal: f32, vertical: f32) -> Self {
121        Margin {
122            top: vertical,
123            right: horizontal,
124            bottom: vertical,
125            left: horizontal,
126        }
127    }
128
129    pub fn all(value: f32) -> Self {
130        Margin {
131            top: value,
132            right: value,
133            bottom: value,
134            left: value,
135        }
136    }
137}
138
139/// Passed to `Header`/`Footer` closures on every (re-)evaluation. Plain data
140/// only, so it can live in `lightweight-pdf-core` without pulling in layout/font
141/// knowledge (ADR-010).
142#[derive(Clone, Copy, Debug)]
143pub struct PageContext {
144    pub page: usize,
145    pub total_pages: usize,
146}
147
148type HeaderFooterFn = Rc<dyn Fn(&PageContext) -> Element>;
149
150/// A header band with a fixed, document-creation-time height (ADR-011): the
151/// closure may vary its content per page but never the reserved band size.
152#[derive(Clone)]
153pub struct Header {
154    pub height: f32,
155    pub content: HeaderFooterFn,
156}
157
158impl Header {
159    pub fn new(height: f32, content: impl Fn(&PageContext) -> Element + 'static) -> Self {
160        Header {
161            height,
162            content: Rc::new(content),
163        }
164    }
165}
166
167#[derive(Clone)]
168pub struct Footer {
169    pub height: f32,
170    pub content: HeaderFooterFn,
171}
172
173impl Footer {
174    pub fn new(height: f32, content: impl Fn(&PageContext) -> Element + 'static) -> Self {
175        Footer {
176            height,
177            content: Rc::new(content),
178        }
179    }
180}
181
182#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(deny_unknown_fields))]
183#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
184#[derive(Clone)]
185pub struct Document {
186    pub page_format: PageFormat,
187    #[cfg_attr(feature = "serde", serde(default))]
188    pub orientation: Orientation,
189    #[cfg_attr(feature = "serde", serde(default))]
190    pub margin: Margin,
191    /// Not representable in the JSON schema (issue #17 V1 scope): the
192    /// content is a Rust closure, re-evaluated per page. Always `None` on
193    /// a JSON-loaded `Document`; `Document::to_json` refuses to serialize
194    /// a `Document` that has one set rather than silently dropping it.
195    #[cfg_attr(feature = "serde", serde(skip))]
196    pub header: Option<Header>,
197    #[cfg_attr(feature = "serde", serde(skip))]
198    pub footer: Option<Footer>,
199    #[cfg_attr(feature = "serde", serde(skip, default = "default_visible_from"))]
200    pub header_visible_from: usize,
201    #[cfg_attr(feature = "serde", serde(skip, default = "default_visible_from"))]
202    pub footer_visible_from: usize,
203    #[cfg_attr(feature = "serde", serde(default))]
204    pub watermark: Option<crate::watermark::Watermark>,
205    #[cfg_attr(feature = "serde", serde(default))]
206    pub metadata: DocumentMetadata,
207    /// `None` (the default) means every element renders exactly as it
208    /// always did — `Document::theme(..)` opts in per-document, resolved
209    /// once per element as it's `.add()`-ed (see `theme::apply_theme`).
210    #[cfg_attr(feature = "serde", serde(default))]
211    pub theme: Option<crate::theme::Theme>,
212    /// Set by `.pdf_a3b()` (issue #25): asks the facade to write a
213    /// PDF/A-3b-conformant document (XMP metadata, `/OutputIntent` with an
214    /// embedded sRGB ICC profile, transparency-group colour space) instead
215    /// of the default output. Always present on `Document` regardless of
216    /// the facade's `pdf-a` Cargo feature (this flag itself costs
217    /// nothing) — `render()` returns `RenderError::PdfAFeatureDisabled` if
218    /// this is `true` but that feature isn't compiled in, rather than
219    /// silently rendering a non-conformant PDF.
220    #[cfg_attr(feature = "serde", serde(default))]
221    pub pdf_a3b: bool,
222    /// Set by `.zugferd_xml(bytes)` (issue #26): the raw bytes of a
223    /// caller-supplied ZUGFeRD/Factur-X invoice XML (EN 16931/Comfort
224    /// profile) to embed as an associated file. This crate embeds only —
225    /// it never generates or validates that XML itself (see ADR-018 in
226    /// the local `plan/00-decisions.md`). Not representable in the JSON
227    /// schema (same reasoning as `Header`/`Footer`: `to_json()` refuses
228    /// outright rather than silently dropping it).
229    #[cfg_attr(feature = "serde", serde(skip))]
230    pub zugferd_xml: Option<Vec<u8>>,
231    /// Set by `.pdf_ua()` (issue #27): asks the facade to write a Tagged
232    /// PDF/PDF-UA-conformant document — a structure tree (`/StructTreeRoot`,
233    /// one `/StructElem` per heading/paragraph/table/list/figure),
234    /// marked content (`BDC`/`EMC` with MCIDs) in every content stream,
235    /// and watermark/header/footer content marked as artifacts rather
236    /// than structure. Always present regardless of the facade's
237    /// `tagged-pdf` Cargo feature (this flag costs nothing) —
238    /// `render()` returns `RenderError::TaggedPdfFeatureDisabled` if this
239    /// is `true` but that feature isn't compiled in.
240    #[cfg_attr(feature = "serde", serde(default))]
241    pub pdf_ua: bool,
242    /// Document natural language (e.g. `"en-US"`, `"de-DE"`) for the
243    /// Catalog's `/Lang` entry — required for PDF/UA, meaningful even
244    /// without it (screen readers use `/Lang` to pick a voice/language).
245    #[cfg_attr(feature = "serde", serde(default))]
246    pub lang: Option<String>,
247    #[cfg_attr(feature = "serde", serde(default))]
248    pub children: Vec<Element>,
249}
250
251#[cfg(feature = "serde")]
252fn default_visible_from() -> usize {
253    1
254}
255
256impl Document {
257    pub fn new(page_format: PageFormat) -> Self {
258        Document {
259            page_format,
260            orientation: Orientation::default(),
261            margin: Margin::default(),
262            header: None,
263            footer: None,
264            header_visible_from: 1,
265            footer_visible_from: 1,
266            watermark: None,
267            metadata: DocumentMetadata::default(),
268            theme: None,
269            pdf_a3b: false,
270            zugferd_xml: None,
271            pdf_ua: false,
272            lang: None,
273            children: Vec::new(),
274        }
275    }
276
277    pub fn theme(mut self, theme: crate::theme::Theme) -> Self {
278        self.theme = Some(theme);
279        self
280    }
281
282    /// Opt in to PDF/A-3b-conformant output (issue #25) — see
283    /// `Document::pdf_a3b`'s field doc comment. Needs the facade's `pdf-a`
284    /// Cargo feature; without it, `render()` returns
285    /// `RenderError::PdfAFeatureDisabled` rather than silently ignoring
286    /// this.
287    pub fn pdf_a3b(mut self) -> Self {
288        self.pdf_a3b = true;
289        self
290    }
291
292    /// Embeds `xml` as the document's ZUGFeRD/Factur-X invoice data
293    /// (EN 16931/Comfort profile, issue #26) — implies `.pdf_a3b()`
294    /// (ZUGFeRD/Factur-X *is* a PDF/A-3 file with an embedded invoice,
295    /// not an independent opt-in). `xml` must already be a valid EN
296    /// 16931 CrossIndustryInvoice document; this crate embeds it
297    /// byte-for-byte and never generates or validates the XML itself
298    /// (ADR-018).
299    pub fn zugferd_xml(mut self, xml: impl Into<Vec<u8>>) -> Self {
300        self.pdf_a3b = true;
301        self.zugferd_xml = Some(xml.into());
302        self
303    }
304
305    /// Opt in to Tagged PDF/PDF-UA output (issue #27) — see
306    /// `Document::pdf_ua`'s field doc comment. Implies `.pdf_a3b()`: both
307    /// need the same XMP/`OutputIntent` machinery, and a combined
308    /// PDF/A+PDF/UA document (archival *and* accessible) is what most
309    /// real producers of this document class actually want — PDF/UA
310    /// without PDF/A isn't a supported combination (ADR-019 in the local
311    /// `plan/00-decisions.md`). Needs the facade's `tagged-pdf` Cargo
312    /// feature; without it, `render()` returns
313    /// `RenderError::TaggedPdfFeatureDisabled`.
314    pub fn pdf_ua(mut self) -> Self {
315        self.pdf_a3b = true;
316        self.pdf_ua = true;
317        self
318    }
319
320    /// Sets the Catalog's `/Lang` (e.g. `"en-US"`).
321    pub fn lang(mut self, lang: impl Into<String>) -> Self {
322        self.lang = Some(lang.into());
323        self
324    }
325
326    /// Effective page dimensions (width, height) in PDF points, accounting for orientation.
327    pub fn page_size(&self) -> (f32, f32) {
328        let (w, h) = self.page_format.size();
329        match self.orientation {
330            Orientation::Portrait => (w, h),
331            Orientation::Landscape => (h, w),
332        }
333    }
334
335    pub fn orientation(mut self, orientation: Orientation) -> Self {
336        self.orientation = orientation;
337        self
338    }
339
340    pub fn landscape(mut self) -> Self {
341        self.orientation = Orientation::Landscape;
342        self
343    }
344
345    pub fn portrait(mut self) -> Self {
346        self.orientation = Orientation::Portrait;
347        self
348    }
349
350    pub fn title(mut self, title: impl Into<String>) -> Self {
351        self.metadata.title = Some(title.into());
352        self
353    }
354
355    pub fn author(mut self, author: impl Into<String>) -> Self {
356        self.metadata.author = Some(author.into());
357        self
358    }
359
360    pub fn subject(mut self, subject: impl Into<String>) -> Self {
361        self.metadata.subject = Some(subject.into());
362        self
363    }
364
365    pub fn keywords(mut self, keywords: impl Into<String>) -> Self {
366        self.metadata.keywords = Some(keywords.into());
367        self
368    }
369
370    pub fn creator(mut self, creator: impl Into<String>) -> Self {
371        self.metadata.creator = Some(creator.into());
372        self
373    }
374
375    pub fn creation_date(mut self, date: PdfDate) -> Self {
376        self.metadata.creation_date = Some(date);
377        self
378    }
379
380    pub fn mod_date(mut self, date: PdfDate) -> Self {
381        self.metadata.mod_date = Some(date);
382        self
383    }
384
385    pub fn margin(mut self, margin: Margin) -> Self {
386        self.margin = margin;
387        self
388    }
389
390    pub fn header(mut self, header: Header) -> Self {
391        self.header = Some(header);
392        self
393    }
394
395    pub fn footer(mut self, footer: Footer) -> Self {
396        self.footer = Some(footer);
397        self
398    }
399
400    /// First page number (1-based) on which the header is drawn. Cover-page
401    /// convenience, see `plan/02-elementcatalog-and-features.md` ("Deckblatt
402    /// / Titelseite").
403    pub fn header_visible_from(mut self, page: usize) -> Self {
404        self.header_visible_from = page;
405        self
406    }
407
408    pub fn footer_visible_from(mut self, page: usize) -> Self {
409        self.footer_visible_from = page;
410        self
411    }
412
413    /// Sets a document-wide diagonal stamp ("ENTWURF", "STORNIERT") — an
414    /// independent layer, not a normal flow element (Phase 6).
415    pub fn watermark(mut self, watermark: crate::watermark::Watermark) -> Self {
416        self.watermark = Some(watermark);
417        self
418    }
419
420    pub fn add(&mut self, element: impl Into<Element>) -> &mut Self {
421        let mut element = element.into();
422        if let Some(theme) = &self.theme {
423            crate::theme::apply_theme(&mut element, theme);
424        }
425        self.children.push(element);
426        self
427    }
428}
429
430// ---------------------------------------------------------------------
431// JSON (issue #17): `Document` ↔ JSON, behind the `serde` feature.
432// Header/Footer aren't representable (Rust closures) — excluded from the
433// wire format entirely rather than silently dropped; `to_json` refuses
434// outright if either is set.
435// ---------------------------------------------------------------------
436
437#[cfg(feature = "serde")]
438pub const CURRENT_SCHEMA_VERSION: u32 = 1;
439
440/// The versioned envelope every JSON document is wrapped in (ADR-009: an
441/// external entry point needs a schema version from day one to stay
442/// extensible). Deliberately not `#[serde(flatten)]`ed into `Document` —
443/// `flatten` and `deny_unknown_fields` don't compose in serde, and
444/// "unknown fields are a clear error, not silent loss" is an explicit
445/// acceptance criterion.
446#[cfg(feature = "serde")]
447#[derive(serde::Serialize, serde::Deserialize)]
448#[serde(deny_unknown_fields)]
449#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
450pub struct DocumentSchema {
451    pub schema_version: u32,
452    pub document: Document,
453}
454
455#[cfg(feature = "serde")]
456#[derive(Debug)]
457pub enum DocumentJsonError {
458    /// `schema_version` isn't one this version of the crate understands.
459    UnsupportedSchemaVersion(u32),
460    /// `Document::to_json` on a `Document` with a `header`/`footer` set —
461    /// neither is representable in JSON, so refusing beats silently
462    /// dropping them.
463    HeaderOrFooterNotSupported,
464    /// `Document::to_json` on a `Document` with `zugferd_xml` set (issue
465    /// #26) — not representable in JSON either, same reasoning.
466    ZugferdXmlNotSupported,
467    Json(serde_json::Error),
468    /// From `Document::from_template` (issue #18): placeholder/`$each`
469    /// resolution against the data tree failed before JSON parsing of
470    /// the resolved document even started.
471    Template(crate::template::TemplateError),
472}
473
474#[cfg(feature = "serde")]
475impl std::fmt::Display for DocumentJsonError {
476    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477        match self {
478            DocumentJsonError::UnsupportedSchemaVersion(v) => {
479                write!(
480                    f,
481                    "unsupported schema_version {v} (this crate understands {CURRENT_SCHEMA_VERSION})"
482                )
483            }
484            DocumentJsonError::HeaderOrFooterNotSupported => {
485                write!(
486                    f,
487                    "Document::to_json: header/footer aren't representable in the JSON schema (issue #17 V1 scope)"
488                )
489            }
490            DocumentJsonError::ZugferdXmlNotSupported => {
491                write!(
492                    f,
493                    "Document::to_json: zugferd_xml isn't representable in the JSON schema (issue #26)"
494                )
495            }
496            DocumentJsonError::Json(e) => write!(f, "{e}"),
497            DocumentJsonError::Template(e) => write!(f, "{e}"),
498        }
499    }
500}
501
502#[cfg(feature = "serde")]
503impl std::error::Error for DocumentJsonError {
504    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
505        match self {
506            DocumentJsonError::Json(e) => Some(e),
507            DocumentJsonError::Template(e) => Some(e),
508            _ => None,
509        }
510    }
511}
512
513#[cfg(feature = "serde")]
514impl Document {
515    /// Parses `{"schema_version": N, "document": { .. }}`. Unknown fields
516    /// anywhere in the tree are a clear error, never silently dropped.
517    pub fn from_json(json: &str) -> Result<Document, DocumentJsonError> {
518        let schema: DocumentSchema = serde_json::from_str(json).map_err(DocumentJsonError::Json)?;
519        if schema.schema_version != CURRENT_SCHEMA_VERSION {
520            return Err(DocumentJsonError::UnsupportedSchemaVersion(schema.schema_version));
521        }
522        Ok(schema.document)
523    }
524
525    /// `crate::template::render_template` + `from_json` in one call — a
526    /// template document (with `{{path}}` placeholders and/or `$each`
527    /// repetition, see the `template` module) plus a separate data
528    /// document, no Rust code needed (issue #18).
529    pub fn from_template(
530        template_json: &str,
531        data_json: &str,
532        on_missing: crate::template::MissingPlaceholder,
533    ) -> Result<Document, DocumentJsonError> {
534        let resolved = crate::template::render_template(template_json, data_json, on_missing).map_err(DocumentJsonError::Template)?;
535        Document::from_json(&resolved)
536    }
537
538    /// The inverse of `from_json` — round-trips to a byte-identical
539    /// rendered PDF as long as neither `header` nor `footer` is set.
540    pub fn to_json(&self) -> Result<String, DocumentJsonError> {
541        if self.header.is_some() || self.footer.is_some() {
542            return Err(DocumentJsonError::HeaderOrFooterNotSupported);
543        }
544        if self.zugferd_xml.is_some() {
545            return Err(DocumentJsonError::ZugferdXmlNotSupported);
546        }
547        let schema = DocumentSchema {
548            schema_version: CURRENT_SCHEMA_VERSION,
549            document: self.clone(),
550        };
551        serde_json::to_string(&schema).map_err(DocumentJsonError::Json)
552    }
553}
554
555#[cfg(all(test, feature = "serde"))]
556mod json_tests {
557    use super::*;
558    use crate::element::Text;
559    use crate::style::{Align, Color};
560
561    fn sample_document() -> Document {
562        let mut doc = Document::new(PageFormat::A4).margin(Margin::all(30.0)).title("Rechnung");
563        doc.add(Text::new("Hello").size(18.0).color(Color::rgb(200, 0, 0)).align(Align::Center));
564        doc
565    }
566
567    #[test]
568    fn round_trip_preserves_page_format_and_children() {
569        let json = sample_document().to_json().expect("to_json should succeed");
570        assert!(
571            json.contains("\"schema_version\":1"),
572            "expected a versioned root field, got: {json}"
573        );
574        let doc = Document::from_json(&json).expect("from_json should succeed");
575        assert_eq!(doc.page_format, PageFormat::A4);
576        assert_eq!(doc.metadata.title.as_deref(), Some("Rechnung"));
577        assert_eq!(doc.children.len(), 1);
578        let Element::Text(t) = &doc.children[0] else {
579            panic!("expected a Text child");
580        };
581        assert_eq!(t.content, "Hello");
582        assert_eq!(t.style.size, 18.0);
583        assert_eq!(t.style.color, Color::rgb(200, 0, 0));
584        assert_eq!(t.style.align, Align::Center);
585    }
586
587    #[test]
588    fn unknown_field_is_a_clear_error_not_silent_loss() {
589        let json = r#"{"schema_version":1,"document":{"page_format":"A4","typo_field":true}}"#;
590        let Err(err) = Document::from_json(json) else {
591            panic!("an unknown field must be rejected");
592        };
593        let message = err.to_string();
594        assert!(
595            message.contains("typo_field") || message.contains("unknown field"),
596            "expected the error to mention the unknown field, got: {message}"
597        );
598    }
599
600    #[test]
601    fn to_json_refuses_a_document_with_a_header() {
602        let mut doc = sample_document();
603        doc = doc.header(Header::new(20.0, |_| Element::Text(Text::new("Header"))));
604        assert!(matches!(doc.to_json(), Err(DocumentJsonError::HeaderOrFooterNotSupported)));
605    }
606
607    #[test]
608    fn unsupported_schema_version_is_rejected() {
609        let json = r#"{"schema_version":99,"document":{"page_format":"A4"}}"#;
610        assert!(matches!(
611            Document::from_json(json),
612            Err(DocumentJsonError::UnsupportedSchemaVersion(99))
613        ));
614    }
615}