Skip to main content

normordis_pdf/
document.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    backend::{FontRef, PdfBackend},
7    backend::pdf_writer_backend::PdfWriterBackend,
8    compliance::ua::{AccessibilityConfig, StructTag, StructureTree, UaValidator},
9    elements::{
10        footnote::FootnoteAccumulator,
11        footer::{PageFooter, SectionedFooter},
12        header::SectionedHeader,
13        toc::TocEntry,
14        Element, LayoutMode, RenderContext,
15    },
16    layout::{PageFlow, TextLayoutEngine},
17    page::PageLayout,
18    styles::{SecurityClassification, TraceabilityMetadata, Watermark},
19    NormaxisPdfError, Result,
20};
21
22/// PDF conformance standard for the output document.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum PdfStandard {
26    /// Standard PDF 1.7 — no conformance requirements.
27    #[default]
28    Pdf17,
29    /// PDF/A-1b — ISO 19005-1, long-term archival.
30    PdfA1b,
31    /// PDF/A-2b — ISO 19005-2 (uses same sRGB+XMP as A-1b in this implementation).
32    PdfA2b,
33    /// PDF/UA-2 — ISO 14289-2:2024 (PDF 2.0 + accessibility structure tree).
34    /// Standalone standard: does NOT imply PDF/A conformance.
35    PdfUa2,
36}
37
38impl PdfStandard {
39    pub fn is_pdfa(self) -> bool {
40        matches!(self, Self::PdfA1b | Self::PdfA2b)
41    }
42
43    pub fn is_pdfu2(self) -> bool {
44        matches!(self, Self::PdfUa2)
45    }
46
47    pub fn xmp_part(self) -> u8 {
48        match self {
49            Self::PdfA1b => 1,
50            Self::PdfA2b => 2,
51            Self::PdfUa2 | Self::Pdf17 => 0,
52        }
53    }
54}
55
56/// Controls zlib compression level applied to PDF content streams.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum CompressionLevel {
60    None,
61    Fast,
62    #[default]
63    Default,
64    Best,
65}
66
67impl CompressionLevel {
68    pub fn to_zlib_level(self) -> u32 {
69        match self {
70            Self::None    => 0,
71            Self::Fast    => 1,
72            Self::Default => 6,
73            Self::Best    => 9,
74        }
75    }
76}
77
78/// Internal document representation.
79pub struct Document {
80    pub(crate) title: String,
81    pub(crate) style: crate::styles::DocumentStyle,
82    pub(crate) fonts: crate::fonts::FontRegistry,
83    pub(crate) header: Option<Box<dyn Element>>,
84    pub(crate) sectioned_header: Option<SectionedHeader>,
85    pub(crate) footer: Option<Box<dyn Element>>,
86    pub(crate) sectioned_footer: Option<SectionedFooter>,
87    pub(crate) watermark: Option<Watermark>,
88    pub(crate) elements: Vec<Box<dyn Element>>,
89    #[allow(dead_code)]
90    pub(crate) footnotes: Vec<(u32, Vec<String>)>,
91    #[allow(dead_code)]
92    pub(crate) toc_entries: Option<Vec<TocEntry>>,
93    pub(crate) compression: CompressionLevel,
94    pub(crate) standard: PdfStandard,
95    pub(crate) signature: Option<crate::signing::SignatureOptions>,
96    pub(crate) traceability: Option<TraceabilityMetadata>,
97    pub(crate) accessibility: AccessibilityConfig,
98}
99
100impl Document {
101    /// First pass: estimate section positions for the TOC.
102    fn collect_toc_entries_pass(&self) -> Vec<TocEntry> {
103        let layout = PageLayout::from_style(&self.style);
104        let hdr_h = if let Some(ref h) = self.header { h.estimated_height_mm() } else { 0.0 };
105        let mut cursor_y = layout.page_height_mm - layout.margin_top_mm - hdr_h;
106        let mut page = 1u32;
107        let mut entries = Vec::new();
108
109        for element in &self.elements {
110            if let LayoutMode::Flow = element.layout_mode() {
111                let h = element.estimated_height_mm();
112                if cursor_y - h < layout.margin_bottom_mm {
113                    page += 1;
114                    cursor_y = layout.page_height_mm - layout.margin_top_mm - hdr_h;
115                }
116                cursor_y -= h;
117            }
118            if let Some((level, title)) = element.as_section_info() {
119                entries.push(TocEntry { level, title: title.to_string(), page_number: page });
120            }
121        }
122        entries
123    }
124
125    pub fn render_to_bytes(mut self) -> Result<Vec<u8>> {
126        // TOC first pass
127        let toc_data = self.collect_toc_entries_pass();
128        if !toc_data.is_empty() {
129            for element in &mut self.elements {
130                element.inject_toc_entries(&toc_data);
131            }
132        }
133
134        // Auto-apply classification watermark when traceability is set and non-public.
135        if self.watermark.is_none() {
136            if let Some(ref trace) = self.traceability {
137                if trace.classification != SecurityClassification::Public {
138                    self.watermark = Some(
139                        Watermark::new(trace.classification.label_pt())
140                            .opacity(0.08)
141                            .color(trace.classification.watermark_color()),
142                    );
143                }
144            }
145        }
146
147        let Document {
148            title,
149            style,
150            fonts,
151            header,
152            sectioned_header,
153            footer,
154            sectioned_footer,
155            watermark,
156            elements,
157            footnotes: _,
158            toc_entries: _,
159            compression,
160            standard,
161            signature,
162            traceability: _,
163            accessibility,
164        } = self;
165
166        // First-pass page count
167        let total_pages = {
168            let mut flow = PageFlow::new(&style);
169            let mut pages = 1u32;
170            let hdr_h = header_height_mm(&header, &sectioned_header, 1);
171            flow.advance(hdr_h);
172            for element in &elements {
173                if let LayoutMode::Flow = element.layout_mode() {
174                    let h = element.estimated_height_mm();
175                    if flow.would_overflow(h) {
176                        flow.new_page();
177                        pages += 1;
178                        flow.advance(header_height_mm(&header, &sectioned_header, flow.page_number));
179                    }
180                    flow.advance(h);
181                }
182            }
183            pages
184        };
185
186        let (pw, ph) = style.page_size.dimensions_mm();
187        let layout = PageLayout::from_style(&style);
188
189        // If PdfUa2 is requested, enable accessibility automatically
190        let accessibility = if standard == PdfStandard::PdfUa2 && !accessibility.enabled {
191            AccessibilityConfig { enabled: true, ..accessibility }
192        } else {
193            accessibility
194        };
195
196        // ── Set up backend ────────────────────────────────────────────────────
197        let mut backend = PdfWriterBackend::new(&title, compression.to_zlib_level());
198        if standard.is_pdfa() {
199            backend.set_pdfa(standard.xmp_part());
200        }
201        if standard.is_pdfu2() {
202            backend.set_pdfu2();
203        }
204        if let Some(ref sig) = signature {
205            backend.set_signature(&sig.reason, &sig.location, sig.reserved_bytes);
206        }
207
208        // Embed fonts
209        let mut font_map: HashMap<String, FontRef> = HashMap::new();
210        for (family_name, family) in fonts.families() {
211            if let Ok(fr) = backend.embed_font(&family.regular.bytes, &family_name, false, false) {
212                font_map.insert(format!("{family_name}::regular"), fr);
213            }
214            if let Some(ref v) = family.bold {
215                if let Ok(fr) = backend.embed_font(&v.bytes, &family_name, true, false) {
216                    font_map.insert(format!("{family_name}::bold"), fr);
217                }
218            }
219            if let Some(ref v) = family.italic {
220                if let Ok(fr) = backend.embed_font(&v.bytes, &family_name, false, true) {
221                    font_map.insert(format!("{family_name}::italic"), fr);
222                }
223            }
224            if let Some(ref v) = family.bold_italic {
225                if let Ok(fr) = backend.embed_font(&v.bytes, &family_name, true, true) {
226                    font_map.insert(format!("{family_name}::bold_italic"), fr);
227                }
228            }
229        }
230
231        // Open first page
232        backend.new_page(pw, ph)?;
233
234        let default_font_family = fonts.default_family_name().to_string();
235        let layout_engine = TextLayoutEngine::new(&fonts, &style);
236        let flow = PageFlow::new(&style);
237
238        let ua_enabled = accessibility.enabled;
239        let ua_lang = accessibility.lang.clone();
240
241        let mut ctx = RenderContext {
242            backend: Box::new(backend),
243            font_map,
244            flow,
245            layout,
246            layout_engine,
247            style,
248            fonts,
249            force_page_break: false,
250            default_font_family,
251            page_number: 1,
252            total_pages,
253            resume_index: 0,
254            glyph_tracker: crate::layout::GlyphUsageTracker::new(),
255            reserved_footnotes_mm: 0.0,
256            ua_config: accessibility,
257            ua_events: StructureTree::new(),
258            mcid_counter: 0,
259            last_heading_level: None,
260        };
261
262        // Fixed elements are deferred until end of page (sorted by z_index)
263        let mut fixed_pending: Vec<(i32, &dyn Element)> = Vec::new();
264        let mut footnote_acc = FootnoteAccumulator::new();
265
266        // PDF/UA-2: wrap all content in a /Document root structure element
267        if ua_enabled {
268            ctx.ua_events.begin_group(StructTag::Document, None);
269        }
270
271        // Render watermark and header on the first page
272        render_watermark_if_any(&watermark, &mut ctx, pw, ph);
273        render_header_for_page(&header, &sectioned_header, &mut ctx);
274
275        for element in &elements {
276            match element.layout_mode() {
277                LayoutMode::Flow => {
278                    if ctx.force_page_break {
279                        ctx.force_page_break = false;
280                        flush_page(
281                            &mut ctx, &mut fixed_pending, &mut footnote_acc,
282                            &footer, &sectioned_footer,
283                            &watermark, &header, &sectioned_header,
284                            pw, ph,
285                        )?;
286                    }
287
288                    ctx.reset_resume();
289                    loop {
290                        let result = element.render(&mut ctx)?;
291                        if !result.has_more {
292                            break;
293                        }
294                        flush_page(
295                            &mut ctx, &mut fixed_pending, &mut footnote_acc,
296                            &footer, &sectioned_footer,
297                            &watermark, &header, &sectioned_header,
298                            pw, ph,
299                        )?;
300                    }
301                }
302                LayoutMode::Fixed(ref fb) => {
303                    fixed_pending.push((fb.z_index, element.as_ref()));
304                }
305            }
306        }
307
308        // Final page: render fixed elements sorted by z, then footnotes, then footer
309        fixed_pending.sort_by_key(|(z, _)| *z);
310        for (_, elem) in &fixed_pending {
311            let _ = elem.render(&mut ctx);
312        }
313        fixed_pending.clear();
314
315        footnote_acc.render_pending(&mut ctx)?;
316        render_footer_for_page(&footer, &sectioned_footer, &mut ctx);
317
318        // ── PDF/UA-2: validate + write structure tree before finalising ───────
319        if ua_enabled {
320            ctx.ua_events.end_group(); // close the /Document root
321            let events = std::mem::take(&mut ctx.ua_events.events);
322            let tree_for_validation = StructureTree { events: events.clone() };
323            let validator = UaValidator::validate(Some(&tree_for_validation), &ua_lang);
324            validator.report();
325            ctx.backend.write_structure_tree(&events, &ua_lang);
326        }
327
328        // Finalise
329        ctx.backend.finish()
330    }
331
332    pub fn render_to_file(self, path: impl AsRef<std::path::Path>) -> Result<()> {
333        let bytes = self.render_to_bytes()?;
334        std::fs::write(path, bytes).map_err(NormaxisPdfError::IoError)
335    }
336
337    /// Render with signature placeholders and return a [`PreparedPdf`] ready
338    /// for external PKCS#7 signing via [`PreparedPdf::embed_signature`].
339    pub fn render_prepared_for_signing(
340        self,
341        opts: crate::signing::SignatureOptions,
342    ) -> Result<crate::signing::PreparedPdf> {
343        let reserved = opts.reserved_bytes;
344        let bytes = Document { signature: Some(opts), ..self }.render_to_bytes()?;
345        crate::signing::extract_prepared(bytes, reserved)
346    }
347}
348
349// ── Page helpers ──────────────────────────────────────────────────────────────
350
351fn header_height_mm(
352    header: &Option<Box<dyn Element>>,
353    sectioned: &Option<SectionedHeader>,
354    page: u32,
355) -> f64 {
356    if let Some(sh) = sectioned {
357        sh.resolve(page).map(|h| h.estimated_height_mm()).unwrap_or(0.0)
358    } else if let Some(h) = header {
359        h.estimated_height_mm()
360    } else {
361        0.0
362    }
363}
364
365#[allow(clippy::too_many_arguments)]
366fn flush_page<'e>(
367    ctx: &mut RenderContext,
368    fixed_pending: &mut Vec<(i32, &'e dyn Element)>,
369    footnote_acc: &mut FootnoteAccumulator,
370    footer: &Option<Box<dyn Element>>,
371    sectioned_footer: &Option<SectionedFooter>,
372    watermark: &Option<Watermark>,
373    header: &Option<Box<dyn Element>>,
374    sectioned_header: &Option<SectionedHeader>,
375    pw: f64,
376    ph: f64,
377) -> Result<()> {
378    // Footnotes before closing page
379    footnote_acc.render_pending(ctx)?;
380    ctx.reserved_footnotes_mm = 0.0;
381
382    // Fixed elements sorted by z
383    fixed_pending.sort_by_key(|(z, _)| *z);
384    for (_, elem) in fixed_pending.iter() {
385        let _ = elem.render(ctx);
386    }
387    fixed_pending.clear();
388
389    render_footer_for_page(footer, sectioned_footer, ctx);
390
391    // Open new page
392    ctx.backend.new_page(pw, ph)?;
393    ctx.flow.new_page();
394    ctx.page_number = ctx.flow.page_number;
395    ctx.mcid_counter = 0;
396
397    render_watermark_if_any(watermark, ctx, pw, ph);
398    render_header_for_page(header, sectioned_header, ctx);
399    Ok(())
400}
401
402fn render_header_for_page(
403    header: &Option<Box<dyn Element>>,
404    sectioned: &Option<SectionedHeader>,
405    ctx: &mut RenderContext,
406) {
407    let page = ctx.page_number;
408    if let Some(sh) = sectioned {
409        if let Some(hdr) = sh.resolve(page) {
410            let _ = hdr.render(ctx);
411        }
412    } else if let Some(hdr) = header {
413        let _ = hdr.render(ctx);
414    }
415}
416
417fn render_footer_for_page(
418    footer: &Option<Box<dyn Element>>,
419    sectioned: &Option<SectionedFooter>,
420    ctx: &mut RenderContext,
421) {
422    let page = ctx.page_number;
423    let footer_ref: Option<&PageFooter> = if let Some(sf) = sectioned {
424        sf.resolve(page)
425    } else {
426        None
427    };
428
429    if let Some(f) = footer_ref {
430        let saved = ctx.flow.cursor_y_mm;
431        ctx.flow.cursor_y_mm = ctx.style.margin_bottom_mm + f.estimated_height_mm();
432        let _ = f.render(ctx);
433        ctx.flow.cursor_y_mm = saved;
434        return;
435    }
436
437    if let Some(f) = footer {
438        let h = f.estimated_height_mm();
439        let saved = ctx.flow.cursor_y_mm;
440        ctx.flow.cursor_y_mm = ctx.style.margin_bottom_mm + h;
441        let _ = f.render(ctx);
442        ctx.flow.cursor_y_mm = saved;
443    }
444}
445
446fn render_watermark_if_any(
447    watermark: &Option<Watermark>,
448    ctx: &mut RenderContext,
449    page_width_mm: f64,
450    page_height_mm: f64,
451) {
452    let Some(wm) = watermark else { return };
453    let Some(font_ref) = ctx.get_font_ref(false, false) else { return };
454
455    let cx_mm = page_width_mm / 2.0;
456    let cy_mm = page_height_mm / 2.0;
457    let half_w = ctx.fonts.get_default()
458        .measure_text_mm(&wm.text, wm.font_size, false, false) / 2.0;
459
460    if ctx.ua_config.enabled {
461        ctx.backend.begin_artifact_content();
462    }
463
464    // Use real ExtGState opacity — no color simulation.
465    let _ = ctx.backend.set_opacity(wm.opacity);
466    let _ = ctx.backend.draw_text_rotated(
467        &wm.text,
468        cx_mm, cy_mm,
469        wm.font_size,
470        font_ref,
471        &wm.color,
472        wm.angle_deg,
473        half_w,
474    );
475    ctx.backend.reset_opacity();
476
477    if ctx.ua_config.enabled {
478        ctx.backend.end_tagged_content();
479    }
480}