Skip to main content

normordis_pdf/elements/
section.rs

1use serde::{Deserialize, Serialize};
2
3use super::{Element, RenderContext};
4use crate::{compliance::ua::StructTag, styles::StyleResolver};
5
6/// A section heading at one of three nesting levels.
7///
8/// Level 1 uses `DocumentStyle.font_size_title` and `primary_color`.
9/// Level 2 uses `font_size_section`.
10/// Level 3 uses `font_size_body` with bold weight.
11///
12/// A `style_ref` can point to a named style (e.g. `"heading_1"`) to override
13/// the default level-based sizing and spacing.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Section {
16    pub title: String,
17    /// 1 = main title, 2 = subtitle, 3 = sub-section.
18    pub level: u8,
19    /// Optional named style override. When set, resolved style provides font size,
20    /// spacing, and colour. Falls back to the built-in heading_1/2/3 when `None`.
21    #[serde(default)]
22    pub style_ref: Option<String>,
23}
24
25impl Section {
26    pub fn new(title: impl Into<String>, level: u8) -> Self {
27        Self {
28            title: title.into(),
29            level: level.clamp(1, 3),
30            style_ref: None,
31        }
32    }
33
34    /// Apply a named style reference.
35    pub fn style(mut self, name: impl Into<String>) -> Self {
36        self.style_ref = Some(name.into());
37        self
38    }
39
40    fn default_style_name(&self) -> &'static str {
41        match self.level {
42            1 => "heading_1",
43            2 => "heading_2",
44            _ => "heading_3",
45        }
46    }
47}
48
49impl Section {
50    /// Returns the heading level (1–3).
51    pub fn level(&self) -> u8 {
52        self.level
53    }
54
55    /// Returns the heading text.
56    pub fn heading_text(&self) -> &str {
57        &self.title
58    }
59}
60
61impl Element for Section {
62    fn as_section_info(&self) -> Option<(u8, &str)> {
63        Some((self.level, &self.title))
64    }
65
66    fn estimated_height_mm(&self) -> f64 {
67        let pre = match self.level { 1 => 8.0, 2 => 6.0, _ => 4.0 };
68        let post = match self.level { 1 => 4.0, 2 => 3.0, _ => 2.0 };
69        pre + 7.0 + post
70    }
71
72    fn render(&self, ctx: &mut RenderContext) -> crate::Result<super::RenderResult> {
73        let style_name = self.style_ref.as_deref()
74            .unwrap_or_else(|| self.default_style_name());
75
76        let resolver = StyleResolver::new(&ctx.style.named_styles, &ctx.style);
77        let resolved = resolver.resolve(style_name)?;
78
79        let fs = resolved.font_size;
80
81        if resolved.space_before_mm > 0.0 && !ctx.flow.is_top_of_page() {
82            ctx.flow.advance(resolved.space_before_mm);
83        }
84
85        let color = resolved.color.clone()
86            .unwrap_or_else(|| ctx.style.text_color.clone());
87
88        let Some(font_ref) = ctx.get_font_ref(resolved.bold, resolved.italic) else {
89            ctx.flow.advance(self.estimated_height_mm() - resolved.space_before_mm);
90            return Ok(super::RenderResult::done());
91        };
92
93        // UA-2 heading tag
94        let ua_tag = match self.level {
95            1 => StructTag::H1,
96            2 => StructTag::H2,
97            3 => StructTag::H3,
98            4 => StructTag::H4,
99            5 => StructTag::H5,
100            _ => StructTag::H6,
101        };
102        if ctx.ua_enabled() {
103            if let Some(prev) = ctx.last_heading_level {
104                if self.level > prev + 1 {
105                    eprintln!(
106                        "PDF/UA-2 WARNING: heading level skipped from H{} to H{}",
107                        prev, self.level
108                    );
109                }
110            }
111            ctx.last_heading_level = Some(self.level);
112        }
113
114        let mcid_opt = if ctx.ua_enabled() {
115            let mcid = ctx.ua_tag_element(ua_tag, None);
116            ctx.backend.begin_tagged_content(
117                match self.level { 1 => b"H1", 2 => b"H2", 3 => b"H3", 4 => b"H4", 5 => b"H5", _ => b"H6" },
118                mcid,
119            );
120            Some(mcid)
121        } else {
122            None
123        };
124
125        let x = ctx.layout.content_x_mm;
126        let y = ctx.flow.cursor_y_mm;
127        ctx.draw_text(&self.title, x, y, fs, font_ref, &color)?;
128
129        // Record outline entry so the bookmarks panel navigates here.
130        let page_idx = ctx.backend.current_page_idx();
131        ctx.backend.add_outline_entry(&self.title, self.level, page_idx, y);
132
133        let line_h = ctx.layout_engine.line_height_mm(&ctx.fonts, fs);
134        ctx.flow.advance(line_h + resolved.space_after_mm);
135
136        if mcid_opt.is_some() {
137            ctx.backend.end_tagged_content();
138        }
139
140        Ok(super::RenderResult::done())
141    }
142}