Skip to main content

normordis_pdf/template/
ndf_pipeline.rs

1use base64::Engine as _;
2use regex::Regex;
3use serde_json::Value;
4
5use super::data::NdtData;
6use super::resolver;
7use crate::ndf::{
8    audit::{Actor, AuditEvent, EventType, NdfAudit},
9    integrity::{canonical_hash, NdfIntegrity},
10    NdfDocument, NdfEmbeddedFont, NdfMeta, NdfOrigin, NDF_VERSION,
11};
12use crate::{NormaxisPdfError, Result};
13
14// ── CompileOptions ────────────────────────────────────────────────────────────
15
16/// Options controlling how `compile_ndt()` builds an `NdfDocument`.
17#[derive(Debug, Clone)]
18pub struct CompileOptions {
19    /// Unique document identifier.
20    /// If `None`, a UUID v4 is generated automatically.
21    pub document_id: Option<String>,
22
23    /// Actor responsible for this generation (stored in audit chain).
24    pub generated_by: Actor,
25
26    /// NDT template identifier for origin traceability.
27    pub ndt_template_id: Option<String>,
28
29    /// SHA-256 hash of the NDT template file.
30    /// If `None`, computed automatically from the `ndt` input.
31    pub ndt_template_hash: Option<String>,
32
33    /// If `true` (default), error when any `{{placeholder}}` remains unresolved.
34    pub validate_resolved: bool,
35}
36
37impl Default for CompileOptions {
38    fn default() -> Self {
39        Self {
40            document_id: None,
41            generated_by: Actor::System {
42                id: "normordis-pdf".into(),
43                version: Some(env!("CARGO_PKG_VERSION").into()),
44                instance_id: None,
45            },
46            ndt_template_id: None,
47            ndt_template_hash: None,
48            validate_resolved: true,
49        }
50    }
51}
52
53// ── compile_ndt ───────────────────────────────────────────────────────────────
54
55/// Compiles an NDT template + data into a fully resolved `NdfDocument`.
56///
57/// Pipeline:
58/// 1. Parse NDT (JSON or TOML)
59/// 2. Validate required placeholders
60/// 3. Deep-substitute `{{placeholders}}` in all body string fields
61/// 4. Check no unresolved placeholders remain (`validate_resolved`)
62/// 5. Compute integrity hashes (RFC 8785 / JCS)
63/// 6. Build and return `NdfDocument`
64///
65/// After calling this, use [`NdfDocument::embed_font`] for any custom fonts
66/// used in the template before persisting the NDF.
67pub fn compile_ndt(ndt: &str, data: &NdtData, options: CompileOptions) -> Result<NdfDocument> {
68    let doc = super::parse_ndt(ndt)
69        .map_err(|e| NormaxisPdfError::NdfCompileError(e.to_string()))?;
70
71    if let Some(ref placeholders) = doc.placeholders {
72        super::validator::validate(placeholders, data)
73            .map_err(|e| NormaxisPdfError::NdfCompileError(e.to_string()))?;
74    }
75
76    // Serialize and resolve body + style
77    let body_val = serde_json::to_value(&doc.body)
78        .map_err(|e| NormaxisPdfError::SerdeError(e.to_string()))?;
79    let resolved_content = resolve_value_placeholders(body_val, data);
80
81    let styles_val = serde_json::to_value(&doc.style)
82        .map_err(|e| NormaxisPdfError::SerdeError(e.to_string()))?;
83
84    if options.validate_resolved {
85        let content_str = serde_json::to_string(&resolved_content)
86            .map_err(|e| NormaxisPdfError::SerdeError(e.to_string()))?;
87        let re = Regex::new(r"\{\{[a-zA-Z0-9_.]+\}\}").expect("static regex");
88        if let Some(m) = re.find(&content_str) {
89            return Err(NormaxisPdfError::NdfCompileError(format!(
90                "unresolved placeholder '{}' in content after substitution",
91                m.as_str()
92            )));
93        }
94    }
95
96    let now = chrono::Utc::now().to_rfc3339();
97
98    let meta_title = doc
99        .meta
100        .as_ref()
101        .and_then(|m| m.title.clone())
102        .unwrap_or_default();
103    let meta_compat = doc.meta.as_ref().and_then(|m| m.compat_mode);
104    let meta = NdfMeta {
105        title: meta_title,
106        entity: String::new(),
107        entity_id: None,
108        lang: "pt-PT".into(),
109        document_ref: None,
110        document_type: None,
111        classification: "public".into(),
112        subject: None,
113        keywords: None,
114        created_at: now.clone(),
115        valid_from: None,
116        valid_until: None,
117        supersedes: None,
118        compat_mode: meta_compat,
119        numbering: None,
120    };
121    let meta_val = serde_json::to_value(&meta)
122        .map_err(|e| NormaxisPdfError::SerdeError(e.to_string()))?;
123
124    let integrity = NdfIntegrity::compute(&resolved_content, &styles_val, &meta_val)?;
125    let document_id = options
126        .document_id
127        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
128
129    let ndt_template_hash = options.ndt_template_hash.unwrap_or_else(|| {
130        let v = serde_json::from_str::<Value>(ndt).unwrap_or(Value::Null);
131        canonical_hash(&v)
132    });
133
134    let first_event = AuditEvent {
135        seq: 1,
136        event_type: EventType::DocumentGenerated,
137        timestamp: now.clone(),
138        actor: options.generated_by.clone(),
139        content_hash: Some(integrity.content_hash.clone()),
140        note: None,
141        extra: Default::default(),
142    };
143
144    Ok(NdfDocument {
145        ndf: NDF_VERSION.into(),
146        origin: NdfOrigin {
147            ndt_template_id: options.ndt_template_id,
148            ndt_version: None,
149            ndt_template_hash: Some(ndt_template_hash),
150            ndt_data_hash: None,
151            engine_version: env!("CARGO_PKG_VERSION").into(),
152            engine_backend: "normordis-pdf".into(),
153            generated_at: now,
154            generated_by: options.generated_by,
155        },
156        revision: None,
157        meta,
158        output: serde_json::to_value(&doc.output).ok(),
159        styles: styles_val,
160        content: resolved_content,
161        page: serde_json::to_value(&doc.page).ok(),
162        embedded_fonts: vec![],
163        integrity,
164        audit: NdfAudit {
165            document_id,
166            events: vec![first_event],
167        },
168        outputs: vec![],
169        signatures: vec![],
170    })
171}
172
173// ── parse_ndf / verify_ndf ────────────────────────────────────────────────────
174
175/// Parses an NDF document from JSON (canonical or pretty-printed).
176pub fn parse_ndf(json: &str) -> Result<NdfDocument> {
177    serde_json::from_str(json).map_err(|e| NormaxisPdfError::SerdeError(e.to_string()))
178}
179
180/// Verifies the integrity hashes of an NDF document.
181pub fn verify_ndf(json: &str) -> Result<crate::ndf::integrity::IntegrityReport> {
182    let ndf = parse_ndf(json)?;
183    ndf.verify_integrity()
184}
185
186// ── render_ndf ────────────────────────────────────────────────────────────────
187
188/// Renders an NDF document to PDF bytes.
189///
190/// Fonts embedded in `ndf.embedded_fonts` are loaded automatically.
191/// For additional or override fonts, use [`render_ndf_with_fonts`].
192pub fn render_ndf(ndf_json: &str) -> Result<Vec<u8>> {
193    render_ndf_inner(ndf_json, None)
194}
195
196/// Renders an NDF document to PDF bytes, supplementing with an external font registry.
197///
198/// Fonts in `extra` take precedence over fonts embedded in the NDF.
199/// Use this when the NDF was archived without embedding font bytes and the
200/// original fonts are available at render time.
201pub fn render_ndf_with_fonts(
202    ndf_json: &str,
203    extra: &crate::fonts::FontRegistry,
204) -> Result<Vec<u8>> {
205    render_ndf_inner(ndf_json, Some(extra))
206}
207
208fn render_ndf_inner(
209    ndf_json: &str,
210    extra_fonts: Option<&crate::fonts::FontRegistry>,
211) -> Result<Vec<u8>> {
212    let ndf = parse_ndf(ndf_json)?;
213    let (ndt_doc, fonts) = rebuild_ndt_doc_and_fonts(&ndf, extra_fonts)?;
214    let (standard, compression, accessibility) = parse_output_options(ndf.output.as_ref());
215
216    let style = crate::styles::DocumentStyle::default();
217    let empty_data = empty_ndt_data();
218    let elements = super::renderer::render_template(&ndt_doc, &empty_data, &style)
219        .map_err(|e| NormaxisPdfError::Template(e.to_string()))?;
220
221    crate::document::Document {
222        title: ndf.meta.title,
223        style,
224        fonts,
225        header: None,
226        sectioned_header: None,
227        footer: None,
228        sectioned_footer: None,
229        watermark: None,
230        elements,
231        footnotes: vec![],
232        toc_entries: None,
233        compression,
234        standard,
235        signature: None,
236        traceability: None,
237        accessibility,
238    }
239    .render_to_bytes()
240}
241
242// ── render_ndf_prepared_for_signing ──────────────────────────────────────────
243
244/// Renders an NDF document to a [`PreparedPdf`] ready for external PKCS#7 signing.
245pub fn render_ndf_prepared_for_signing(
246    ndf_json: &str,
247    opts: crate::signing::SignatureOptions,
248) -> Result<crate::signing::PreparedPdf> {
249    render_ndf_prepared_for_signing_inner(ndf_json, opts, None)
250}
251
252/// Renders an NDF document to a [`PreparedPdf`], supplementing with an external font registry.
253pub fn render_ndf_prepared_for_signing_with_fonts(
254    ndf_json: &str,
255    opts: crate::signing::SignatureOptions,
256    extra: &crate::fonts::FontRegistry,
257) -> Result<crate::signing::PreparedPdf> {
258    render_ndf_prepared_for_signing_inner(ndf_json, opts, Some(extra))
259}
260
261fn render_ndf_prepared_for_signing_inner(
262    ndf_json: &str,
263    opts: crate::signing::SignatureOptions,
264    extra_fonts: Option<&crate::fonts::FontRegistry>,
265) -> Result<crate::signing::PreparedPdf> {
266    let ndf = parse_ndf(ndf_json)?;
267    let (ndt_doc, fonts) = rebuild_ndt_doc_and_fonts(&ndf, extra_fonts)?;
268    let (standard, compression, accessibility) = parse_output_options(ndf.output.as_ref());
269
270    let style = crate::styles::DocumentStyle::default();
271    let empty_data = empty_ndt_data();
272    let elements = super::renderer::render_template(&ndt_doc, &empty_data, &style)
273        .map_err(|e| NormaxisPdfError::Template(e.to_string()))?;
274
275    crate::document::Document {
276        title: ndf.meta.title,
277        style,
278        fonts,
279        header: None,
280        sectioned_header: None,
281        footer: None,
282        sectioned_footer: None,
283        watermark: None,
284        elements,
285        footnotes: vec![],
286        toc_entries: None,
287        compression,
288        standard,
289        signature: None,
290        traceability: None,
291        accessibility,
292    }
293    .render_prepared_for_signing(opts)
294}
295
296// ── Shared helpers ────────────────────────────────────────────────────────────
297
298/// Reconstruct a renderable `NdtDocument` and a `FontRegistry` from a parsed NDF.
299fn rebuild_ndt_doc_and_fonts(
300    ndf: &NdfDocument,
301    extra_fonts: Option<&crate::fonts::FontRegistry>,
302) -> Result<(super::model::NdtDocument, crate::fonts::FontRegistry)> {
303    let body: Vec<super::model::BodyElement> =
304        serde_json::from_value(ndf.content.clone())
305            .map_err(|e| NormaxisPdfError::SerdeError(e.to_string()))?;
306
307    let page: Option<super::model::NdtPage> =
308        ndf.page.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok());
309
310    let ndt_doc = super::model::NdtDocument {
311        ndt: "2.1.0".into(),
312        id: None,
313        meta: Some(super::model::NdtMeta {
314            title: Some(ndf.meta.title.clone()),
315            compat_mode: ndf.meta.compat_mode,
316            ..Default::default()
317        }),
318        style: serde_json::from_value(ndf.styles.clone()).ok(),
319        fonts: None,
320        page,
321        output: None,
322        signature: None,
323        placeholders: None,
324        zones: None,
325        body,
326    };
327
328    // Build font registry: defaults + embedded + extra (extra wins)
329    let mut fonts = crate::fonts::FontRegistry::default();
330    for ef in &ndf.embedded_fonts {
331        decode_and_register_font(ef, &mut fonts)?;
332    }
333    if let Some(extra) = extra_fonts {
334        for (_name, fam) in extra.families() {
335            fonts.register(fam.clone());
336        }
337    }
338
339    Ok((ndt_doc, fonts))
340}
341
342/// Decode a base64-encoded [`NdfEmbeddedFont`] and register it in the registry.
343fn decode_and_register_font(
344    ef: &NdfEmbeddedFont,
345    fonts: &mut crate::fonts::FontRegistry,
346) -> Result<()> {
347    let dec = base64::engine::general_purpose::STANDARD;
348    let regular = dec
349        .decode(&ef.regular)
350        .map_err(|e| NormaxisPdfError::FontLoadError(e.to_string()))?;
351    let bold = ef
352        .bold
353        .as_deref()
354        .map(|s| dec.decode(s).map_err(|e| NormaxisPdfError::FontLoadError(e.to_string())))
355        .transpose()?;
356    let italic = ef
357        .italic
358        .as_deref()
359        .map(|s| dec.decode(s).map_err(|e| NormaxisPdfError::FontLoadError(e.to_string())))
360        .transpose()?;
361    let bold_italic = ef
362        .bold_italic
363        .as_deref()
364        .map(|s| dec.decode(s).map_err(|e| NormaxisPdfError::FontLoadError(e.to_string())))
365        .transpose()?;
366    fonts.register_bytes(
367        &ef.family,
368        &regular,
369        bold.as_deref(),
370        italic.as_deref(),
371        bold_italic.as_deref(),
372    )
373}
374
375/// Parse PDF output options from the NDF `output` field.
376fn parse_output_options(
377    output: Option<&Value>,
378) -> (
379    crate::document::PdfStandard,
380    crate::document::CompressionLevel,
381    crate::compliance::ua::AccessibilityConfig,
382) {
383    let ndt_output: Option<super::model::NdtOutput> =
384        output.and_then(|v| serde_json::from_value(v.clone()).ok());
385
386    let standard = match ndt_output.as_ref().and_then(|o| o.standard.as_deref()) {
387        Some("pdf_a_1b") | Some("pdf_a1b") => crate::document::PdfStandard::PdfA1b,
388        Some("pdf_a_2b") | Some("pdf_a2b") => crate::document::PdfStandard::PdfA2b,
389        Some("pdf_ua2") | Some("pdf_ua_2") => crate::document::PdfStandard::PdfUa2,
390        _ => crate::document::PdfStandard::Pdf17,
391    };
392
393    let compression = match ndt_output.as_ref().and_then(|o| o.compression.as_deref()) {
394        Some("none") => crate::document::CompressionLevel::None,
395        Some("fast") => crate::document::CompressionLevel::Fast,
396        Some("best") => crate::document::CompressionLevel::Best,
397        _ => crate::document::CompressionLevel::Default,
398    };
399
400    let accessibility = ndt_output
401        .as_ref()
402        .and_then(|o| o.accessibility.clone())
403        .unwrap_or_default();
404
405    (standard, compression, accessibility)
406}
407
408fn empty_ndt_data() -> NdtData {
409    NdtData {
410        ndt_data: "1.0.0".into(),
411        template_id: None,
412        template_version: None,
413        data: Default::default(),
414    }
415}
416
417// ── Helpers ───────────────────────────────────────────────────────────────────
418
419/// Recursively substitutes `{{placeholder}}` patterns in all string nodes of a JSON value.
420fn resolve_value_placeholders(value: Value, data: &NdtData) -> Value {
421    match value {
422        Value::String(s) => Value::String(resolver::resolve_string(&s, data)),
423        Value::Array(arr) => {
424            Value::Array(arr.into_iter().map(|v| resolve_value_placeholders(v, data)).collect())
425        }
426        Value::Object(map) => {
427            let mut new_map = serde_json::Map::new();
428            for (k, v) in map {
429                new_map.insert(k, resolve_value_placeholders(v, data));
430            }
431            Value::Object(new_map)
432        }
433        other => other,
434    }
435}