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}