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