Skip to main content

normordis_pdf/
styles.rs

1use std::collections::{HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    error::NormaxisPdfError,
7    fonts::FontFallbackChain,
8    layout::TextAlign,
9};
10
11/// Diagonal text watermark rendered on every page.
12///
13/// # Example
14///
15/// ```rust
16/// use normordis_pdf::{Watermark, RgbColor};
17///
18/// let wm = Watermark::new("RASCUNHO")
19///     .opacity(0.12)
20///     .color(RgbColor { r: 0.8, g: 0.0, b: 0.0 })
21///     .font_size(72.0);
22/// ```
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Watermark {
25    /// Text to display (e.g. "RASCUNHO", "CÓPIA NÃO CERTIFICADA").
26    pub text: String,
27    /// Opacity from 0.0 (invisible) to 1.0 (opaque). Default: 0.10.
28    pub opacity: f64,
29    /// Text color. Default: light grey (0.7, 0.7, 0.7).
30    pub color: RgbColor,
31    /// Font size in points. Default: 72.0.
32    pub font_size: f64,
33    /// Rotation angle in degrees (counter-clockwise). Default: 45.0.
34    pub angle_deg: f64,
35}
36
37impl Watermark {
38    pub fn new(text: impl Into<String>) -> Self {
39        Self { text: text.into(), ..Self::default() }
40    }
41    pub fn opacity(mut self, v: f64) -> Self { self.opacity = v; self }
42    pub fn color(mut self, c: RgbColor) -> Self { self.color = c; self }
43    pub fn font_size(mut self, pt: f64) -> Self { self.font_size = pt; self }
44    pub fn angle_deg(mut self, deg: f64) -> Self { self.angle_deg = deg; self }
45}
46
47impl Default for Watermark {
48    fn default() -> Self {
49        Self {
50            text: "RASCUNHO".into(),
51            opacity: 0.10,
52            color: RgbColor { r: 0.7, g: 0.7, b: 0.7 },
53            font_size: 72.0,
54            angle_deg: 45.0,
55        }
56    }
57}
58
59/// Page orientation.
60#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
61#[serde(rename_all = "snake_case")]
62pub enum Orientation {
63    #[default]
64    Portrait,
65    Landscape,
66}
67
68/// Full styling configuration for a document.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DocumentStyle {
71    pub page_size: PageSize,
72    pub orientation: Orientation,
73    pub margin_top_mm: f64,
74    pub margin_bottom_mm: f64,
75    pub margin_left_mm: f64,
76    pub margin_right_mm: f64,
77    pub font_size_body: f64,
78    pub font_size_title: f64,
79    pub font_size_section: f64,
80    pub font_size_small: f64,
81    /// Line height multiplier (e.g. 1.4 = 140% of font size).
82    pub line_height: f64,
83    /// Primary brand colour — used for section headings and table headers.
84    pub primary_color: RgbColor,
85    /// Default body text colour.
86    pub text_color: RgbColor,
87    /// Named paragraph/table styles. Built-in defaults are always available.
88    #[serde(default)]
89    pub named_styles: HashMap<String, NamedStyle>,
90    /// Minimum lines of a paragraph that must appear at the bottom of a page
91    /// before a page break (orphan control). 0 = disabled. Default: 2.
92    #[serde(default = "default_orphan_lines")]
93    pub min_orphan_lines: u8,
94    /// Minimum lines of a paragraph that must appear at the top of a page
95    /// after a page break (widow control). 0 = disabled. Default: 2.
96    #[serde(default = "default_widow_lines")]
97    pub min_widow_lines: u8,
98    /// Font fallback chain used when the requested font family is not registered.
99    /// The first registered name in the chain wins; then the document default.
100    #[serde(default = "default_fallback_chain")]
101    pub font_fallback: FontFallbackChain,
102}
103
104fn default_orphan_lines() -> u8 { 2 }
105fn default_widow_lines() -> u8 { 2 }
106fn default_fallback_chain() -> FontFallbackChain {
107    FontFallbackChain::new(vec!["LiberationSans", "LiberationSerif", "LiberationMono"])
108}
109
110impl Default for DocumentStyle {
111    fn default() -> Self {
112        Self {
113            page_size: PageSize::A4,
114            orientation: Orientation::Portrait,
115            margin_top_mm: 20.0,
116            margin_bottom_mm: 20.0,
117            margin_left_mm: 25.0,
118            margin_right_mm: 20.0,
119            font_size_body: 11.0,
120            font_size_title: 16.0,
121            font_size_section: 13.0,
122            font_size_small: 9.0,
123            line_height: 1.4,
124            // Institutional blue #003399
125            primary_color: RgbColor { r: 0.0, g: 0.2, b: 0.6 },
126            // Near-black #1A1A1A
127            text_color: RgbColor { r: 0.102, g: 0.102, b: 0.102 },
128            named_styles: HashMap::new(),
129            min_orphan_lines: 2,
130            min_widow_lines: 2,
131            font_fallback: default_fallback_chain(),
132        }
133    }
134}
135
136/// Supported paper sizes.
137#[derive(Debug, Clone, Serialize, Deserialize, Default)]
138pub enum PageSize {
139    #[default]
140    A4,
141    A3,
142    Letter,
143}
144
145impl PageSize {
146    /// Returns `(width_mm, height_mm)` for the page size.
147    pub fn dimensions_mm(&self) -> (f64, f64) {
148        match self {
149            PageSize::A4 => (210.0, 297.0),
150            PageSize::A3 => (297.0, 420.0),
151            PageSize::Letter => (215.9, 279.4),
152        }
153    }
154}
155
156/// An RGB colour with components in the range [0.0, 1.0].
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct RgbColor {
159    pub r: f64,
160    pub g: f64,
161    pub b: f64,
162}
163
164impl RgbColor {
165    pub const fn new(r: f64, g: f64, b: f64) -> Self {
166        Self { r, g, b }
167    }
168
169    /// Parse a CSS-style hex colour (e.g. `"#003399"` or `"003399"`).
170    pub fn from_hex(hex: &str) -> Option<Self> {
171        let hex = hex.trim_start_matches('#');
172        if hex.len() != 6 {
173            return None;
174        }
175        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
176        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
177        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
178        Some(Self {
179            r: r as f64 / 255.0,
180            g: g as f64 / 255.0,
181            b: b as f64 / 255.0,
182        })
183    }
184}
185
186// ── Named Styles ─────────────────────────────────────────────────────────────
187
188/// A named paragraph style (equivalent to a Word Paragraph Style).
189///
190/// All fields are `Option<T>` — `None` means "inherit from parent or document defaults".
191/// Styles form an inheritance chain via the `extends` field.
192#[derive(Debug, Clone, Serialize, Deserialize, Default)]
193pub struct NamedStyle {
194    /// Parent style name. Resolution walks the chain until it reaches a style
195    /// with no `extends`, then fills remaining `None` fields from `DocumentStyle` defaults.
196    pub extends: Option<String>,
197    pub font_size: Option<f64>,
198    pub bold: Option<bool>,
199    pub italic: Option<bool>,
200    pub alignment: Option<TextAlign>,
201    pub space_before_mm: Option<f64>,
202    pub space_after_mm: Option<f64>,
203    pub indent_left_mm: Option<f64>,
204    pub indent_right_mm: Option<f64>,
205    pub indent_first_line_mm: Option<f64>,
206    /// Explicit text colour override. `None` inherits from document.
207    pub color: Option<RgbColor>,
208    /// Font family name (e.g. "LiberationSerif", "LiberationMono").
209    /// `None` inherits from parent or document default.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub font_family: Option<String>,
212}
213
214/// A fully-resolved paragraph style — no `Option` fields.
215///
216/// Produced by `StyleResolver::resolve`. Callers read directly from these fields.
217#[derive(Debug, Clone)]
218pub struct ResolvedStyle {
219    pub font_size: f64,
220    pub bold: bool,
221    pub italic: bool,
222    pub alignment: TextAlign,
223    pub space_before_mm: f64,
224    pub space_after_mm: f64,
225    pub indent_left_mm: f64,
226    pub indent_right_mm: f64,
227    pub indent_first_line_mm: f64,
228    pub color: Option<RgbColor>,
229    /// Resolved font family name — never empty.
230    pub font_family: String,
231}
232
233/// Resolves named styles against a `DocumentStyle`, with cycle detection.
234pub struct StyleResolver<'a> {
235    styles: &'a HashMap<String, NamedStyle>,
236    doc: &'a DocumentStyle,
237}
238
239impl<'a> StyleResolver<'a> {
240    pub fn new(styles: &'a HashMap<String, NamedStyle>, doc: &'a DocumentStyle) -> Self {
241        Self { styles, doc }
242    }
243
244    /// Resolves `name` to a `ResolvedStyle`, traversing the inheritance chain.
245    ///
246    /// Returns `Err(StyleCycleError)` if a cycle is detected, or
247    /// `Err(UnknownStyle)` if the name is not found in the registry or built-ins.
248    pub fn resolve(&self, name: &str) -> crate::Result<ResolvedStyle> {
249        let mut visited = HashSet::new();
250        // Merge built-ins into a combined lookup; user styles override built-ins.
251        let builtins = default_named_styles(self.doc);
252        self.resolve_chain(name, &builtins, &mut visited)
253    }
254
255    fn resolve_chain(
256        &self,
257        name: &str,
258        builtins: &HashMap<String, NamedStyle>,
259        visited: &mut HashSet<String>,
260    ) -> crate::Result<ResolvedStyle> {
261        if !visited.insert(name.to_string()) {
262            return Err(NormaxisPdfError::StyleCycleError(name.to_string()));
263        }
264
265        // User styles take priority over built-ins.
266        let style = self.styles.get(name)
267            .or_else(|| builtins.get(name))
268            .ok_or_else(|| NormaxisPdfError::UnknownStyle(name.to_string()))?;
269
270        // If this style extends another, resolve the parent first, then overlay.
271        let base = if let Some(ref parent) = style.extends {
272            self.resolve_chain(parent, builtins, visited)?
273        } else {
274            self.doc_defaults()
275        };
276
277        Ok(ResolvedStyle {
278            font_size: style.font_size.unwrap_or(base.font_size),
279            bold: style.bold.unwrap_or(base.bold),
280            italic: style.italic.unwrap_or(base.italic),
281            alignment: style.alignment.unwrap_or(base.alignment),
282            space_before_mm: style.space_before_mm.unwrap_or(base.space_before_mm),
283            space_after_mm: style.space_after_mm.unwrap_or(base.space_after_mm),
284            indent_left_mm: style.indent_left_mm.unwrap_or(base.indent_left_mm),
285            indent_right_mm: style.indent_right_mm.unwrap_or(base.indent_right_mm),
286            indent_first_line_mm: style.indent_first_line_mm.unwrap_or(base.indent_first_line_mm),
287            color: style.color.clone().or(base.color),
288            font_family: style.font_family.clone().unwrap_or(base.font_family),
289        })
290    }
291
292    /// Document-level defaults used as the ultimate fallback in the chain.
293    fn doc_defaults(&self) -> ResolvedStyle {
294        let space_after = self.doc.font_size_body * 0.3 * 25.4 / 72.0;
295        ResolvedStyle {
296            font_size: self.doc.font_size_body,
297            bold: false,
298            italic: false,
299            alignment: TextAlign::Justify,
300            space_before_mm: 0.0,
301            space_after_mm: space_after,
302            indent_left_mm: 0.0,
303            indent_right_mm: 0.0,
304            indent_first_line_mm: 0.0,
305            color: None,
306            font_family: "LiberationSans".to_string(),
307        }
308    }
309}
310
311/// Returns the 7 built-in named styles computed from `doc` defaults.
312///
313/// These are always available without declaring them in `DocumentStyle.named_styles`.
314/// User-defined styles with the same name override these.
315pub fn default_named_styles(doc: &DocumentStyle) -> HashMap<String, NamedStyle> {
316    let pt_to_mm = |pt: f64| pt * 25.4 / 72.0;
317
318    let mut m = HashMap::new();
319
320    // normal — body text baseline
321    m.insert("normal".into(), NamedStyle {
322        font_size: Some(doc.font_size_body),
323        bold: Some(false),
324        italic: Some(false),
325        alignment: Some(TextAlign::Justify),
326        space_after_mm: Some(pt_to_mm(doc.font_size_body) * 0.3),
327        ..Default::default()
328    });
329
330    // heading_1 — document title level
331    m.insert("heading_1".into(), NamedStyle {
332        font_size: Some(doc.font_size_title),
333        bold: Some(true),
334        alignment: Some(TextAlign::Left),
335        space_before_mm: Some(8.0),
336        space_after_mm: Some(4.0),
337        color: Some(doc.primary_color.clone()),
338        ..Default::default()
339    });
340
341    // heading_2 — section level
342    m.insert("heading_2".into(), NamedStyle {
343        extends: Some("heading_1".into()),
344        font_size: Some(doc.font_size_section),
345        space_before_mm: Some(6.0),
346        space_after_mm: Some(3.0),
347        color: Some(doc.text_color.clone()),
348        ..Default::default()
349    });
350
351    // heading_3 — sub-section level
352    m.insert("heading_3".into(), NamedStyle {
353        extends: Some("heading_2".into()),
354        font_size: Some(doc.font_size_body),
355        space_before_mm: Some(4.0),
356        space_after_mm: Some(2.0),
357        ..Default::default()
358    });
359
360    // caption — figure/table captions
361    m.insert("caption".into(), NamedStyle {
362        extends: Some("normal".into()),
363        font_size: Some(doc.font_size_small),
364        italic: Some(true),
365        alignment: Some(TextAlign::Center),
366        space_before_mm: Some(2.0),
367        space_after_mm: Some(4.0),
368        ..Default::default()
369    });
370
371    // table_header — bold, left-aligned header cells
372    m.insert("table_header".into(), NamedStyle {
373        extends: Some("normal".into()),
374        bold: Some(true),
375        alignment: Some(TextAlign::Left),
376        space_before_mm: Some(0.0),
377        space_after_mm: Some(0.0),
378        ..Default::default()
379    });
380
381    // table_body — standard body cell text
382    m.insert("table_body".into(), NamedStyle {
383        extends: Some("normal".into()),
384        alignment: Some(TextAlign::Left),
385        space_before_mm: Some(0.0),
386        space_after_mm: Some(0.0),
387        ..Default::default()
388    });
389
390    // footnote — small text at bottom of page
391    m.insert("footnote".into(), NamedStyle {
392        extends: Some("normal".into()),
393        font_size: Some(9.0),
394        space_before_mm: Some(0.5),
395        space_after_mm: Some(0.5),
396        ..Default::default()
397    });
398
399    // TOC entry styles — indentation increases with level
400    m.insert("toc_1".into(), NamedStyle {
401        extends: Some("normal".into()),
402        font_size: Some(11.0),
403        bold: Some(true),
404        space_after_mm: Some(1.0),
405        ..Default::default()
406    });
407
408    m.insert("toc_2".into(), NamedStyle {
409        extends: Some("toc_1".into()),
410        bold: Some(false),
411        indent_left_mm: Some(8.0),
412        ..Default::default()
413    });
414
415    m.insert("toc_3".into(), NamedStyle {
416        extends: Some("toc_2".into()),
417        indent_left_mm: Some(16.0),
418        font_size: Some(10.0),
419        ..Default::default()
420    });
421
422    m
423}
424
425// ── SecurityClassification ────────────────────────────────────────────────────
426
427/// Document security classification level.
428#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "snake_case")]
430pub enum SecurityClassification {
431    #[default]
432    Public,
433    Internal,
434    Confidential,
435    Reserved,
436}
437
438impl SecurityClassification {
439    /// Portuguese label for this classification level.
440    pub fn label_pt(self) -> &'static str {
441        match self {
442            Self::Public       => "Público",
443            Self::Internal     => "Interno",
444            Self::Confidential => "Confidencial",
445            Self::Reserved     => "Reservado",
446        }
447    }
448
449    /// Watermark colour for non-public documents.
450    pub fn watermark_color(self) -> RgbColor {
451        match self {
452            Self::Internal     => RgbColor { r: 0.0,  g: 0.0,  b: 0.5 },
453            Self::Confidential => RgbColor { r: 0.8,  g: 0.0,  b: 0.0 },
454            Self::Reserved     => RgbColor { r: 0.5,  g: 0.0,  b: 0.0 },
455            Self::Public       => RgbColor { r: 0.7,  g: 0.7,  b: 0.7 },
456        }
457    }
458}
459
460// ── TraceabilityMetadata ──────────────────────────────────────────────────────
461
462/// Traceability metadata for CRA/NIS2 compliance.
463///
464/// When set on a [`DocumentBuilder`], this is embedded in the document context
465/// and — if classification is non-public — automatically applies a classification
466/// watermark to every page.
467///
468/// [`DocumentBuilder`]: crate::DocumentBuilder
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct TraceabilityMetadata {
471    /// normordis-pdf version that generated this document.
472    pub engine_version: String,
473    /// NORMAXIS framework version.
474    pub framework_version: Option<String>,
475    /// Generating entity identifier (e.g. `"cm-lisboa"`).
476    pub entity_id: String,
477    /// Document reference (e.g. `"REF/2026/001"`).
478    pub document_ref: Option<String>,
479    /// Document security classification.
480    pub classification: SecurityClassification,
481    /// Generation timestamp (ISO 8601).
482    pub generated_at: String,
483    /// NDT template version used.
484    pub ndt_version: String,
485}
486