Skip to main content

lightweight_pdf_layout/
render_node.rs

1use crate::geometry::Rect;
2use crate::text::RichLine;
3use lightweight_pdf_core::{Align, Border, Color, ImageFormat, TextStyle};
4use std::sync::Arc;
5
6/// Positioned, resolved layout output ready for the facade to translate
7/// into `lightweight-pdf-writer` content-stream operations. Never seen by
8/// `lightweight-pdf-writer` directly (`plan/00a-contracts-and-artifacts.md` point 3).
9#[derive(Clone, Debug)]
10pub enum RenderNode {
11    Empty,
12    /// Already-wrapped lines, one per output line, top-aligned within `area`.
13    TextLines {
14        area: Rect,
15        style: TextStyle,
16        lines: Vec<String>,
17        /// Same length as `lines`: `true` for the last line of its source
18        /// paragraph. Only consulted when `style.align == Align::Justify`
19        /// (that line stays left-aligned instead of being stretched); every
20        /// other alignment ignores it.
21        paragraph_end: Vec<bool>,
22        line_height_pt: f32,
23        url: Option<String>,
24        anchor: Option<String>,
25        link_to: Option<String>,
26        /// `Text::outline_level` — the PDF bookmark tree is built from
27        /// these, in document order, once pagination is final.
28        outline_level: Option<u8>,
29    },
30    /// `Text::rich(..)` (issue #11) — the multi-style counterpart to
31    /// `TextLines`. Deliberately a separate variant rather than a shape
32    /// change to `TextLines`: every existing `TextLines` consumer (facade
33    /// rendering, font-collection, outline/anchor collection) keeps
34    /// working unmodified for plain `Text`, and this variant just isn't
35    /// handled by any of the outline/anchor/link machinery yet (rich text
36    /// doesn't support those in V1, see `Text::spans`' doc comment).
37    RichTextLines {
38        area: Rect,
39        align: Align,
40        lines: Vec<RichLine>,
41    },
42    Rect {
43        area: Rect,
44        background: Option<Color>,
45        border: Option<Border>,
46        corner_radius: f32,
47    },
48    Line {
49        x1: f32,
50        y1: f32,
51        x2: f32,
52        y2: f32,
53        thickness: f32,
54        color: Color,
55    },
56    /// A validated JPEG/PNG placed at its final, Contain-fit size. `bytes`
57    /// are the original file bytes (facade decides how to embed them —
58    /// JPEG passes through as `DCTDecode`, PNG gets decoded once here to
59    /// split out the alpha channel as a `SMask`).
60    Image {
61        area: Rect,
62        bytes: Arc<[u8]>,
63        format: ImageFormat,
64        width_px: u32,
65        height_px: u32,
66        components: u8,
67        /// `Image::alt` (issue #27) — carried through layout so the
68        /// facade can both write `/Alt` and warn when it's missing.
69        alt: Option<String>,
70    },
71    /// A container's own box: clipped (Grundprinzip 4), optionally painted
72    /// with a background/border, holding its children.
73    Group {
74        area: Rect,
75        clip: bool,
76        background: Option<Color>,
77        border: Option<Border>,
78        corner_radius: f32,
79        children: Vec<RenderNode>,
80    },
81    /// Wraps `inner` with a structure-tree role (issue #27) — a wrapper
82    /// variant rather than a field on every other variant, so tagging
83    /// support doesn't require touching every `RenderNode` construction
84    /// site in this crate. Attached at `Element::layout`'s dispatch (see
85    /// `layoutable::mod`) plus, for `Table`/`List`/`TableOfContents`,
86    /// inside their own layout code where row/cell/item structure is
87    /// known.
88    Tagged {
89        role: StructRole,
90        inner: Box<RenderNode>,
91    },
92}
93
94/// The PDF standard structure types (ISO 32000-1 14.8.4, Table 333/334)
95/// this crate tags content with — no `RoleMap` needed since all of these
96/// are standard types. `Grouping` roles (see `StructRole::is_grouping`)
97/// nest child `StructElem`s and own no marked content of their own;
98/// `Artifact` is excluded from the structure tree entirely (watermarks,
99/// running headers/footers — PDF/UA pagination artifacts, not content).
100#[derive(Clone, Copy, PartialEq, Eq, Debug)]
101pub enum StructRole {
102    Document,
103    /// `Text::outline_level` 1-6, clamped (`.outline_level(7+)` still
104    /// produces valid output, just tagged as the deepest standard
105    /// heading level rather than inventing a 7th).
106    Heading(u8),
107    Paragraph,
108    Figure,
109    Table,
110    TableRow,
111    TableHeaderCell,
112    TableCell,
113    List,
114    ListItem,
115    ListItemLabel,
116    ListItemBody,
117    Toc,
118    TocItem,
119    /// Not structure content at all — `BDC`/`EMC` with no MCID, entirely
120    /// excluded from `/StructTreeRoot` (ISO 32000-1 14.8.2.2).
121    Artifact,
122}
123
124impl StructRole {
125    /// `true` for roles that only nest child `StructElem`s (no marked
126    /// content of their own — see `RenderNode::Tagged`'s doc comment).
127    pub fn is_grouping(self) -> bool {
128        matches!(
129            self,
130            StructRole::Document
131                | StructRole::Table
132                | StructRole::TableRow
133                | StructRole::TableHeaderCell
134                | StructRole::TableCell
135                | StructRole::List
136                | StructRole::ListItem
137                | StructRole::ListItemLabel
138                | StructRole::ListItemBody
139                | StructRole::Toc
140        )
141    }
142
143    /// The PDF structure type name (ISO 32000-1 Table 333/334).
144    pub fn tag_name(self) -> &'static str {
145        match self {
146            StructRole::Document => "Document",
147            StructRole::Heading(n) => match n.clamp(1, 6) {
148                1 => "H1",
149                2 => "H2",
150                3 => "H3",
151                4 => "H4",
152                5 => "H5",
153                _ => "H6",
154            },
155            StructRole::Paragraph => "P",
156            StructRole::Figure => "Figure",
157            StructRole::Table => "Table",
158            StructRole::TableRow => "TR",
159            StructRole::TableHeaderCell => "TH",
160            StructRole::TableCell => "TD",
161            StructRole::List => "L",
162            StructRole::ListItem => "LI",
163            StructRole::ListItemLabel => "Lbl",
164            StructRole::ListItemBody => "LBody",
165            StructRole::Toc => "TOC",
166            StructRole::TocItem => "TOCI",
167            StructRole::Artifact => "Artifact",
168        }
169    }
170}
171
172impl RenderNode {
173    /// Wraps `self` in a clipping group bound to `area` — the render-pass
174    /// safety net required from every element, not only containers
175    /// (Grundprinzip 4/6).
176    pub fn clipped(area: Rect, inner: RenderNode) -> RenderNode {
177        RenderNode::Group {
178            area,
179            clip: true,
180            background: None,
181            border: None,
182            corner_radius: 0.0,
183            children: vec![inner],
184        }
185    }
186
187    pub fn height(&self) -> f32 {
188        match self {
189            RenderNode::Empty => 0.0,
190            RenderNode::TextLines { area, .. }
191            | RenderNode::RichTextLines { area, .. }
192            | RenderNode::Rect { area, .. }
193            | RenderNode::Group { area, .. }
194            | RenderNode::Image { area, .. } => area.height,
195            RenderNode::Line { .. } => 0.0,
196            RenderNode::Tagged { inner, .. } => inner.height(),
197        }
198    }
199
200    /// Peels away `Tagged` wrappers — for tests that assert on the
201    /// underlying `Group`/`TextLines`/etc. shape without caring about
202    /// structure-tree tagging (issue #27) specifically.
203    #[cfg(test)]
204    pub fn untagged(&self) -> &RenderNode {
205        match self {
206            RenderNode::Tagged { inner, .. } => inner.untagged(),
207            other => other,
208        }
209    }
210
211    /// Wraps `self` with a structure-tree role (issue #27) — see
212    /// `RenderNode::Tagged`'s doc comment.
213    pub fn tagged(role: StructRole, inner: RenderNode) -> RenderNode {
214        RenderNode::Tagged {
215            role,
216            inner: Box::new(inner),
217        }
218    }
219}
220
221/// Just used inside `Row`/`Column` cross-axis alignment.
222pub fn align_offset(align: Align, available: f32, used: f32) -> f32 {
223    match align {
224        Align::Start | Align::Justify => 0.0,
225        Align::Center => ((available - used) / 2.0).max(0.0),
226        Align::End => (available - used).max(0.0),
227    }
228}