Skip to main content

rdocx_layout/
block.rs

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