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::{
5    Align, Color, GroupElement, InlineItem, LayoutLine, LineBreakParams, MediaId, SourceNodeId,
6    StructureId, TextDirection,
7};
8use rdocx_oxml::borders::CT_PBdr;
9use rdocx_oxml::drawing::{
10    AnchorAlignH, AnchorAlignV, ST_RelativeFromH, ST_RelativeFromV, WrapType,
11};
12use std::ops::Deref;
13use std::sync::Arc;
14
15/// A floating drawing anchored to a paragraph.
16///
17/// Offsets are kept in points alongside the frame they are measured from. A
18/// `wp:anchor` offset is meaningless on its own: the same number means a
19/// different place depending on whether it is relative to the page, the
20/// margin, the text column or the paragraph.
21#[derive(Debug, Clone)]
22pub struct AnchoredDrawing {
23    /// Render underneath the text rather than on top of it.
24    pub behind_doc: bool,
25    /// Frame the horizontal offset is measured from.
26    pub rel_h: ST_RelativeFromH,
27    /// Horizontal offset in points.
28    pub off_h: f64,
29    /// Frame the vertical offset is measured from.
30    pub rel_v: ST_RelativeFromV,
31    /// Vertical offset in points.
32    pub off_v: f64,
33    /// Width in points.
34    pub width: f64,
35    /// Height in points.
36    pub height: f64,
37    /// How text flows around the drawing.
38    pub wrap: WrapType,
39    /// Space kept between the drawing and the text wrapping around it, in
40    /// points.
41    pub dist_top: f64,
42    pub dist_bottom: f64,
43    pub dist_left: f64,
44    pub dist_right: f64,
45    /// Horizontal alignment, used instead of the offset when present.
46    pub align_h: Option<AnchorAlignH>,
47    /// Vertical alignment, used instead of the offset when present.
48    pub align_v: Option<AnchorAlignV>,
49    /// What the drawing actually holds.
50    pub content: AnchoredContent,
51    /// Source description for an informative drawing.
52    pub alternate_text: Option<String>,
53    /// Logical figure node allocated before pagination.
54    pub structure_id: Option<StructureId>,
55}
56
57/// The drawable content of an anchored drawing.
58#[derive(Debug, Clone)]
59pub enum AnchoredContent {
60    /// A picture resolved to its content-addressed shared media identity.
61    Image { media_id: MediaId },
62    /// A backend-neutral group rendered in child-local chart coordinates.
63    Group(GroupElement),
64    /// A shape: preset geometry, an optional fill, and optional text.
65    ///
66    /// The text arrives already laid out, because breaking it into lines needs
67    /// a font manager and that only exists in the engine.
68    Shape {
69        /// Preset geometry we recognise.
70        preset: ShapePreset,
71        /// Fill colour, or `None` for `a:noFill` and for fills we cannot
72        /// resolve. An unfilled shape draws no body, only its text.
73        fill: Option<Color>,
74        /// Laid-out paragraphs of the shape's text box.
75        text: Vec<ParagraphBlock>,
76    },
77}
78
79/// The preset geometries we can draw.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ShapePreset {
82    /// A rectangle, drawn as a filled box.
83    Rect,
84    /// A straight line, drawn along the top edge of the extent.
85    Line,
86    /// Anything else. The body is not drawn, but text still is.
87    Unsupported,
88}
89
90impl ShapePreset {
91    /// Map an `a:prstGeom@prst` value onto what we can draw.
92    pub fn from_prst(prst: Option<&str>) -> Self {
93        match prst {
94            Some("rect") => ShapePreset::Rect,
95            Some("line") | Some("straightConnector1") => ShapePreset::Line,
96            _ => ShapePreset::Unsupported,
97        }
98    }
99}
100
101/// A laid-out block element (paragraph or table).
102#[derive(Debug, Clone)]
103pub enum LayoutBlock {
104    Paragraph(ParagraphBlock),
105    Table(TableBlock),
106}
107
108#[derive(Debug, Clone, Copy)]
109pub(crate) struct ParagraphSemantics {
110    pub source_node: Option<SourceNodeId>,
111    pub structure_id: Option<StructureId>,
112    pub reflow_direction: TextDirection,
113}
114
115#[derive(Debug, Clone)]
116pub(crate) enum CellBlockSemantics {
117    Paragraph(ParagraphSemantics),
118    Table(TableSemantics),
119}
120
121#[derive(Debug, Clone)]
122pub(crate) struct CellSemantics {
123    pub blocks: Vec<CellBlockSemantics>,
124}
125
126#[derive(Debug, Clone)]
127pub(crate) struct RowSemantics {
128    pub cells: Vec<CellSemantics>,
129}
130
131#[derive(Debug, Clone)]
132pub(crate) struct TableSemantics {
133    pub rows: Vec<RowSemantics>,
134}
135
136#[derive(Debug, Clone)]
137pub(crate) enum SharedLayoutBlock {
138    Owned {
139        block: Box<LayoutBlock>,
140        reflow_direction: TextDirection,
141    },
142    Paragraph {
143        block: Arc<ParagraphBlock>,
144        semantics: ParagraphSemantics,
145    },
146    Table {
147        block: Arc<TableBlock>,
148        semantics: TableSemantics,
149    },
150}
151
152#[derive(Clone, Copy)]
153pub(crate) struct ParagraphView<'a> {
154    pub block: &'a ParagraphBlock,
155    pub semantics: Option<&'a ParagraphSemantics>,
156    pub reflow_direction: TextDirection,
157    pub reflow_allowed: bool,
158}
159
160impl Deref for ParagraphView<'_> {
161    type Target = ParagraphBlock;
162
163    fn deref(&self) -> &Self::Target {
164        self.block
165    }
166}
167
168impl ParagraphView<'_> {
169    pub fn source_node(self) -> Option<Option<SourceNodeId>> {
170        self.semantics.map(|semantics| semantics.source_node)
171    }
172
173    pub fn structure_id(self) -> Option<StructureId> {
174        self.semantics
175            .map_or(self.block.structure_id, |semantics| semantics.structure_id)
176    }
177}
178
179#[derive(Clone, Copy)]
180pub(crate) struct TableView<'a> {
181    pub block: &'a TableBlock,
182    pub semantics: Option<&'a TableSemantics>,
183}
184
185impl Deref for TableView<'_> {
186    type Target = TableBlock;
187
188    fn deref(&self) -> &Self::Target {
189        self.block
190    }
191}
192
193pub(crate) trait LayoutBlockLike {
194    fn paragraph(&self) -> Option<ParagraphView<'_>>;
195    fn table(&self) -> Option<TableView<'_>>;
196
197    fn content_height(&self) -> f64 {
198        self.paragraph().map_or_else(
199            || self.table().unwrap().content_height(),
200            |p| p.content_height(),
201        )
202    }
203
204    fn space_before(&self) -> f64 {
205        self.paragraph()
206            .map_or(0.0, |paragraph| paragraph.space_before)
207    }
208
209    fn space_after(&self) -> f64 {
210        self.paragraph()
211            .map_or(0.0, |paragraph| paragraph.space_after)
212    }
213
214    fn page_break_before(&self) -> bool {
215        self.paragraph()
216            .is_some_and(|paragraph| paragraph.page_break_before)
217    }
218}
219
220impl LayoutBlockLike for LayoutBlock {
221    fn paragraph(&self) -> Option<ParagraphView<'_>> {
222        match self {
223            Self::Paragraph(block) => Some(ParagraphView {
224                block,
225                semantics: None,
226                reflow_direction: TextDirection::Auto,
227                reflow_allowed: true,
228            }),
229            Self::Table(_) => None,
230        }
231    }
232
233    fn table(&self) -> Option<TableView<'_>> {
234        match self {
235            Self::Paragraph(_) => None,
236            Self::Table(block) => Some(TableView {
237                block,
238                semantics: None,
239            }),
240        }
241    }
242}
243
244impl LayoutBlockLike for SharedLayoutBlock {
245    fn paragraph(&self) -> Option<ParagraphView<'_>> {
246        match self {
247            Self::Owned {
248                block,
249                reflow_direction,
250            } => block.paragraph().map(|mut paragraph| {
251                paragraph.reflow_direction = *reflow_direction;
252                paragraph
253            }),
254            Self::Paragraph { block, semantics } => Some(ParagraphView {
255                block,
256                semantics: Some(semantics),
257                reflow_direction: semantics.reflow_direction,
258                reflow_allowed: true,
259            }),
260            Self::Table { .. } => None,
261        }
262    }
263
264    fn table(&self) -> Option<TableView<'_>> {
265        match self {
266            Self::Owned { block, .. } => block.table(),
267            Self::Paragraph { .. } => None,
268            Self::Table { block, semantics } => Some(TableView {
269                block,
270                semantics: Some(semantics),
271            }),
272        }
273    }
274}
275
276impl LayoutBlock {
277    /// Total height including spacing.
278    pub fn total_height(&self) -> f64 {
279        match self {
280            LayoutBlock::Paragraph(p) => p.total_height(),
281            LayoutBlock::Table(t) => t.total_height(),
282        }
283    }
284
285    /// Content height without spacing.
286    pub fn content_height(&self) -> f64 {
287        match self {
288            LayoutBlock::Paragraph(p) => p.content_height(),
289            LayoutBlock::Table(t) => t.content_height(),
290        }
291    }
292
293    pub fn space_before(&self) -> f64 {
294        match self {
295            LayoutBlock::Paragraph(p) => p.space_before,
296            LayoutBlock::Table(_) => 0.0,
297        }
298    }
299
300    pub fn space_after(&self) -> f64 {
301        match self {
302            LayoutBlock::Paragraph(p) => p.space_after,
303            LayoutBlock::Table(_) => 0.0,
304        }
305    }
306
307    pub fn keep_next(&self) -> bool {
308        match self {
309            LayoutBlock::Paragraph(p) => p.keep_next,
310            LayoutBlock::Table(_) => false,
311        }
312    }
313
314    pub fn keep_lines(&self) -> bool {
315        match self {
316            LayoutBlock::Paragraph(p) => p.keep_lines,
317            LayoutBlock::Table(_) => false,
318        }
319    }
320
321    pub fn page_break_before(&self) -> bool {
322        match self {
323            LayoutBlock::Paragraph(p) => p.page_break_before,
324            LayoutBlock::Table(_) => false,
325        }
326    }
327
328    pub fn widow_control(&self) -> bool {
329        match self {
330            LayoutBlock::Paragraph(p) => p.widow_control,
331            LayoutBlock::Table(_) => false,
332        }
333    }
334}
335
336/// What a paragraph needs in order to be broken into lines again.
337///
338/// Whether a floating drawing overlaps a line is only known once the paragraph
339/// has a position on a page, which is after layout and during pagination. The
340/// inputs to line breaking therefore have to survive that far.
341///
342/// `InlineItem::Text` holds the same shaped glyphs `LayoutLine` already holds,
343/// so this roughly doubles a paragraph's text memory. It is carried only when
344/// the document contains a drawing that wraps, which nearly none do.
345#[derive(Debug, Clone)]
346pub struct ParagraphReflow {
347    pub items: Vec<InlineItem>,
348    pub params: LineBreakParams,
349}
350
351/// A laid-out paragraph with its lines and spacing.
352#[derive(Debug, Clone)]
353pub struct ParagraphBlock {
354    /// Laid-out lines.
355    pub lines: Vec<LayoutLine>,
356    /// Whether the tracked projection contains a visible revision.
357    pub has_visible_revision: bool,
358    /// Floating drawings anchored to this paragraph.
359    ///
360    /// These travel with the paragraph so the paginator can resolve a
361    /// paragraph-relative or line-relative offset once it knows where the
362    /// paragraph actually landed.
363    pub anchored: Vec<AnchoredDrawing>,
364    /// Space before the paragraph in points.
365    pub space_before: f64,
366    /// Space after the paragraph in points.
367    pub space_after: f64,
368    /// Paragraph borders.
369    pub borders: Option<CT_PBdr>,
370    /// Background shading color.
371    pub shading: Option<Color>,
372    /// Left indent in points.
373    pub indent_left: f64,
374    /// Right indent in points.
375    pub indent_right: f64,
376    /// Paragraph justification.
377    pub jc: Option<Align>,
378    /// Keep with next paragraph.
379    pub keep_next: bool,
380    /// Keep all lines together on one page.
381    pub keep_lines: bool,
382    /// Force page break before this paragraph.
383    pub page_break_before: bool,
384    /// Widow/orphan control.
385    pub widow_control: bool,
386    /// Heading level (1-9) if this is a heading paragraph, for outline generation.
387    pub heading_level: Option<u32>,
388    /// Heading text for outline generation.
389    pub heading_text: Option<String>,
390    /// Numbering instance and zero-based nesting level for semantic lists.
391    pub list: Option<(u32, u8)>,
392    /// Logical paragraph node allocated before pagination.
393    pub structure_id: Option<StructureId>,
394    /// Inputs for re-breaking this paragraph around a floating drawing.
395    ///
396    /// `None` unless the document holds a drawing that wraps.
397    pub reflow: Option<Box<ParagraphReflow>>,
398    /// Vertical space kept clear above the first line, for a drawing this
399    /// paragraph must clear rather than flow beside.
400    pub content_offset_top: f64,
401}
402
403impl ParagraphBlock {
404    /// Total height of the paragraph lines (not including before/after spacing).
405    pub fn content_height(&self) -> f64 {
406        self.content_offset_top + self.lines.iter().map(|l| l.height).sum::<f64>()
407    }
408
409    /// Total height including spacing.
410    pub fn total_height(&self) -> f64 {
411        self.space_before + self.content_height() + self.space_after
412    }
413
414    /// Number of lines.
415    pub fn line_count(&self) -> usize {
416        self.lines.len()
417    }
418}
419
420/// Build a ParagraphBlock from resolved properties and layout lines.
421pub fn build_paragraph_block(
422    lines: Vec<LayoutLine>,
423    space_before: f64,
424    space_after: f64,
425    borders: Option<CT_PBdr>,
426    shading: Option<Color>,
427    indent_left: f64,
428    indent_right: f64,
429    jc: Option<Align>,
430    keep_next: bool,
431    keep_lines: bool,
432    page_break_before: bool,
433    widow_control: bool,
434) -> ParagraphBlock {
435    ParagraphBlock {
436        lines,
437        has_visible_revision: false,
438        anchored: Vec::new(),
439        space_before,
440        space_after,
441        borders,
442        shading,
443        indent_left,
444        indent_right,
445        jc,
446        keep_next,
447        keep_lines,
448        page_break_before,
449        widow_control,
450        heading_level: None,
451        heading_text: None,
452        list: None,
453        structure_id: None,
454        reflow: None,
455        content_offset_top: 0.0,
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn paragraph_block_height() {
465        let block = ParagraphBlock {
466            anchored: Vec::new(),
467            has_visible_revision: false,
468            lines: vec![
469                LayoutLine {
470                    items: vec![],
471                    width: 0.0,
472                    ascent: 10.0,
473                    descent: 3.0,
474                    line_gap: 0.0,
475                    height: 13.0,
476                    indent_left: 0.0,
477                    available_width: 468.0,
478                    is_last: false,
479                },
480                LayoutLine {
481                    items: vec![],
482                    width: 0.0,
483                    ascent: 10.0,
484                    descent: 3.0,
485                    line_gap: 0.0,
486                    height: 13.0,
487                    indent_left: 0.0,
488                    available_width: 468.0,
489                    is_last: true,
490                },
491            ],
492            space_before: 6.0,
493            space_after: 8.0,
494            borders: None,
495            shading: None,
496            indent_left: 0.0,
497            indent_right: 0.0,
498            jc: None,
499            keep_next: false,
500            keep_lines: false,
501            page_break_before: false,
502            widow_control: true,
503            heading_level: None,
504            heading_text: None,
505            list: None,
506            structure_id: None,
507            reflow: None,
508            content_offset_top: 0.0,
509        };
510        assert!((block.content_height() - 26.0).abs() < 0.01);
511        assert!((block.total_height() - 40.0).abs() < 0.01);
512    }
513
514    /// Only the presets we can actually draw map to a drawable body. Anything
515    /// else still renders its text, so it must not be treated as a rectangle.
516    #[test]
517    fn shape_presets_map_to_what_we_can_draw() {
518        assert_eq!(ShapePreset::from_prst(Some("rect")), ShapePreset::Rect);
519        assert_eq!(ShapePreset::from_prst(Some("line")), ShapePreset::Line);
520        assert_eq!(
521            ShapePreset::from_prst(Some("straightConnector1")),
522            ShapePreset::Line
523        );
524        assert_eq!(
525            ShapePreset::from_prst(Some("roundRect")),
526            ShapePreset::Unsupported,
527            "an unhandled preset must not silently draw as a plain rectangle"
528        );
529        assert_eq!(ShapePreset::from_prst(None), ShapePreset::Unsupported);
530    }
531}