Skip to main content

rdocx_layout/
block.rs

1//! Block-level layout: paragraphs and tables as positioned blocks.
2
3use crate::table::TableBlock;
4use oxml_layout::{Align, Color, LayoutLine, MediaId};
5use rdocx_oxml::borders::CT_PBdr;
6use rdocx_oxml::drawing::{ST_RelativeFromH, ST_RelativeFromV};
7
8/// A floating drawing anchored to a paragraph.
9///
10/// Offsets are kept in points alongside the frame they are measured from. A
11/// `wp:anchor` offset is meaningless on its own: the same number means a
12/// different place depending on whether it is relative to the page, the
13/// margin, the text column or the paragraph.
14#[derive(Debug, Clone)]
15pub struct AnchoredDrawing {
16    /// Render underneath the text rather than on top of it.
17    pub behind_doc: bool,
18    /// Frame the horizontal offset is measured from.
19    pub rel_h: ST_RelativeFromH,
20    /// Horizontal offset in points.
21    pub off_h: f64,
22    /// Frame the vertical offset is measured from.
23    pub rel_v: ST_RelativeFromV,
24    /// Vertical offset in points.
25    pub off_v: f64,
26    /// Width in points.
27    pub width: f64,
28    /// Height in points.
29    pub height: f64,
30    /// What the drawing actually holds.
31    pub content: AnchoredContent,
32}
33
34/// The drawable content of an anchored drawing.
35#[derive(Debug, Clone)]
36pub enum AnchoredContent {
37    /// A picture resolved to its content-addressed shared media identity.
38    Image { media_id: MediaId },
39    /// A shape: preset geometry, an optional fill, and optional text.
40    ///
41    /// The text arrives already laid out, because breaking it into lines needs
42    /// a font manager and that only exists in the engine.
43    Shape {
44        /// Preset geometry we recognise.
45        preset: ShapePreset,
46        /// Fill colour, or `None` for `a:noFill` and for fills we cannot
47        /// resolve. An unfilled shape draws no body, only its text.
48        fill: Option<Color>,
49        /// Laid-out paragraphs of the shape's text box.
50        text: Vec<ParagraphBlock>,
51    },
52}
53
54/// The preset geometries we can draw.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ShapePreset {
57    /// A rectangle, drawn as a filled box.
58    Rect,
59    /// A straight line, drawn along the top edge of the extent.
60    Line,
61    /// Anything else. The body is not drawn, but text still is.
62    Unsupported,
63}
64
65impl ShapePreset {
66    /// Map an `a:prstGeom@prst` value onto what we can draw.
67    pub fn from_prst(prst: Option<&str>) -> Self {
68        match prst {
69            Some("rect") => ShapePreset::Rect,
70            Some("line") | Some("straightConnector1") => ShapePreset::Line,
71            _ => ShapePreset::Unsupported,
72        }
73    }
74}
75
76/// A laid-out block element (paragraph or table).
77#[derive(Debug, Clone)]
78pub enum LayoutBlock {
79    Paragraph(ParagraphBlock),
80    Table(TableBlock),
81}
82
83impl LayoutBlock {
84    /// Total height including spacing.
85    pub fn total_height(&self) -> f64 {
86        match self {
87            LayoutBlock::Paragraph(p) => p.total_height(),
88            LayoutBlock::Table(t) => t.total_height(),
89        }
90    }
91
92    /// Content height without spacing.
93    pub fn content_height(&self) -> f64 {
94        match self {
95            LayoutBlock::Paragraph(p) => p.content_height(),
96            LayoutBlock::Table(t) => t.content_height(),
97        }
98    }
99
100    pub fn space_before(&self) -> f64 {
101        match self {
102            LayoutBlock::Paragraph(p) => p.space_before,
103            LayoutBlock::Table(_) => 0.0,
104        }
105    }
106
107    pub fn space_after(&self) -> f64 {
108        match self {
109            LayoutBlock::Paragraph(p) => p.space_after,
110            LayoutBlock::Table(_) => 0.0,
111        }
112    }
113
114    pub fn keep_next(&self) -> bool {
115        match self {
116            LayoutBlock::Paragraph(p) => p.keep_next,
117            LayoutBlock::Table(_) => false,
118        }
119    }
120
121    pub fn keep_lines(&self) -> bool {
122        match self {
123            LayoutBlock::Paragraph(p) => p.keep_lines,
124            LayoutBlock::Table(_) => false,
125        }
126    }
127
128    pub fn page_break_before(&self) -> bool {
129        match self {
130            LayoutBlock::Paragraph(p) => p.page_break_before,
131            LayoutBlock::Table(_) => false,
132        }
133    }
134
135    pub fn widow_control(&self) -> bool {
136        match self {
137            LayoutBlock::Paragraph(p) => p.widow_control,
138            LayoutBlock::Table(_) => false,
139        }
140    }
141}
142
143/// A laid-out paragraph with its lines and spacing.
144#[derive(Debug, Clone)]
145pub struct ParagraphBlock {
146    /// Laid-out lines.
147    pub lines: Vec<LayoutLine>,
148    /// Floating drawings anchored to this paragraph.
149    ///
150    /// These travel with the paragraph so the paginator can resolve a
151    /// paragraph-relative or line-relative offset once it knows where the
152    /// paragraph actually landed.
153    pub anchored: Vec<AnchoredDrawing>,
154    /// Space before the paragraph in points.
155    pub space_before: f64,
156    /// Space after the paragraph in points.
157    pub space_after: f64,
158    /// Paragraph borders.
159    pub borders: Option<CT_PBdr>,
160    /// Background shading color.
161    pub shading: Option<Color>,
162    /// Left indent in points.
163    pub indent_left: f64,
164    /// Right indent in points.
165    pub indent_right: f64,
166    /// Paragraph justification.
167    pub jc: Option<Align>,
168    /// Keep with next paragraph.
169    pub keep_next: bool,
170    /// Keep all lines together on one page.
171    pub keep_lines: bool,
172    /// Force page break before this paragraph.
173    pub page_break_before: bool,
174    /// Widow/orphan control.
175    pub widow_control: bool,
176    /// Heading level (1-9) if this is a heading paragraph, for outline generation.
177    pub heading_level: Option<u32>,
178    /// Heading text for outline generation.
179    pub heading_text: Option<String>,
180}
181
182impl ParagraphBlock {
183    /// Total height of the paragraph lines (not including before/after spacing).
184    pub fn content_height(&self) -> f64 {
185        self.lines.iter().map(|l| l.height).sum()
186    }
187
188    /// Total height including spacing.
189    pub fn total_height(&self) -> f64 {
190        self.space_before + self.content_height() + self.space_after
191    }
192
193    /// Number of lines.
194    pub fn line_count(&self) -> usize {
195        self.lines.len()
196    }
197}
198
199/// Build a ParagraphBlock from resolved properties and layout lines.
200pub fn build_paragraph_block(
201    lines: Vec<LayoutLine>,
202    space_before: f64,
203    space_after: f64,
204    borders: Option<CT_PBdr>,
205    shading: Option<Color>,
206    indent_left: f64,
207    indent_right: f64,
208    jc: Option<Align>,
209    keep_next: bool,
210    keep_lines: bool,
211    page_break_before: bool,
212    widow_control: bool,
213) -> ParagraphBlock {
214    ParagraphBlock {
215        lines,
216        anchored: Vec::new(),
217        space_before,
218        space_after,
219        borders,
220        shading,
221        indent_left,
222        indent_right,
223        jc,
224        keep_next,
225        keep_lines,
226        page_break_before,
227        widow_control,
228        heading_level: None,
229        heading_text: None,
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn paragraph_block_height() {
239        let block = ParagraphBlock {
240            anchored: Vec::new(),
241            lines: vec![
242                LayoutLine {
243                    items: vec![],
244                    width: 0.0,
245                    ascent: 10.0,
246                    descent: 3.0,
247                    line_gap: 0.0,
248                    height: 13.0,
249                    indent_left: 0.0,
250                    available_width: 468.0,
251                    is_last: false,
252                },
253                LayoutLine {
254                    items: vec![],
255                    width: 0.0,
256                    ascent: 10.0,
257                    descent: 3.0,
258                    line_gap: 0.0,
259                    height: 13.0,
260                    indent_left: 0.0,
261                    available_width: 468.0,
262                    is_last: true,
263                },
264            ],
265            space_before: 6.0,
266            space_after: 8.0,
267            borders: None,
268            shading: None,
269            indent_left: 0.0,
270            indent_right: 0.0,
271            jc: None,
272            keep_next: false,
273            keep_lines: false,
274            page_break_before: false,
275            widow_control: true,
276            heading_level: None,
277            heading_text: None,
278        };
279        assert!((block.content_height() - 26.0).abs() < 0.01);
280        assert!((block.total_height() - 40.0).abs() < 0.01);
281    }
282
283    /// Only the presets we can actually draw map to a drawable body. Anything
284    /// else still renders its text, so it must not be treated as a rectangle.
285    #[test]
286    fn shape_presets_map_to_what_we_can_draw() {
287        assert_eq!(ShapePreset::from_prst(Some("rect")), ShapePreset::Rect);
288        assert_eq!(ShapePreset::from_prst(Some("line")), ShapePreset::Line);
289        assert_eq!(
290            ShapePreset::from_prst(Some("straightConnector1")),
291            ShapePreset::Line
292        );
293        assert_eq!(
294            ShapePreset::from_prst(Some("roundRect")),
295            ShapePreset::Unsupported,
296            "an unhandled preset must not silently draw as a plain rectangle"
297        );
298        assert_eq!(ShapePreset::from_prst(None), ShapePreset::Unsupported);
299    }
300}