Skip to main content

normordis_pdf/elements/
header.rs

1use serde::{Deserialize, Serialize};
2
3use super::{Element, RenderContext};
4use crate::styles::{RgbColor, StyleResolver};
5
6/// Institutional document header rendered at the top of the first page.
7///
8/// Includes entity name, document title, optional subtitle/logo/reference/date,
9/// and a horizontal separator line.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct InstitutionalHeader {
12    /// Full name of the issuing entity (e.g. "Câmara Municipal de Lisboa").
13    pub entity_name: String,
14    pub document_title: String,
15    pub document_subtitle: Option<String>,
16    /// Raw PNG or JPEG bytes of the entity logo.
17    pub logo: Option<Vec<u8>>,
18    /// Document reference number or code.
19    pub reference: Option<String>,
20    /// Issue date string.
21    pub date: Option<String>,
22}
23
24impl InstitutionalHeader {
25    pub fn new(
26        entity_name: impl Into<String>,
27        document_title: impl Into<String>,
28    ) -> Self {
29        Self {
30            entity_name: entity_name.into(),
31            document_title: document_title.into(),
32            document_subtitle: None,
33            logo: None,
34            reference: None,
35            date: None,
36        }
37    }
38
39    pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
40        self.document_subtitle = Some(subtitle.into());
41        self
42    }
43
44    pub fn with_logo(mut self, bytes: Vec<u8>) -> Self {
45        self.logo = Some(bytes);
46        self
47    }
48
49    pub fn with_reference(mut self, reference: impl Into<String>) -> Self {
50        self.reference = Some(reference.into());
51        self
52    }
53
54    pub fn with_date(mut self, date: impl Into<String>) -> Self {
55        self.date = Some(date.into());
56        self
57    }
58}
59
60impl Element for InstitutionalHeader {
61    fn estimated_height_mm(&self) -> f64 {
62        if self.logo.is_some() { 35.0 } else { 25.0 }
63    }
64
65    fn render(&self, ctx: &mut RenderContext) -> crate::Result<super::RenderResult> {
66        let ua = ctx.ua_config.enabled;
67        if ua { ctx.backend.begin_artifact_content(); }
68
69        let text_color = ctx.style.text_color.clone();
70        let muted_color = RgbColor { r: 0.45, g: 0.45, b: 0.45 };
71        let sep_color = ctx.style.primary_color.clone();
72        let resolver = StyleResolver::new(&ctx.style.named_styles, &ctx.style);
73        let font_family = resolver.resolve("normal").map(|r| r.font_family).unwrap_or_else(|_| "LiberationSans".to_string());
74
75        let content_x = ctx.layout.content_x_mm;
76        let content_w = ctx.layout.content_width_mm;
77        let right_edge = content_x + content_w;
78
79        // ── Row 1: entity name (bold 13pt, left) + date (9pt, right) ────────
80        {
81            let fs_main = 13.0_f64;
82            let fs_meta = 9.0_f64;
83            let y = ctx.flow.cursor_y_mm;
84            if let Some(fref) = ctx.get_font_ref(true, false) {
85                ctx.draw_text(&self.entity_name, content_x, y, fs_main, fref, &text_color)?;
86            }
87            if let Some(ref date) = self.date {
88                let dw = ctx.fonts.get_family(&font_family)
89                    .measure_text_mm(date, fs_meta, false, false);
90                if let Some(fref) = ctx.get_font_ref(false, false) {
91                    ctx.draw_text(date, right_edge - dw, y, fs_meta, fref, &muted_color)?;
92                }
93            }
94            let lh = ctx.layout_engine.line_height_mm(&ctx.fonts, fs_main);
95            ctx.flow.advance(lh + 1.5);
96        }
97
98        // ── Row 2: document title (bold 11pt, left) + reference (9pt, right) ─
99        {
100            let fs_main = 11.0_f64;
101            let fs_meta = 9.0_f64;
102            let y = ctx.flow.cursor_y_mm;
103            if let Some(fref) = ctx.get_font_ref(true, false) {
104                ctx.draw_text(&self.document_title, content_x, y, fs_main, fref, &text_color)?;
105            }
106            if let Some(ref reference) = self.reference {
107                let rw = ctx.fonts.get_family(&font_family)
108                    .measure_text_mm(reference, fs_meta, false, false);
109                if let Some(fref) = ctx.get_font_ref(false, false) {
110                    ctx.draw_text(reference, right_edge - rw, y, fs_meta, fref, &muted_color)?;
111                }
112            }
113            let lh = ctx.layout_engine.line_height_mm(&ctx.fonts, fs_main);
114            ctx.flow.advance(lh + 1.5);
115        }
116
117        // ── Row 3: subtitle (italic 9pt, left) ──────────────────────────────
118        if let Some(ref subtitle) = self.document_subtitle {
119            let fs = 9.0_f64;
120            let y = ctx.flow.cursor_y_mm;
121            if let Some(fref) = ctx.get_font_ref(false, true) {
122                ctx.draw_text(subtitle, content_x, y, fs, fref, &muted_color)?;
123            }
124            let lh = ctx.layout_engine.line_height_mm(&ctx.fonts, fs);
125            ctx.flow.advance(lh + 1.5);
126        }
127
128        // ── Separator line ───────────────────────────────────────────────────
129        ctx.flow.advance(1.0);
130        let sep_y = ctx.flow.cursor_y_mm;
131        ctx.backend.draw_line(content_x, sep_y, right_edge, sep_y, 0.75, &sep_color)?;
132        ctx.flow.advance(3.0);
133
134        if ua { ctx.backend.end_tagged_content(); }
135        Ok(super::RenderResult::done())
136    }
137}
138
139// ── SectionedHeader ───────────────────────────────────────────────────────────
140
141/// Header configuration with per-section variants.
142///
143/// Allows different headers for the first page, odd pages, and even pages,
144/// matching the Word document model.
145///
146/// # Example
147///
148/// ```rust
149/// use normordis_pdf::{SectionedHeader, InstitutionalHeader};
150///
151/// let header = SectionedHeader::new()
152///     .first_page(
153///         InstitutionalHeader::new("Câmara Municipal", "Ofício")
154///             .with_reference("REF/2026/001")
155///     )
156///     .odd_pages(
157///         InstitutionalHeader::new("Câmara Municipal", "Ofício — continuação")
158///     );
159/// ```
160#[derive(Debug, Clone, Default)]
161pub struct SectionedHeader {
162    pub first_page: Option<InstitutionalHeader>,
163    pub odd_pages: Option<InstitutionalHeader>,
164    pub even_pages: Option<InstitutionalHeader>,
165}
166
167impl SectionedHeader {
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    /// Sets the header for the first page only.
173    pub fn first_page(mut self, h: InstitutionalHeader) -> Self {
174        self.first_page = Some(h);
175        self
176    }
177
178    /// Sets the header for odd pages (1, 3, 5, …).
179    /// Also used as fallback when `even_pages` is not set.
180    pub fn odd_pages(mut self, h: InstitutionalHeader) -> Self {
181        self.odd_pages = Some(h);
182        self
183    }
184
185    /// Sets the header for even pages (2, 4, 6, …).
186    pub fn even_pages(mut self, h: InstitutionalHeader) -> Self {
187        self.even_pages = Some(h);
188        self
189    }
190
191    /// Resolves which header to render for a given page number (1-based).
192    ///
193    /// Resolution order:
194    /// - page == 1 AND first_page.is_some() → first_page
195    /// - page is even AND even_pages.is_some() → even_pages
196    /// - odd_pages.is_some() → odd_pages
197    /// - None (no header for this page)
198    pub fn resolve(&self, page_number: u32) -> Option<&InstitutionalHeader> {
199        if page_number == 1 {
200            if let Some(ref h) = self.first_page {
201                return Some(h);
202            }
203        }
204        if page_number.is_multiple_of(2) {
205            if let Some(ref h) = self.even_pages {
206                return Some(h);
207            }
208        }
209        self.odd_pages.as_ref()
210    }
211}
212