Skip to main content

rdocx_layout/
engine.rs

1//! Layout engine orchestrator: ties all phases together.
2
3use rdocx_oxml::document::{BodyContent, CT_SectPr};
4use rdocx_oxml::drawing::WrapType;
5use rdocx_oxml::header_footer::HdrFtrType;
6use rdocx_oxml::properties::CT_PPr;
7use rdocx_oxml::shared::ST_HighlightColor;
8use rdocx_oxml::styles::CT_Styles;
9use rdocx_oxml::text::{BreakType, CT_P, FieldType, RunContent};
10
11use crate::block::{self, LayoutBlock, ParagraphBlock};
12use crate::convert;
13use crate::input::{LayoutInput, MediaRegistry};
14use crate::notes::NoteRegistry;
15use crate::paginator::{self, HeaderFooterContent, PageGeometry};
16use crate::style_resolver::{self, NumberingState};
17use crate::table;
18use oxml_layout::{
19    Color, DocumentMetadata, FieldKind, FontManager, InlineItem, LayoutResult, NoteRef, NoteStream,
20    PageFrame, PositionedElement, Rect, Result, TextSegment, break_into_lines,
21};
22
23/// The layout engine.
24pub struct Engine {
25    font_manager: FontManager,
26}
27
28impl Default for Engine {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl Engine {
35    pub fn new() -> Self {
36        Engine {
37            font_manager: FontManager::new(),
38        }
39    }
40
41    /// Create an engine that resolves fonts without system font discovery.
42    pub fn new_deterministic() -> Result<Self> {
43        Ok(Engine {
44            font_manager: FontManager::new_deterministic()?,
45        })
46    }
47
48    /// Lay out the entire document.
49    pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
50        // Load user-provided / DOCX-embedded fonts (highest priority)
51        if !input.fonts.is_empty() {
52            self.font_manager.load_additional_fonts(&input.fonts);
53        }
54
55        let styles = &input.styles;
56        let mut num_state = NumberingState::new();
57        let media = MediaRegistry::new(&input.images);
58
59        // Re-breaking a paragraph around a floating drawing needs its line
60        // breaking inputs kept alive past layout. Nearly no document has a
61        // drawing that wraps, so the state is dropped again unless one does.
62        let document_wraps = document_has_wrapping_drawing(input);
63
64        // Get final section properties (body-level sectPr)
65        let final_sect_pr = input
66            .document
67            .body
68            .sect_pr
69            .as_ref()
70            .cloned()
71            .unwrap_or_else(CT_SectPr::default_letter);
72
73        // Build sections: each section has blocks + geometry + header/footer
74        let mut sections: Vec<paginator::Section> = Vec::new();
75        let mut current_blocks: Vec<LayoutBlock> = Vec::new();
76        let mut current_sect_pr: Option<CT_SectPr> = None; // Will be set from paragraph sect_pr
77
78        for content in &input.document.body.content {
79            match content {
80                BodyContent::Paragraph(para) => {
81                    // Check if this paragraph ends a section (has sect_pr)
82                    let para_sect_pr = para.properties.as_ref().and_then(|p| p.sect_pr.clone());
83
84                    let sect_pr_for_layout = para_sect_pr
85                        .as_ref()
86                        .or(current_sect_pr.as_ref())
87                        .unwrap_or(&final_sect_pr);
88                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
89
90                    let mut para_block = layout_paragraph(
91                        para,
92                        geometry.content_width(),
93                        styles,
94                        input,
95                        &media,
96                        &mut self.font_manager,
97                        &mut num_state,
98                    )?;
99
100                    if !document_wraps {
101                        para_block.reflow = None;
102                    }
103
104                    // Detect heading style for outline generation
105                    if let Some(level) = detect_heading_level(para, styles) {
106                        para_block.heading_level = Some(level);
107                        para_block.heading_text = Some(para.text());
108                    }
109
110                    current_blocks.push(LayoutBlock::Paragraph(para_block));
111
112                    // If this paragraph has sect_pr, it ends a section
113                    if let Some(sect_pr) = para_sect_pr {
114                        let geometry = sect_pr_to_geometry(&sect_pr);
115                        let header_footer = layout_header_footer(
116                            &sect_pr,
117                            input,
118                            styles,
119                            &media,
120                            &mut self.font_manager,
121                            &mut num_state,
122                        )?;
123                        let title_pg = sect_pr.title_pg.unwrap_or(false);
124                        sections.push(paginator::Section {
125                            blocks: std::mem::take(&mut current_blocks),
126                            geometry,
127                            header_footer,
128                            title_pg,
129                        });
130                        current_sect_pr = Some(sect_pr);
131                    }
132                }
133                BodyContent::Table(tbl) => {
134                    let sect_pr_for_layout = current_sect_pr.as_ref().unwrap_or(&final_sect_pr);
135                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
136
137                    let table_block = table::layout_table(
138                        tbl,
139                        geometry.content_width(),
140                        styles,
141                        input,
142                        &media,
143                        &mut self.font_manager,
144                        &mut num_state,
145                    )?;
146                    current_blocks.push(LayoutBlock::Table(table_block));
147                }
148                _ => {} // Skip RawXml elements during layout
149            }
150        }
151
152        // Remaining blocks belong to the final section
153        let final_geometry = sect_pr_to_geometry(&final_sect_pr);
154        let final_hf = layout_header_footer(
155            &final_sect_pr,
156            input,
157            styles,
158            &media,
159            &mut self.font_manager,
160            &mut num_state,
161        )?;
162        let final_title_pg = final_sect_pr.title_pg.unwrap_or(false);
163        sections.push(paginator::Section {
164            blocks: current_blocks,
165            geometry: final_geometry,
166            header_footer: final_hf,
167            title_pg: final_title_pg,
168        });
169
170        // Lay the notes out once, before pagination, so the paginator can
171        // reserve exactly the height it will later draw.
172        let notes = NoteRegistry::build(
173            input,
174            styles,
175            &media,
176            &mut self.font_manager,
177            &mut num_state,
178            final_geometry.content_width(),
179        )?;
180
181        // Paginate across all sections
182        let (mut pages, outlines) =
183            paginator::paginate_sections(&sections, &self.font_manager, &media, &notes);
184
185        // Endnotes read at the end of the document, so they follow the last
186        // body page rather than sitting at the foot of their reference's page.
187        paginator::append_endnote_pages(&mut pages, &notes, final_geometry);
188
189        // Post-pagination pass: substitute field placeholders
190        let total_pages = pages.len();
191        for page in &mut pages {
192            let page_num = page.page_number;
193            substitute_fields(
194                &mut page.elements,
195                page_num,
196                total_pages,
197                &mut self.font_manager,
198            );
199        }
200
201        // Post-pagination pass: apply page background color
202        apply_page_background(&mut pages, input);
203
204        // Collect font data
205        let fonts = self.font_manager.all_font_data();
206
207        // Convert core properties to document metadata
208        let metadata = input.core_properties.as_ref().map(|cp| DocumentMetadata {
209            title: cp.title.clone(),
210            author: cp.creator.clone(),
211            subject: cp.subject.clone(),
212            keywords: cp.keywords.clone(),
213            creator: Some("rdocx".to_string()),
214        });
215
216        Ok(LayoutResult::new(pages, fonts, metadata, outlines))
217    }
218}
219
220/// Apply page background color from `w:background` element to all pages.
221fn apply_page_background(pages: &mut [PageFrame], input: &LayoutInput) {
222    let bg_xml = match &input.document.background_xml {
223        Some(xml) => xml,
224        None => return,
225    };
226
227    // Parse w:color attribute from background XML
228    let xml_str = std::str::from_utf8(bg_xml).unwrap_or("");
229    let color = extract_background_color(xml_str);
230    let color = match color {
231        Some(c) => c,
232        None => return,
233    };
234
235    // Insert a full-page FilledRect at position 0 on every page (renders underneath everything)
236    for page in pages.iter_mut() {
237        page.elements.insert(
238            0,
239            PositionedElement::FilledRect {
240                rect: Rect {
241                    x: 0.0,
242                    y: 0.0,
243                    width: page.width,
244                    height: page.height,
245                },
246                color,
247            },
248        );
249    }
250}
251
252/// Extract the background color hex from w:background XML.
253fn extract_background_color(xml: &str) -> Option<Color> {
254    // Look for w:color="RRGGBB" or color="RRGGBB"
255    for attr in ["w:color=\"", "color=\""] {
256        if let Some(start) = xml.find(attr) {
257            let val_start = start + attr.len();
258            if let Some(end) = xml[val_start..].find('"') {
259                let hex = &xml[val_start..val_start + end];
260                if hex.len() == 6 && hex != "auto" {
261                    return Some(Color::from_hex(hex));
262                }
263            }
264        }
265    }
266    None
267}
268
269/// Replace field placeholder GlyphRuns with actual values.
270fn substitute_fields(
271    elements: &mut [PositionedElement],
272    page_number: usize,
273    total_pages: usize,
274    fm: &mut FontManager,
275) {
276    for element in elements.iter_mut() {
277        if let PositionedElement::Text(run) = element
278            && let Some(fk) = run.field_kind
279        {
280            let value = match fk {
281                FieldKind::Page => page_number.to_string(),
282                FieldKind::NumPages => total_pages.to_string(),
283            };
284            // Re-shape the text with the actual value
285            if let Ok(shaped) = fm.shape_text(run.font_id, &value, run.font_size) {
286                run.text = value;
287                run.glyph_ids = shaped.glyph_ids;
288                run.advances = shaped.advances;
289            }
290        }
291    }
292}
293
294/// Detect if a paragraph has a heading style, returning the level (1-9).
295fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
296    let style_id = para.properties.as_ref()?.style_id.as_deref()?;
297    // Check if style ID matches "Heading1" .. "Heading9"
298    if let Some(rest) = style_id.strip_prefix("Heading") {
299        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
300    }
301    // Also check style name in the styles definitions
302    if let Some(style_def) = styles.get_by_id(style_id)
303        && let Some(ref name) = style_def.name
304        && let Some(rest) = name.strip_prefix("heading ")
305    {
306        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
307    }
308    None
309}
310
311/// Lay out a single paragraph into a ParagraphBlock.
312pub fn layout_paragraph(
313    para: &CT_P,
314    available_width: f64,
315    styles: &CT_Styles,
316    input: &LayoutInput,
317    media: &MediaRegistry,
318    fm: &mut FontManager,
319    num_state: &mut NumberingState,
320) -> Result<ParagraphBlock> {
321    // Resolve paragraph properties
322    let para_style_id = para.properties.as_ref().and_then(|p| p.style_id.as_deref());
323
324    let resolved_ppr = style_resolver::resolve_paragraph_properties(para_style_id, styles);
325
326    let mut effective_ppr = resolved_ppr;
327
328    // A numbering level carries paragraph properties of its own, mainly the
329    // indentation for that level. They sit between the style and direct
330    // formatting, so merge them before the direct properties rather than
331    // after. Without this every level of a list draws at the same indent.
332    let direct_ppr = para.properties.as_ref();
333    let list_num_id = direct_ppr.and_then(|p| p.num_id).or(effective_ppr.num_id);
334    let list_ilvl = direct_ppr
335        .and_then(|p| p.num_ilvl)
336        .or(effective_ppr.num_ilvl)
337        .unwrap_or(0);
338    if let (Some(num_id), Some(numbering)) = (list_num_id, input.numbering.as_ref())
339        && let Some(lvl_ppr) =
340            style_resolver::level_paragraph_properties(num_id, list_ilvl, numbering)
341    {
342        merge_direct_ppr(&mut effective_ppr, lvl_ppr);
343    }
344
345    // Merge direct paragraph properties
346    if let Some(direct_ppr) = direct_ppr {
347        merge_direct_ppr(&mut effective_ppr, direct_ppr);
348    }
349
350    // Convert paragraph properties to layout values
351    let space_before = effective_ppr.space_before.map(|t| t.to_pt()).unwrap_or(0.0);
352    let space_after = effective_ppr.space_after.map(|t| t.to_pt()).unwrap_or(0.0);
353    let ind_left = effective_ppr.ind_left.map(|t| t.to_pt()).unwrap_or(0.0);
354    let ind_right = effective_ppr.ind_right.map(|t| t.to_pt()).unwrap_or(0.0);
355    let keep_next = effective_ppr.keep_next.unwrap_or(false);
356    let keep_lines = effective_ppr.keep_lines.unwrap_or(false);
357    let page_break_before = effective_ppr.page_break_before.unwrap_or(false);
358    let widow_control = effective_ppr.widow_control.unwrap_or(true);
359    let jc = convert::alignment(effective_ppr.jc);
360
361    // Parse shading color
362    let shading = effective_ppr
363        .shading
364        .as_ref()
365        .and_then(|shd| shd.fill.as_ref())
366        .filter(|f| f != &"auto")
367        .map(|f| Color::from_hex(f));
368
369    // Convert runs to inline items
370    let mut inline_items = Vec::new();
371
372    // Handle numbering marker
373    if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) {
374        let ilvl = effective_ppr.num_ilvl.unwrap_or(0);
375        if let Some(marker) = style_resolver::generate_marker(num_id, ilvl, numbering, num_state) {
376            // Shape the marker text
377            let marker_rpr = marker.marker_rpr;
378            let marker_font_size = marker_rpr.sz.map(|hp| hp.to_pt()).unwrap_or_else(|| {
379                style_resolver::resolve_run_properties(para_style_id, None, styles)
380                    .sz
381                    .map(|hp| hp.to_pt())
382                    .unwrap_or(11.0)
383            });
384            let marker_bold = marker_rpr.bold.unwrap_or(false);
385            let marker_italic = marker_rpr.italic.unwrap_or(false);
386            let marker_font_family = marker_rpr.font_ascii.as_deref();
387
388            // Bullet glyphs are not in every font either, so the marker gets
389            // the same coverage check as body text.
390            if let Ok(font_id) = fm.resolve_font_for_text(
391                marker_font_family,
392                marker_bold,
393                marker_italic,
394                &marker.marker_text,
395            ) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size)
396            {
397                let metrics = fm.metrics(font_id, marker_font_size)?;
398                let color = marker_rpr
399                    .color
400                    .as_ref()
401                    .map(|c| Color::from_hex(c))
402                    .unwrap_or(Color::BLACK);
403
404                inline_items.push(InlineItem::Marker(TextSegment {
405                    text: marker.marker_text,
406                    font_id,
407                    font_size: marker_font_size,
408                    glyph_ids: shaped.glyph_ids,
409                    advances: shaped.advances,
410                    width: shaped.width,
411                    ascent: metrics.ascent,
412                    descent: metrics.descent,
413                    line_gap: 0.0,
414                    color,
415                    bold: marker_bold,
416                    italic: marker_italic,
417                    underline: None,
418                    strike: false,
419                    dstrike: false,
420                    highlight: None,
421                    baseline_offset: 0.0,
422                    hyperlink_url: None,
423                    field_kind: None,
424                    note: None,
425                }));
426
427                // Add a space/tab after the marker
428                inline_items.push(InlineItem::Tab);
429            }
430        }
431    }
432
433    // Build hyperlink URL map: run index → URL
434    let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
435        std::collections::HashMap::new();
436    for hl in &para.hyperlinks {
437        if let Some(ref rel_id) = hl.rel_id
438            && let Some(url) = input.hyperlink_urls.get(rel_id)
439        {
440            for run_idx in hl.run_start..hl.run_end {
441                run_hyperlink_url.insert(run_idx, url.clone());
442            }
443        }
444    }
445
446    // Process runs
447    for (run_idx, run) in para.runs.iter().enumerate() {
448        let current_hyperlink_url = run_hyperlink_url.get(&run_idx).cloned();
449
450        let run_style_id = run.properties.as_ref().and_then(|p| p.style_id.as_deref());
451
452        let resolved_rpr =
453            style_resolver::resolve_run_properties(para_style_id, run_style_id, styles);
454
455        // Merge direct run properties
456        let mut effective_rpr = resolved_rpr;
457        if let Some(ref direct_rpr) = run.properties {
458            effective_rpr.merge_from(direct_rpr);
459        }
460
461        // Skip hidden text
462        if effective_rpr.vanish == Some(true) {
463            continue;
464        }
465
466        let mut font_size = effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
467        let bold = effective_rpr.bold.unwrap_or(false);
468        let italic = effective_rpr.italic.unwrap_or(false);
469
470        // Resolve font family: theme font takes priority when no explicit font is set
471        let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
472
473        // Resolve color: theme color takes priority over literal color value
474        let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
475
476        // Decoration properties
477        let underline = convert::underline(effective_rpr.underline);
478        let strike = effective_rpr.strike.unwrap_or(false);
479        let dstrike = effective_rpr.dstrike.unwrap_or(false);
480        let highlight = effective_rpr.highlight.and_then(highlight_to_color);
481
482        // Superscript/subscript handling
483        let mut baseline_offset = 0.0;
484        if let Some(ref va) = effective_rpr.vert_align {
485            match va.as_str() {
486                "superscript" => {
487                    // Reduce font size to ~58% and raise baseline
488                    let original_size = font_size;
489                    font_size *= 0.58;
490                    baseline_offset = original_size * 0.33; // raise by 1/3 of original size
491                }
492                "subscript" => {
493                    // Reduce font size to ~58% and lower baseline
494                    let original_size = font_size;
495                    font_size *= 0.58;
496                    baseline_offset = -(original_size * 0.14); // lower
497                }
498                _ => {}
499            }
500        }
501
502        // Position offset (in half-points, positive=raise)
503        if let Some(pos) = effective_rpr.position {
504            baseline_offset += pos as f64 / 2.0; // half-points to points
505        }
506
507        // Resolved against the run's own text, so a family without glyphs for
508        // this script is replaced by one that has them.
509        let font_id =
510            fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?;
511        let metrics = fm.metrics(font_id, font_size)?;
512
513        for content in &run.content {
514            match content {
515                RunContent::Text(ct_text) => {
516                    let text = if effective_rpr.caps == Some(true) {
517                        ct_text.text.to_uppercase()
518                    } else {
519                        ct_text.text.clone()
520                    };
521
522                    if text.is_empty() {
523                        continue;
524                    }
525
526                    let mut shaped = fm.shape_text(font_id, &text, font_size)?;
527
528                    // Apply character spacing from run properties (in twips)
529                    if let Some(spacing) = effective_rpr.spacing {
530                        let extra = spacing.to_pt();
531                        for advance in &mut shaped.advances {
532                            *advance += extra;
533                        }
534                        shaped.width += extra * shaped.advances.len() as f64;
535                    }
536
537                    inline_items.extend(convert::text_segments(TextSegment {
538                        text,
539                        font_id,
540                        font_size,
541                        glyph_ids: shaped.glyph_ids,
542                        advances: shaped.advances,
543                        width: shaped.width,
544                        ascent: metrics.ascent,
545                        descent: metrics.descent,
546                        line_gap: 0.0,
547                        color,
548                        bold,
549                        italic,
550                        underline,
551                        strike,
552                        dstrike,
553                        highlight,
554                        baseline_offset,
555                        hyperlink_url: current_hyperlink_url.clone(),
556                        field_kind: None,
557                        note: None,
558                    }));
559                }
560                RunContent::Tab => {
561                    inline_items.push(InlineItem::Tab);
562                }
563                RunContent::Break(bt) => match bt {
564                    BreakType::Line => inline_items.push(InlineItem::LineBreak),
565                    BreakType::Page => inline_items.push(InlineItem::PageBreak),
566                    BreakType::Column => inline_items.push(InlineItem::ColumnBreak),
567                },
568                RunContent::Drawing(drawing) => {
569                    if let Some(ref inline) = drawing.inline {
570                        let width = inline.extent_cx.to_pt();
571                        let height = inline.extent_cy.to_pt();
572                        inline_items.push(InlineItem::Image {
573                            width,
574                            height,
575                            media_id: media.id_for_relationship(&inline.embed_id),
576                        });
577                    }
578                }
579                RunContent::Field { field_type } => {
580                    // Shape a placeholder ("99") for estimated width
581                    let placeholder = "99";
582                    let fk = match field_type {
583                        FieldType::Page => FieldKind::Page,
584                        FieldType::NumPages => FieldKind::NumPages,
585                        FieldType::Other(_) => continue, // skip unsupported fields
586                    };
587                    let shaped = fm.shape_text(font_id, placeholder, font_size)?;
588                    inline_items.push(InlineItem::Text(TextSegment {
589                        text: placeholder.to_string(),
590                        font_id,
591                        font_size,
592                        glyph_ids: shaped.glyph_ids,
593                        advances: shaped.advances,
594                        width: shaped.width,
595                        ascent: metrics.ascent,
596                        descent: metrics.descent,
597                        line_gap: 0.0,
598                        color,
599                        bold,
600                        italic,
601                        underline: None,
602                        strike: false,
603                        dstrike: false,
604                        highlight: None,
605                        baseline_offset,
606                        hyperlink_url: None,
607                        field_kind: Some(fk),
608                        note: None,
609                    }));
610                }
611                RunContent::FootnoteRef { id } | RunContent::EndnoteRef { id } => {
612                    // The two streams number independently, so the marker has
613                    // to carry which one it came from.
614                    let stream = match content {
615                        RunContent::EndnoteRef { .. } => NoteStream::Endnote,
616                        _ => NoteStream::Footnote,
617                    };
618                    // Render as superscript number
619                    let marker = id.to_string();
620                    let sup_size = font_size * 0.58;
621                    let sup_offset = font_size * 0.33; // raise baseline
622                    let shaped = fm.shape_text(font_id, &marker, sup_size)?;
623                    let sup_metrics = fm.metrics(font_id, sup_size)?;
624                    inline_items.push(InlineItem::Text(TextSegment {
625                        text: marker,
626                        font_id,
627                        font_size: sup_size,
628                        glyph_ids: shaped.glyph_ids,
629                        advances: shaped.advances,
630                        width: shaped.width,
631                        ascent: sup_metrics.ascent,
632                        descent: sup_metrics.descent,
633                        line_gap: 0.0,
634                        color,
635                        bold,
636                        italic,
637                        underline: None,
638                        strike: false,
639                        dstrike: false,
640                        highlight: None,
641                        baseline_offset: sup_offset,
642                        hyperlink_url: None,
643                        field_kind: None,
644                        note: Some(NoteRef { stream, id: *id }),
645                    }));
646                }
647            }
648        }
649    }
650
651    // Line breaking
652    let line_params = convert::line_break_params(&effective_ppr, available_width);
653
654    let mut lines = break_into_lines(&inline_items, &line_params, fm)?;
655    convert::restore_word_line_heights(&mut lines, &effective_ppr);
656
657    let mut result = block::build_paragraph_block(
658        lines,
659        space_before,
660        space_after,
661        effective_ppr.borders,
662        shading,
663        ind_left,
664        ind_right,
665        jc,
666        keep_next,
667        keep_lines,
668        page_break_before,
669        widow_control,
670    );
671    result.anchored = collect_anchored_drawings(para, styles, input, media, fm, num_state)?;
672    // `inline_items` is finished with here and would otherwise be dropped, so
673    // handing it to the reflow costs nothing but the memory it already holds.
674    // `Engine::layout` frees it again unless the document wraps.
675    result.reflow = Some(Box::new(block::ParagraphReflow {
676        items: inline_items,
677        params: line_params,
678    }));
679    Ok(result)
680}
681
682/// Whether any drawing in the document body wraps text around itself.
683///
684/// A document without one can never reach the reflow path, so it does not pay
685/// for it.
686fn document_has_wrapping_drawing(input: &LayoutInput) -> bool {
687    fn paragraph_wraps(para: &CT_P) -> bool {
688        para.runs.iter().any(|run| {
689            run.content
690                .iter()
691                .filter_map(|rc| match rc {
692                    RunContent::Drawing(d) => Some(d),
693                    _ => None,
694                })
695                .chain(run.alt_drawings.iter())
696                .any(|drawing| {
697                    drawing
698                        .anchor
699                        .as_ref()
700                        .is_some_and(|anchor| anchor.wrap != WrapType::None)
701                })
702        })
703    }
704
705    input
706        .document
707        .body
708        .content
709        .iter()
710        .any(|content| match content {
711            BodyContent::Paragraph(para) => paragraph_wraps(para),
712            BodyContent::Table(table) => table
713                .rows
714                .iter()
715                .flat_map(|row| row.cells.iter())
716                .flat_map(|cell| cell.content.iter())
717                .any(|content| match content {
718                    rdocx_oxml::table::CellContent::Paragraph(para) => paragraph_wraps(para),
719                    // A drawing inside a nested table is rare enough that the
720                    // conservative answer is to look no deeper.
721                    rdocx_oxml::table::CellContent::Table(_) => false,
722                }),
723            _ => false,
724        })
725}
726
727/// Collect the floating drawings anchored to a paragraph.
728///
729/// The offsets stay paired with the frame they are measured from. Resolving
730/// them here is not possible: a paragraph-relative offset needs the laid-out
731/// position of the paragraph, which only the paginator knows.
732///
733/// A shape's text box is laid out here rather than later, because breaking it
734/// into lines needs the font manager.
735fn collect_anchored_drawings(
736    para: &CT_P,
737    styles: &CT_Styles,
738    input: &LayoutInput,
739    media: &MediaRegistry,
740    fm: &mut FontManager,
741    num_state: &mut NumberingState,
742) -> Result<Vec<block::AnchoredDrawing>> {
743    let mut out = Vec::new();
744
745    // Drawings written plainly, and drawings recovered from an
746    // mc:AlternateContent block, are both anchored the same way.
747    for run in &para.runs {
748        let plain = run.content.iter().filter_map(|rc| match rc {
749            RunContent::Drawing(d) => Some(d),
750            _ => None,
751        });
752        for drawing in plain.chain(run.alt_drawings.iter()) {
753            let Some(anchor) = drawing.anchor.as_ref() else {
754                continue;
755            };
756
757            // A picture also carries a pic:spPr, so a parsed shape alone does
758            // not mean this is a shape. An embed id is what makes it a
759            // picture, and that takes precedence.
760            let shape = if anchor.embed_id.is_empty() {
761                anchor.shape.as_ref()
762            } else {
763                None
764            };
765
766            let content = match shape {
767                Some(shape) => {
768                    // A shape's text box wraps at the shape width.
769                    let mut text = Vec::new();
770                    for p in &shape.text {
771                        text.push(layout_paragraph(
772                            p,
773                            anchor.extent_cx.to_pt(),
774                            styles,
775                            input,
776                            media,
777                            fm,
778                            num_state,
779                        )?);
780                    }
781                    block::AnchoredContent::Shape {
782                        preset: block::ShapePreset::from_prst(shape.preset.as_deref()),
783                        fill: shape.solid_fill.as_deref().map(Color::from_hex),
784                        text,
785                    }
786                }
787                None if anchor.embed_id.is_empty() => continue,
788                None => block::AnchoredContent::Image {
789                    media_id: media.id_for_relationship(&anchor.embed_id),
790                },
791            };
792
793            out.push(block::AnchoredDrawing {
794                behind_doc: anchor.behind_doc,
795                rel_h: anchor.pos_h_relative_from,
796                off_h: anchor.pos_h_offset.to_pt(),
797                rel_v: anchor.pos_v_relative_from,
798                off_v: anchor.pos_v_offset.to_pt(),
799                width: anchor.extent_cx.to_pt(),
800                height: anchor.extent_cy.to_pt(),
801                wrap: anchor.wrap,
802                dist_top: anchor.dist_t.to_pt(),
803                dist_bottom: anchor.dist_b.to_pt(),
804                dist_left: anchor.dist_l.to_pt(),
805                dist_right: anchor.dist_r.to_pt(),
806                align_h: anchor.pos_h_align,
807                align_v: anchor.pos_v_align,
808                content,
809            });
810        }
811    }
812    Ok(out)
813}
814
815/// Merge direct paragraph properties (only fields explicitly set in the XML).
816fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
817    // Don't merge style_id — that was already used for resolution
818    if direct.jc.is_some() {
819        effective.jc = direct.jc;
820    }
821    if direct.space_before.is_some() {
822        effective.space_before = direct.space_before;
823    }
824    if direct.space_after.is_some() {
825        effective.space_after = direct.space_after;
826    }
827    if direct.line_spacing.is_some() {
828        effective.line_spacing = direct.line_spacing;
829    }
830    if direct.line_rule.is_some() {
831        effective.line_rule = direct.line_rule.clone();
832    }
833    if direct.ind_left.is_some() {
834        effective.ind_left = direct.ind_left;
835    }
836    if direct.ind_right.is_some() {
837        effective.ind_right = direct.ind_right;
838    }
839    if direct.ind_first_line.is_some() {
840        effective.ind_first_line = direct.ind_first_line;
841    }
842    if direct.ind_hanging.is_some() {
843        effective.ind_hanging = direct.ind_hanging;
844    }
845    if direct.keep_next.is_some() {
846        effective.keep_next = direct.keep_next;
847    }
848    if direct.keep_lines.is_some() {
849        effective.keep_lines = direct.keep_lines;
850    }
851    if direct.page_break_before.is_some() {
852        effective.page_break_before = direct.page_break_before;
853    }
854    if direct.widow_control.is_some() {
855        effective.widow_control = direct.widow_control;
856    }
857    if direct.borders.is_some() {
858        effective.borders = direct.borders.clone();
859    }
860    if direct.tabs.is_some() {
861        effective.tabs = direct.tabs.clone();
862    }
863    if direct.shading.is_some() {
864        effective.shading = direct.shading.clone();
865    }
866    if direct.num_id.is_some() {
867        effective.num_id = direct.num_id;
868    }
869    if direct.num_ilvl.is_some() {
870        effective.num_ilvl = direct.num_ilvl;
871    }
872}
873
874/// Convert section properties to page geometry.
875fn sect_pr_to_geometry(sect_pr: &CT_SectPr) -> PageGeometry {
876    PageGeometry {
877        page_width: sect_pr.page_width.map(|t| t.to_pt()).unwrap_or(612.0),
878        page_height: sect_pr.page_height.map(|t| t.to_pt()).unwrap_or(792.0),
879        margin_top: sect_pr.margin_top.map(|t| t.to_pt()).unwrap_or(72.0),
880        margin_right: sect_pr.margin_right.map(|t| t.to_pt()).unwrap_or(72.0),
881        margin_bottom: sect_pr.margin_bottom.map(|t| t.to_pt()).unwrap_or(72.0),
882        margin_left: sect_pr.margin_left.map(|t| t.to_pt()).unwrap_or(72.0),
883        header_distance: sect_pr.header_distance.map(|t| t.to_pt()).unwrap_or(36.0),
884        footer_distance: sect_pr.footer_distance.map(|t| t.to_pt()).unwrap_or(36.0),
885    }
886}
887
888/// Lay out header and footer content (both Default and First-page).
889fn layout_header_footer(
890    sect_pr: &CT_SectPr,
891    input: &LayoutInput,
892    styles: &CT_Styles,
893    media: &MediaRegistry,
894    fm: &mut FontManager,
895    num_state: &mut NumberingState,
896) -> Result<Option<HeaderFooterContent>> {
897    let mut has_content = false;
898    let mut header_blocks = Vec::new();
899    let mut footer_blocks = Vec::new();
900    let mut first_header_blocks = Vec::new();
901    let mut first_footer_blocks = Vec::new();
902
903    let geometry = sect_pr_to_geometry(sect_pr);
904    let width = geometry.content_width();
905
906    for href in &sect_pr.header_refs {
907        let target_blocks = match href.hdr_ftr_type {
908            HdrFtrType::Default => &mut header_blocks,
909            HdrFtrType::First => &mut first_header_blocks,
910            _ => continue, // skip Even for now
911        };
912        if let Some(hdr) = input.headers.get(&href.rel_id) {
913            for para in &hdr.paragraphs {
914                let block = layout_paragraph(para, width, styles, input, media, fm, num_state)?;
915                target_blocks.push(block);
916            }
917            has_content = true;
918        }
919    }
920
921    for fref in &sect_pr.footer_refs {
922        let target_blocks = match fref.hdr_ftr_type {
923            HdrFtrType::Default => &mut footer_blocks,
924            HdrFtrType::First => &mut first_footer_blocks,
925            _ => continue, // skip Even for now
926        };
927        if let Some(ftr) = input.footers.get(&fref.rel_id) {
928            for para in &ftr.paragraphs {
929                let block = layout_paragraph(para, width, styles, input, media, fm, num_state)?;
930                target_blocks.push(block);
931            }
932            has_content = true;
933        }
934    }
935
936    if has_content {
937        Ok(Some(HeaderFooterContent {
938            header_blocks,
939            footer_blocks,
940            first_header_blocks,
941            first_footer_blocks,
942        }))
943    } else {
944        Ok(None)
945    }
946}
947
948/// Resolve the effective font family for a run, considering theme fonts.
949///
950/// Priority: explicit font_ascii > theme font > None (use default).
951fn resolve_font_family(
952    rpr: &rdocx_oxml::properties::CT_RPr,
953    theme: Option<&rdocx_oxml::theme::Theme>,
954) -> Option<String> {
955    // Explicit font name takes priority
956    if rpr.font_ascii.is_some() {
957        return rpr.font_ascii.clone();
958    }
959
960    // Resolve theme font reference
961    if let (Some(theme_ref), Some(theme)) = (&rpr.font_ascii_theme, theme) {
962        let font = match theme_ref.as_str() {
963            "majorAscii" | "majorHAnsi" | "majorBidi" | "majorEastAsia" => {
964                theme.major_font.as_deref()
965            }
966            "minorAscii" | "minorHAnsi" | "minorBidi" | "minorEastAsia" => {
967                theme.minor_font.as_deref()
968            }
969            _ => None,
970        };
971        if let Some(f) = font {
972            return Some(f.to_string());
973        }
974    }
975
976    None
977}
978
979/// Resolve the effective color for a run, considering theme colors.
980///
981/// Priority: literal color (non-auto) > theme color > black.
982fn resolve_run_color(
983    rpr: &rdocx_oxml::properties::CT_RPr,
984    theme: Option<&rdocx_oxml::theme::Theme>,
985) -> Color {
986    // If theme color is specified, resolve it from the theme
987    if let Some(ref theme_name) = rpr.color_theme
988        && let Some(theme) = theme
989        && let Some(hex) = theme.colors.get(theme_name)
990    {
991        return Color::from_hex(hex);
992    }
993
994    // Fall back to literal color value
995    rpr.color
996        .as_ref()
997        .filter(|c| c.as_str() != "auto")
998        .map(|c| Color::from_hex(c))
999        .unwrap_or(Color::BLACK)
1000}
1001
1002/// Convert a highlight color enum to an RGBA Color.
1003fn highlight_to_color(h: ST_HighlightColor) -> Option<Color> {
1004    match h {
1005        ST_HighlightColor::None => None,
1006        ST_HighlightColor::Black => Some(Color {
1007            r: 0.0,
1008            g: 0.0,
1009            b: 0.0,
1010            a: 1.0,
1011        }),
1012        ST_HighlightColor::Blue => Some(Color {
1013            r: 0.0,
1014            g: 0.0,
1015            b: 1.0,
1016            a: 1.0,
1017        }),
1018        ST_HighlightColor::Cyan => Some(Color {
1019            r: 0.0,
1020            g: 1.0,
1021            b: 1.0,
1022            a: 1.0,
1023        }),
1024        ST_HighlightColor::DarkBlue => Some(Color {
1025            r: 0.0,
1026            g: 0.0,
1027            b: 0.545,
1028            a: 1.0,
1029        }),
1030        ST_HighlightColor::DarkCyan => Some(Color {
1031            r: 0.0,
1032            g: 0.545,
1033            b: 0.545,
1034            a: 1.0,
1035        }),
1036        ST_HighlightColor::DarkGray => Some(Color {
1037            r: 0.663,
1038            g: 0.663,
1039            b: 0.663,
1040            a: 1.0,
1041        }),
1042        ST_HighlightColor::DarkGreen => Some(Color {
1043            r: 0.0,
1044            g: 0.392,
1045            b: 0.0,
1046            a: 1.0,
1047        }),
1048        ST_HighlightColor::DarkMagenta => Some(Color {
1049            r: 0.545,
1050            g: 0.0,
1051            b: 0.545,
1052            a: 1.0,
1053        }),
1054        ST_HighlightColor::DarkRed => Some(Color {
1055            r: 0.545,
1056            g: 0.0,
1057            b: 0.0,
1058            a: 1.0,
1059        }),
1060        ST_HighlightColor::DarkYellow => Some(Color {
1061            r: 0.545,
1062            g: 0.545,
1063            b: 0.0,
1064            a: 1.0,
1065        }),
1066        ST_HighlightColor::Green => Some(Color {
1067            r: 0.0,
1068            g: 1.0,
1069            b: 0.0,
1070            a: 1.0,
1071        }),
1072        ST_HighlightColor::LightGray => Some(Color {
1073            r: 0.827,
1074            g: 0.827,
1075            b: 0.827,
1076            a: 1.0,
1077        }),
1078        ST_HighlightColor::Magenta => Some(Color {
1079            r: 1.0,
1080            g: 0.0,
1081            b: 1.0,
1082            a: 1.0,
1083        }),
1084        ST_HighlightColor::Red => Some(Color {
1085            r: 1.0,
1086            g: 0.0,
1087            b: 0.0,
1088            a: 1.0,
1089        }),
1090        ST_HighlightColor::White => Some(Color {
1091            r: 1.0,
1092            g: 1.0,
1093            b: 1.0,
1094            a: 1.0,
1095        }),
1096        ST_HighlightColor::Yellow => Some(Color {
1097            r: 1.0,
1098            g: 1.0,
1099            b: 0.0,
1100            a: 1.0,
1101        }),
1102    }
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107    use super::*;
1108    use crate::input::ImageData;
1109    use oxml_layout::MediaId;
1110    use std::collections::HashMap;
1111
1112    fn make_input_with_text(text: &str) -> LayoutInput {
1113        let mut doc = rdocx_oxml::document::CT_Document::new();
1114        let mut p = CT_P::new();
1115        p.add_run(text);
1116        doc.body.add_paragraph(p);
1117
1118        LayoutInput {
1119            document: doc,
1120            styles: CT_Styles::new_default(),
1121            numbering: None,
1122            headers: HashMap::new(),
1123            footers: HashMap::new(),
1124            images: HashMap::new(),
1125            core_properties: None,
1126            hyperlink_urls: HashMap::new(),
1127            footnotes: None,
1128            endnotes: None,
1129            theme: None,
1130            fonts: Vec::new(),
1131        }
1132    }
1133
1134    #[test]
1135    fn layout_simple_document() {
1136        let input = make_input_with_text("Hello World");
1137        let result = Engine::new().layout(&input);
1138        // On systems without fonts, this may fail — that's OK
1139        if let Ok(result) = result {
1140            assert!(!result.pages.is_empty());
1141            assert_eq!(result.pages[0].page_number, 1);
1142            assert!((result.pages[0].width - 612.0).abs() < 0.01);
1143        }
1144    }
1145
1146    #[test]
1147    fn layout_empty_document() {
1148        let mut doc = rdocx_oxml::document::CT_Document::new();
1149        doc.body.add_paragraph(CT_P::new());
1150
1151        let input = LayoutInput {
1152            document: doc,
1153            styles: CT_Styles::new_default(),
1154            numbering: None,
1155            headers: HashMap::new(),
1156            footers: HashMap::new(),
1157            images: HashMap::new(),
1158            core_properties: None,
1159            hyperlink_urls: HashMap::new(),
1160            footnotes: None,
1161            endnotes: None,
1162            theme: None,
1163            fonts: Vec::new(),
1164        };
1165
1166        let result = Engine::new().layout(&input);
1167        if let Ok(result) = result {
1168            assert_eq!(result.pages.len(), 1);
1169        }
1170    }
1171
1172    #[test]
1173    fn empty_shapeless_anchor_keeps_the_pre_cutover_omission() {
1174        let input = make_input_with_text("");
1175        let mut paragraph = CT_P::new();
1176        paragraph.add_run("").content = vec![RunContent::Drawing(
1177            rdocx_oxml::drawing::CT_Drawing::anchor(rdocx_oxml::drawing::CT_Anchor::background(
1178                "", 914_400, 914_400,
1179            )),
1180        )];
1181        let mut font_manager = FontManager::new();
1182        let mut numbering_state = NumberingState::new();
1183        let media = MediaRegistry::new(&input.images);
1184
1185        let anchored = collect_anchored_drawings(
1186            &paragraph,
1187            &input.styles,
1188            &input,
1189            &media,
1190            &mut font_manager,
1191            &mut numbering_state,
1192        )
1193        .expect("empty shapeless anchor collection should succeed");
1194
1195        assert!(anchored.is_empty());
1196    }
1197
1198    #[test]
1199    fn colliding_media_ids_keep_inline_and_anchored_image_bytes_distinct() {
1200        let mut input = make_input_with_text("");
1201        input.images.insert(
1202            "rIdInline".to_string(),
1203            ImageData {
1204                data: vec![1, 2, 3],
1205                content_type: "image/png".to_string(),
1206            },
1207        );
1208        input.images.insert(
1209            "rIdAnchor".to_string(),
1210            ImageData {
1211                data: vec![4, 5, 6],
1212                content_type: "image/jpeg".to_string(),
1213            },
1214        );
1215
1216        let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
1217        let inline_id = media.id_for_relationship("rIdInline");
1218        let anchor_id = media.id_for_relationship("rIdAnchor");
1219        assert_ne!(inline_id, anchor_id);
1220
1221        let line = oxml_layout::LayoutLine {
1222            items: vec![oxml_layout::LineItem::Image {
1223                width: 12.0,
1224                height: 10.0,
1225                media_id: inline_id,
1226            }],
1227            width: 12.0,
1228            ascent: 10.0,
1229            descent: 0.0,
1230            line_gap: 0.0,
1231            height: 10.0,
1232            indent_left: 0.0,
1233            available_width: 468.0,
1234            is_last: true,
1235        };
1236        let mut paragraph = block::build_paragraph_block(
1237            vec![line],
1238            0.0,
1239            0.0,
1240            None,
1241            None,
1242            0.0,
1243            0.0,
1244            None,
1245            false,
1246            false,
1247            false,
1248            true,
1249        );
1250        paragraph.anchored.push(block::AnchoredDrawing {
1251            behind_doc: false,
1252            rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
1253            off_h: 20.0,
1254            rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
1255            off_v: 20.0,
1256            width: 12.0,
1257            height: 10.0,
1258            wrap: rdocx_oxml::drawing::WrapType::None,
1259            dist_top: 0.0,
1260            dist_bottom: 0.0,
1261            dist_left: 0.0,
1262            dist_right: 0.0,
1263            align_h: None,
1264            align_v: None,
1265            content: block::AnchoredContent::Image {
1266                media_id: anchor_id,
1267            },
1268        });
1269        let sections = [paginator::Section {
1270            blocks: vec![LayoutBlock::Paragraph(paragraph)],
1271            geometry: PageGeometry::default(),
1272            header_footer: None,
1273            title_pg: false,
1274        }];
1275
1276        let (pages, _) = paginator::paginate_sections(
1277            &sections,
1278            &FontManager::new(),
1279            &media,
1280            &NoteRegistry::default(),
1281        );
1282        let images = pages[0]
1283            .elements
1284            .iter()
1285            .filter_map(|element| match element {
1286                PositionedElement::Image {
1287                    data,
1288                    content_type,
1289                    media_id,
1290                    ..
1291                } => Some((data.as_slice(), content_type.as_str(), *media_id)),
1292                _ => None,
1293            })
1294            .collect::<Vec<_>>();
1295
1296        assert!(images.contains(&(b"\x01\x02\x03".as_slice(), "image/png", inline_id)));
1297        assert!(images.contains(&(b"\x04\x05\x06".as_slice(), "image/jpeg", anchor_id)));
1298    }
1299
1300    #[test]
1301    fn layout_with_heading_style() {
1302        let mut doc = rdocx_oxml::document::CT_Document::new();
1303        let mut p = CT_P::new();
1304        p.properties = Some(CT_PPr {
1305            style_id: Some("Heading1".to_string()),
1306            ..Default::default()
1307        });
1308        p.add_run("Chapter 1");
1309        doc.body.add_paragraph(p);
1310
1311        let input = LayoutInput {
1312            document: doc,
1313            styles: CT_Styles::new_default(),
1314            numbering: None,
1315            headers: HashMap::new(),
1316            footers: HashMap::new(),
1317            images: HashMap::new(),
1318            core_properties: None,
1319            hyperlink_urls: HashMap::new(),
1320            footnotes: None,
1321            endnotes: None,
1322            theme: None,
1323            fonts: Vec::new(),
1324        };
1325
1326        let result = Engine::new().layout(&input);
1327        if let Ok(result) = result {
1328            assert!(!result.pages.is_empty());
1329            // Should produce one outline entry for Heading1
1330            assert_eq!(result.outlines.len(), 1);
1331            assert_eq!(result.outlines[0].title, "Chapter 1");
1332            assert_eq!(result.outlines[0].level, 1);
1333            assert_eq!(result.outlines[0].page_index, 0);
1334        }
1335    }
1336
1337    #[test]
1338    fn layout_nested_headings_produce_outlines() {
1339        let mut doc = rdocx_oxml::document::CT_Document::new();
1340
1341        // H1
1342        let mut h1 = CT_P::new();
1343        h1.properties = Some(CT_PPr {
1344            style_id: Some("Heading1".to_string()),
1345            ..Default::default()
1346        });
1347        h1.add_run("Chapter 1");
1348        doc.body.add_paragraph(h1);
1349
1350        // H2 under H1
1351        let mut h2 = CT_P::new();
1352        h2.properties = Some(CT_PPr {
1353            style_id: Some("Heading2".to_string()),
1354            ..Default::default()
1355        });
1356        h2.add_run("Section 1.1");
1357        doc.body.add_paragraph(h2);
1358
1359        // Another H1
1360        let mut h1b = CT_P::new();
1361        h1b.properties = Some(CT_PPr {
1362            style_id: Some("Heading1".to_string()),
1363            ..Default::default()
1364        });
1365        h1b.add_run("Chapter 2");
1366        doc.body.add_paragraph(h1b);
1367
1368        let input = LayoutInput {
1369            document: doc,
1370            styles: CT_Styles::new_default(),
1371            numbering: None,
1372            headers: HashMap::new(),
1373            footers: HashMap::new(),
1374            images: HashMap::new(),
1375            core_properties: None,
1376            hyperlink_urls: HashMap::new(),
1377            footnotes: None,
1378            endnotes: None,
1379            theme: None,
1380            fonts: Vec::new(),
1381        };
1382
1383        let result = Engine::new().layout(&input);
1384        if let Ok(result) = result {
1385            assert_eq!(result.outlines.len(), 3);
1386            assert_eq!(result.outlines[0].level, 1);
1387            assert_eq!(result.outlines[0].title, "Chapter 1");
1388            assert_eq!(result.outlines[1].level, 2);
1389            assert_eq!(result.outlines[1].title, "Section 1.1");
1390            assert_eq!(result.outlines[2].level, 1);
1391            assert_eq!(result.outlines[2].title, "Chapter 2");
1392        }
1393    }
1394
1395    #[test]
1396    fn sect_pr_geometry_conversion() {
1397        let sect = CT_SectPr::default_letter();
1398        let geom = sect_pr_to_geometry(&sect);
1399        assert!((geom.page_width - 612.0).abs() < 0.01);
1400        assert!((geom.page_height - 792.0).abs() < 0.01);
1401        assert!((geom.margin_top - 72.0).abs() < 0.01);
1402        assert!((geom.content_width() - 468.0).abs() < 0.01);
1403    }
1404
1405    #[test]
1406    fn sect_pr_a4_geometry() {
1407        let sect = CT_SectPr::default_a4();
1408        let geom = sect_pr_to_geometry(&sect);
1409        // A4: 210mm = 595.3pt, 297mm = 841.9pt
1410        assert!((geom.page_width - 595.3).abs() < 0.5);
1411        assert!((geom.page_height - 841.9).abs() < 0.5);
1412    }
1413
1414    // F-X013a, footnote line advance.
1415
1416    /// Build a document whose single body paragraph references footnote 1, and
1417    /// whose footnote 1 is one paragraph made of `note_runs` separate runs.
1418    fn make_input_with_footnote(note_runs: &[&str]) -> LayoutInput {
1419        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
1420        use rdocx_oxml::text::CT_R;
1421
1422        let mut doc = rdocx_oxml::document::CT_Document::new();
1423        let mut body = CT_P::new();
1424        body.add_run("Body text carrying a note");
1425        let mut marker_run = CT_R::new("");
1426        marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
1427        body.runs.push(marker_run);
1428        doc.body.add_paragraph(body);
1429
1430        let mut note = CT_P::new();
1431        for text in note_runs {
1432            note.add_run(text);
1433        }
1434
1435        LayoutInput {
1436            document: doc,
1437            styles: CT_Styles::new_default(),
1438            numbering: None,
1439            headers: HashMap::new(),
1440            footers: HashMap::new(),
1441            images: HashMap::new(),
1442            core_properties: None,
1443            hyperlink_urls: HashMap::new(),
1444            footnotes: Some(CT_Footnotes {
1445                footnotes: vec![CT_Footnote {
1446                    id: 1,
1447                    note_type: NoteType::Normal,
1448                    paragraphs: vec![note],
1449                }],
1450            }),
1451            endnotes: None,
1452            theme: None,
1453            fonts: Vec::new(),
1454        }
1455    }
1456
1457    /// The x origin of every glyph run sitting below the footnote separator,
1458    /// in the order the renderer emitted them. The first is the note marker.
1459    fn footnote_glyph_x(page: &oxml_layout::output::PageFrame) -> Vec<f64> {
1460        let separator_y = page
1461            .elements
1462            .iter()
1463            .find_map(|element| match element {
1464                PositionedElement::Line { start, .. } => Some(start.y),
1465                _ => None,
1466            })
1467            .expect("a page with a footnote draws a separator line");
1468
1469        page.elements
1470            .iter()
1471            .filter_map(|element| match element {
1472                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.x),
1473                _ => None,
1474            })
1475            .collect()
1476    }
1477
1478    #[test]
1479    fn a_multi_segment_footnote_does_not_stack_its_segments_at_one_x() {
1480        let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
1481        let mut engine = Engine::new();
1482        let output = engine.layout(&input).expect("layout succeeds");
1483        let xs = footnote_glyph_x(&output.pages[0]);
1484
1485        assert!(
1486            xs.len() >= 4,
1487            "expected a marker and three note segments, got {xs:?}"
1488        );
1489        for pair in xs.windows(2) {
1490            assert!(
1491                pair[1] > pair[0],
1492                "footnote segments must advance, got {xs:?}"
1493            );
1494        }
1495    }
1496
1497    #[test]
1498    fn a_single_segment_footnote_keeps_its_original_position() {
1499        let input = make_input_with_footnote(&["Solitary"]);
1500        let mut engine = Engine::new();
1501        let output = engine.layout(&input).expect("layout succeeds");
1502        let xs = footnote_glyph_x(&output.pages[0]);
1503        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
1504
1505        // The marker sits at the left margin, the single segment one indent in.
1506        assert_eq!(xs.len(), 2, "expected a marker and one segment, got {xs:?}");
1507        assert!(
1508            (xs[0] - geometry.margin_left).abs() < 0.01,
1509            "marker at {xs:?}"
1510        );
1511        assert!(
1512            (xs[1] - (geometry.margin_left + 12.0)).abs() < 0.01,
1513            "segment at {xs:?}"
1514        );
1515    }
1516
1517    #[test]
1518    fn a_long_footnote_does_not_overrun_the_right_margin() {
1519        // Long enough to wrap, which is what exposes a break width that
1520        // disagrees with the indent the note is drawn at.
1521        let long = "In paged media, footnotes are usually displayed at the \
1522                    bottom of the text. However, in ebooks, a better paradigm \
1523                    is to make them clickable endnotes that the reader can \
1524                    browse at leisure, which this sentence exists to force.";
1525        let input = make_input_with_footnote(&[long]);
1526        let mut engine = Engine::new();
1527        let output = engine.layout(&input).expect("layout succeeds");
1528        let page = &output.pages[0];
1529        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
1530        let right_margin = geometry.page_width - geometry.margin_right;
1531
1532        let separator_y = page
1533            .elements
1534            .iter()
1535            .find_map(|element| match element {
1536                PositionedElement::Line { start, .. } => Some(start.y),
1537                _ => None,
1538            })
1539            .expect("a page with a footnote draws a separator line");
1540
1541        let mut wrapped = false;
1542        let mut first_y = None;
1543        for element in &page.elements {
1544            let PositionedElement::Text(run) = element else {
1545                continue;
1546            };
1547            if run.origin.y <= separator_y {
1548                continue;
1549            }
1550            let first = *first_y.get_or_insert(run.origin.y);
1551            if run.origin.y > first + 0.01 {
1552                wrapped = true;
1553            }
1554            let right_edge = run.origin.x + run.advances.iter().sum::<f64>();
1555            assert!(
1556                right_edge <= right_margin + 0.01,
1557                "note text reaches {right_edge}, past the right margin {right_margin}"
1558            );
1559        }
1560        assert!(wrapped, "the note must wrap for this test to mean anything");
1561    }
1562
1563    #[test]
1564    fn a_tab_inside_a_footnote_still_advances_the_text_after_it() {
1565        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
1566        use rdocx_oxml::text::CT_R;
1567
1568        // Two notes differing only by a tab between their runs. The tab is not
1569        // drawn, but it occupies width, so the run after it must shift right.
1570        let build = |with_tab: bool| {
1571            let mut doc = rdocx_oxml::document::CT_Document::new();
1572            let mut body = CT_P::new();
1573            body.add_run("Body");
1574            let mut marker_run = CT_R::new("");
1575            marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
1576            body.runs.push(marker_run);
1577            doc.body.add_paragraph(body);
1578
1579            let mut note = CT_P::new();
1580            note.add_run("Alpha");
1581            if with_tab {
1582                let mut tab_run = CT_R::new("");
1583                tab_run.content = vec![RunContent::Tab];
1584                note.runs.push(tab_run);
1585            }
1586            note.add_run("Beta");
1587
1588            LayoutInput {
1589                document: doc,
1590                styles: CT_Styles::new_default(),
1591                numbering: None,
1592                headers: HashMap::new(),
1593                footers: HashMap::new(),
1594                images: HashMap::new(),
1595                core_properties: None,
1596                hyperlink_urls: HashMap::new(),
1597                footnotes: Some(CT_Footnotes {
1598                    footnotes: vec![CT_Footnote {
1599                        id: 1,
1600                        note_type: NoteType::Normal,
1601                        paragraphs: vec![note],
1602                    }],
1603                }),
1604                endnotes: None,
1605                theme: None,
1606                fonts: Vec::new(),
1607            }
1608        };
1609
1610        let mut engine = Engine::new();
1611        let plain = engine.layout(&build(false)).expect("layout succeeds");
1612        let tabbed = engine.layout(&build(true)).expect("layout succeeds");
1613
1614        let plain_x = footnote_glyph_x(&plain.pages[0]);
1615        let tabbed_x = footnote_glyph_x(&tabbed.pages[0]);
1616
1617        // Marker and both runs are drawn in each case. The tab draws nothing.
1618        assert_eq!(plain_x.len(), 3, "plain note glyphs {plain_x:?}");
1619        assert_eq!(tabbed_x.len(), 3, "tabbed note glyphs {tabbed_x:?}");
1620        assert!(
1621            tabbed_x[2] > plain_x[2] + 1.0,
1622            "the run after a tab must shift right, plain {plain_x:?} tabbed {tabbed_x:?}"
1623        );
1624    }
1625
1626    #[test]
1627    fn footnote_segment_advance_matches_body_segment_advance() {
1628        let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
1629        let mut engine = Engine::new();
1630        let output = engine.layout(&input).expect("layout succeeds");
1631        let page = &output.pages[0];
1632
1633        let separator_y = page
1634            .elements
1635            .iter()
1636            .find_map(|element| match element {
1637                PositionedElement::Line { start, .. } => Some(start.y),
1638                _ => None,
1639            })
1640            .expect("a page with a footnote draws a separator line");
1641
1642        // Gaps between consecutive note segments must equal the width of the
1643        // segment that precedes them, which is what the body path advances by.
1644        let notes: Vec<&oxml_layout::GlyphRun> = page
1645            .elements
1646            .iter()
1647            .filter_map(|element| match element {
1648                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run),
1649                _ => None,
1650            })
1651            .skip(1) // the marker, which is positioned independently
1652            .collect();
1653
1654        assert_eq!(notes.len(), 3, "expected three note segments");
1655        for pair in notes.windows(2) {
1656            let advance: f64 = pair[0].advances.iter().sum();
1657            let gap = pair[1].origin.x - pair[0].origin.x;
1658            assert!(
1659                (gap - advance).abs() < 0.01,
1660                "gap {gap} should equal preceding segment advance {advance}"
1661            );
1662        }
1663    }
1664
1665    // F-X013b, reservation and splitting.
1666
1667    /// A document of `body_paras` paragraphs. The paragraph at
1668    /// `ref_positions` each carry a reference to note 1, whose content is
1669    /// `note_paras` paragraphs of `note_text`.
1670    fn make_noted_document(
1671        body_paras: usize,
1672        ref_positions: &[usize],
1673        note_paras: usize,
1674        note_text: &str,
1675        continuation_separator: bool,
1676    ) -> LayoutInput {
1677        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
1678        use rdocx_oxml::text::CT_R;
1679
1680        let mut doc = rdocx_oxml::document::CT_Document::new();
1681        for index in 0..body_paras {
1682            let mut para = CT_P::new();
1683            para.add_run("Body paragraph text that occupies a line of the page.");
1684            if ref_positions.contains(&index) {
1685                let mut marker = CT_R::new("");
1686                marker.content = vec![RunContent::FootnoteRef { id: 1 }];
1687                para.runs.push(marker);
1688            }
1689            doc.body.add_paragraph(para);
1690        }
1691
1692        let mut entries = Vec::new();
1693        if continuation_separator {
1694            entries.push(CT_Footnote {
1695                id: 0,
1696                note_type: NoteType::ContinuationSeparator,
1697                paragraphs: vec![CT_P::new()],
1698            });
1699        }
1700        entries.push(CT_Footnote {
1701            id: 1,
1702            note_type: NoteType::Normal,
1703            paragraphs: (0..note_paras)
1704                .map(|_| {
1705                    let mut p = CT_P::new();
1706                    p.add_run(note_text);
1707                    p
1708                })
1709                .collect(),
1710        });
1711
1712        LayoutInput {
1713            document: doc,
1714            styles: CT_Styles::new_default(),
1715            numbering: None,
1716            headers: HashMap::new(),
1717            footers: HashMap::new(),
1718            images: HashMap::new(),
1719            core_properties: None,
1720            hyperlink_urls: HashMap::new(),
1721            footnotes: Some(CT_Footnotes { footnotes: entries }),
1722            endnotes: None,
1723            theme: None,
1724            fonts: Vec::new(),
1725        }
1726    }
1727
1728    /// Split a page into the glyphs drawn above the note separator and those
1729    /// drawn below it. Notes are emitted after body content, so the separator
1730    /// is the boundary.
1731    fn split_at_separator(
1732        page: &oxml_layout::output::PageFrame,
1733    ) -> Option<(f64, Vec<f64>, Vec<String>)> {
1734        let separator_index = page.elements.iter().position(|element| {
1735            matches!(element, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001)
1736        })?;
1737        let PositionedElement::Line { start, end, .. } = &page.elements[separator_index] else {
1738            return None;
1739        };
1740        let separator_y = start.y;
1741        let separator_width = end.x - start.x;
1742
1743        let body_ys: Vec<f64> = page.elements[..separator_index]
1744            .iter()
1745            .filter_map(|element| match element {
1746                PositionedElement::Text(run) => Some(run.origin.y),
1747                _ => None,
1748            })
1749            .collect();
1750        let note_text: Vec<String> = page.elements[separator_index + 1..]
1751            .iter()
1752            .filter_map(|element| match element {
1753                PositionedElement::Text(run) => Some(run.text.clone()),
1754                _ => None,
1755            })
1756            .collect();
1757
1758        let _ = separator_y;
1759        Some((separator_width, body_ys, note_text))
1760    }
1761
1762    fn separator_y_of(page: &oxml_layout::output::PageFrame) -> Option<f64> {
1763        page.elements.iter().find_map(|element| match element {
1764            PositionedElement::Line { start, width, .. } if (*width - 0.5).abs() < 0.001 => {
1765                Some(start.y)
1766            }
1767            _ => None,
1768        })
1769    }
1770
1771    #[test]
1772    fn a_page_whose_body_fills_the_text_area_does_not_overlap_its_notes() {
1773        // Enough body to reach the bottom margin, with the reference early so
1774        // the note is owed by the first page.
1775        let input = make_noted_document(
1776            60,
1777            &[0],
1778            2,
1779            "A note long enough to wrap onto a second line of the note area.",
1780            false,
1781        );
1782        let mut engine = Engine::new();
1783        let output = engine.layout(&input).expect("layout succeeds");
1784        let page = &output.pages[0];
1785
1786        let separator_y = separator_y_of(page).expect("the page draws a separator");
1787        let (_, body_ys, note_text) = split_at_separator(page).unwrap();
1788
1789        assert!(!note_text.is_empty(), "the note must be drawn");
1790        let lowest_body = body_ys.iter().cloned().fold(f64::MIN, f64::max);
1791        assert!(
1792            lowest_body < separator_y,
1793            "body text reaches {lowest_body}, at or below the separator at {separator_y}"
1794        );
1795    }
1796
1797    #[test]
1798    fn a_page_referencing_one_note_twice_reserves_it_once() {
1799        let input = make_noted_document(4, &[0, 1], 1, "Referenced twice from one page.", false);
1800        let mut engine = Engine::new();
1801        let output = engine.layout(&input).expect("layout succeeds");
1802        let page = &output.pages[0];
1803
1804        let separators = page
1805            .elements
1806            .iter()
1807            .filter(|e| matches!(e, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001))
1808            .count();
1809        assert_eq!(separators, 1, "one note area, so one separator");
1810
1811        let (_, _, note_text) = split_at_separator(page).unwrap();
1812        let markers = note_text.iter().filter(|t| t.as_str() == "1").count();
1813        assert_eq!(markers, 1, "the note is drawn once, got {note_text:?}");
1814    }
1815
1816    #[test]
1817    fn a_note_taller_than_its_remaining_space_continues_on_the_next_page() {
1818        // 120 note paragraphs exceed a single page, so the note has to break.
1819        let input = make_noted_document(30, &[25], 120, "Note paragraph line.", true);
1820        let mut engine = Engine::new();
1821        let output = engine.layout(&input).expect("layout succeeds");
1822
1823        let note_pages: Vec<usize> = output
1824            .pages
1825            .iter()
1826            .enumerate()
1827            .filter(|(_, page)| separator_y_of(page).is_some())
1828            .map(|(index, _)| index)
1829            .collect();
1830
1831        assert!(
1832            note_pages.len() >= 2,
1833            "a note taller than a page must span pages, got {note_pages:?}"
1834        );
1835
1836        let first = split_at_separator(&output.pages[note_pages[0]]).unwrap().2;
1837        let second = split_at_separator(&output.pages[note_pages[1]]).unwrap().2;
1838
1839        assert!(!first.is_empty(), "the first page draws part of the note");
1840        assert!(!second.is_empty(), "the next page draws the rest");
1841        assert_eq!(
1842            first.iter().filter(|t| t.as_str() == "1").count(),
1843            1,
1844            "the marker is drawn on the page the note starts on"
1845        );
1846        assert_eq!(
1847            second.iter().filter(|t| t.as_str() == "1").count(),
1848            0,
1849            "a continuation does not repeat the marker, got {second:?}"
1850        );
1851    }
1852
1853    #[test]
1854    fn a_continued_note_draws_the_continuation_separator() {
1855        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
1856
1857        let widths = |continuation: bool| {
1858            let input = make_noted_document(30, &[25], 120, "Note paragraph line.", continuation);
1859            let mut engine = Engine::new();
1860            let output = engine.layout(&input).expect("layout succeeds");
1861            let pages: Vec<usize> = output
1862                .pages
1863                .iter()
1864                .enumerate()
1865                .filter(|(_, page)| separator_y_of(page).is_some())
1866                .map(|(index, _)| index)
1867                .collect();
1868            assert!(pages.len() >= 2, "the note must span pages");
1869            (
1870                split_at_separator(&output.pages[pages[0]]).unwrap().0,
1871                split_at_separator(&output.pages[pages[1]]).unwrap().0,
1872            )
1873        };
1874
1875        let (first, second) = widths(true);
1876        assert!(
1877            (first - geometry.content_width() * 0.33).abs() < 0.5,
1878            "a note starting on its page gets the short rule, got {first}"
1879        );
1880        assert!(
1881            (second - geometry.content_width()).abs() < 0.5,
1882            "a continued note gets the full-width rule, got {second}"
1883        );
1884
1885        // A document defining no continuation separator keeps the short rule.
1886        let (_, second) = widths(false);
1887        assert!(
1888            (second - geometry.content_width() * 0.33).abs() < 0.5,
1889            "without a continuation separator the short rule is kept, got {second}"
1890        );
1891    }
1892
1893    #[test]
1894    fn an_oversized_note_still_leaves_room_for_body_text() {
1895        // A note several pages tall, referenced from the first paragraph.
1896        let input = make_noted_document(3, &[0], 200, "A line of an enormous note.", true);
1897        let mut engine = Engine::new();
1898        let output = engine.layout(&input).expect("layout terminates");
1899
1900        let (_, body_ys, _) = split_at_separator(&output.pages[0]).unwrap();
1901        assert!(
1902            !body_ys.is_empty(),
1903            "an oversized note must not starve the page of body text"
1904        );
1905        assert!(
1906            output.pages.len() > 1 && output.pages.len() < 100,
1907            "the note spills over a bounded number of pages, got {}",
1908            output.pages.len()
1909        );
1910
1911        // The note area has to stay on the page. Placing an oversized note
1912        // whole would push its separator off the top of the sheet.
1913        for (index, page) in output.pages.iter().enumerate() {
1914            let Some(separator_y) = separator_y_of(page) else {
1915                continue;
1916            };
1917            assert!(
1918                separator_y >= 0.0,
1919                "page {} draws its separator at {separator_y}, off the sheet",
1920                index + 1
1921            );
1922        }
1923    }
1924
1925    #[test]
1926    fn a_note_is_drawn_on_the_page_that_carries_its_reference() {
1927        // Sweeping the reference across the document is what catches the two
1928        // ways a note drifts off its own page: notes claimed for a paragraph
1929        // that then moves, and a note area measured from a cursor that still
1930        // holds the previous paragraph's trailing space.
1931        let mut mismatches = Vec::new();
1932        for position in 0..60 {
1933            let input = make_noted_document(60, &[position], 1, "Note text.", false);
1934            let mut engine = Engine::new();
1935            let output = engine.layout(&input).expect("layout succeeds");
1936
1937            let reference_page = output.pages.iter().position(|page| {
1938                page.elements.iter().any(|element| {
1939                    matches!(element, PositionedElement::Text(run)
1940                    if run.note == Some(oxml_layout::NoteRef {
1941                        stream: oxml_layout::NoteStream::Footnote,
1942                        id: 1,
1943                    }))
1944                })
1945            });
1946            let note_page = output
1947                .pages
1948                .iter()
1949                .position(|page| separator_y_of(page).is_some());
1950
1951            if reference_page != note_page {
1952                mismatches.push((position, reference_page, note_page));
1953            }
1954        }
1955
1956        assert!(
1957            mismatches.is_empty(),
1958            "note and reference landed on different pages for (position, ref, note): {mismatches:?}"
1959        );
1960    }
1961
1962    // F-X013c, endnotes at the document end.
1963
1964    /// A document whose single body paragraph references footnote `id` and
1965    /// endnote `id`, with each stream giving that number different text.
1966    fn make_document_with_both_streams(id: i32, body_paras: usize) -> LayoutInput {
1967        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
1968        use rdocx_oxml::text::CT_R;
1969
1970        let mut doc = rdocx_oxml::document::CT_Document::new();
1971        for index in 0..body_paras {
1972            let mut para = CT_P::new();
1973            para.add_run("Body paragraph text that occupies a line of the page.");
1974            if index == 0 {
1975                let mut foot = CT_R::new("");
1976                foot.content = vec![RunContent::FootnoteRef { id }];
1977                para.runs.push(foot);
1978                let mut end = CT_R::new("");
1979                end.content = vec![RunContent::EndnoteRef { id }];
1980                para.runs.push(end);
1981            }
1982            doc.body.add_paragraph(para);
1983        }
1984
1985        let note = |text: &str| {
1986            let mut p = CT_P::new();
1987            p.add_run(text);
1988            CT_Footnote {
1989                id,
1990                note_type: NoteType::Normal,
1991                paragraphs: vec![p],
1992            }
1993        };
1994
1995        LayoutInput {
1996            document: doc,
1997            styles: CT_Styles::new_default(),
1998            numbering: None,
1999            headers: HashMap::new(),
2000            footers: HashMap::new(),
2001            images: HashMap::new(),
2002            core_properties: None,
2003            hyperlink_urls: HashMap::new(),
2004            footnotes: Some(CT_Footnotes {
2005                footnotes: vec![note("FOOTNOTETEXT")],
2006            }),
2007            endnotes: Some(CT_Footnotes {
2008                footnotes: vec![note("ENDNOTETEXT")],
2009            }),
2010            theme: None,
2011            fonts: Vec::new(),
2012        }
2013    }
2014
2015    fn page_text(page: &oxml_layout::output::PageFrame) -> String {
2016        page.elements
2017            .iter()
2018            .filter_map(|element| match element {
2019                PositionedElement::Text(run) => Some(run.text.as_str()),
2020                _ => None,
2021            })
2022            .collect::<Vec<_>>()
2023            .join(" ")
2024    }
2025
2026    #[test]
2027    fn a_footnote_and_an_endnote_sharing_a_number_render_their_own_text() {
2028        let input = make_document_with_both_streams(2, 3);
2029        let mut engine = Engine::new();
2030        let output = engine.layout(&input).expect("layout succeeds");
2031
2032        let all: String = output
2033            .pages
2034            .iter()
2035            .map(page_text)
2036            .collect::<Vec<_>>()
2037            .join(" | ");
2038        assert!(
2039            all.contains("FOOTNOTETEXT"),
2040            "the footnote must render its own text, got {all}"
2041        );
2042        assert!(
2043            all.contains("ENDNOTETEXT"),
2044            "the endnote must render its own text, got {all}"
2045        );
2046    }
2047
2048    #[test]
2049    fn endnotes_render_after_the_last_body_page() {
2050        let input = make_document_with_both_streams(2, 3);
2051        let mut engine = Engine::new();
2052        let output = engine.layout(&input).expect("layout succeeds");
2053
2054        let endnote_page = output
2055            .pages
2056            .iter()
2057            .position(|page| page_text(page).contains("ENDNOTETEXT"))
2058            .expect("the endnote is rendered somewhere");
2059        let last_body_page = output
2060            .pages
2061            .iter()
2062            .rposition(|page| page_text(page).contains("occupies"))
2063            .expect("the body is rendered somewhere");
2064
2065        assert!(
2066            endnote_page > last_body_page,
2067            "endnotes come after every body page, endnote on {endnote_page} and body to {last_body_page}"
2068        );
2069        assert!(
2070            !page_text(&output.pages[endnote_page]).contains("occupies"),
2071            "an endnote page carries no body text"
2072        );
2073    }
2074
2075    #[test]
2076    fn footnotes_and_endnotes_keep_their_own_regions() {
2077        let input = make_document_with_both_streams(2, 3);
2078        let mut engine = Engine::new();
2079        let output = engine.layout(&input).expect("layout succeeds");
2080
2081        let footnote_page = output
2082            .pages
2083            .iter()
2084            .position(|page| page_text(page).contains("FOOTNOTETEXT"))
2085            .expect("the footnote is rendered");
2086
2087        // The footnote shares the page that carries its reference.
2088        assert!(
2089            page_text(&output.pages[footnote_page]).contains("occupies"),
2090            "a footnote sits on the page carrying its reference"
2091        );
2092        assert!(
2093            separator_y_of(&output.pages[footnote_page]).is_some(),
2094            "the footnote page draws a separator"
2095        );
2096
2097        // The endnote page is a different page, and draws no separator,
2098        // because there is no body text there to divide it from.
2099        let endnote_page = output
2100            .pages
2101            .iter()
2102            .position(|page| page_text(page).contains("ENDNOTETEXT"))
2103            .expect("the endnote is rendered");
2104        assert_ne!(footnote_page, endnote_page, "the two regions are distinct");
2105        assert!(
2106            separator_y_of(&output.pages[endnote_page]).is_none(),
2107            "an endnote page draws no separator rule"
2108        );
2109    }
2110
2111    #[test]
2112    fn an_endnote_reference_does_not_reserve_space_at_the_page_foot() {
2113        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
2114        use rdocx_oxml::text::CT_R;
2115
2116        // The same document twice, once with an endnote reference and once
2117        // with none. An endnote costs its page nothing, so the body must
2118        // paginate identically.
2119        let build = |with_endnote: bool| {
2120            let mut doc = rdocx_oxml::document::CT_Document::new();
2121            for index in 0..60 {
2122                let mut para = CT_P::new();
2123                para.add_run("Body paragraph text that occupies a line of the page.");
2124                if index == 0 && with_endnote {
2125                    let mut end = CT_R::new("");
2126                    end.content = vec![RunContent::EndnoteRef { id: 1 }];
2127                    para.runs.push(end);
2128                }
2129                doc.body.add_paragraph(para);
2130            }
2131            let mut note = CT_P::new();
2132            note.add_run("An endnote that would be tall in the margin.");
2133            LayoutInput {
2134                document: doc,
2135                styles: CT_Styles::new_default(),
2136                numbering: None,
2137                headers: HashMap::new(),
2138                footers: HashMap::new(),
2139                images: HashMap::new(),
2140                core_properties: None,
2141                hyperlink_urls: HashMap::new(),
2142                footnotes: None,
2143                endnotes: Some(CT_Footnotes {
2144                    footnotes: vec![CT_Footnote {
2145                        id: 1,
2146                        note_type: NoteType::Normal,
2147                        paragraphs: vec![note],
2148                    }],
2149                }),
2150                theme: None,
2151                fonts: Vec::new(),
2152            }
2153        };
2154
2155        let mut engine = Engine::new();
2156        let plain = engine.layout(&build(false)).expect("layout succeeds");
2157        let noted = engine.layout(&build(true)).expect("layout succeeds");
2158
2159        // One extra page for the endnote itself, and no separator anywhere.
2160        assert_eq!(
2161            noted.pages.len(),
2162            plain.pages.len() + 1,
2163            "an endnote adds its own page and takes none from the body"
2164        );
2165        for (index, page) in noted.pages.iter().enumerate() {
2166            if index < plain.pages.len() {
2167                assert!(
2168                    separator_y_of(page).is_none(),
2169                    "page {} reserved foot space for an endnote",
2170                    index + 1
2171                );
2172            }
2173        }
2174
2175        // Body pagination is untouched.
2176        for (index, plain_page) in plain.pages.iter().enumerate() {
2177            let body_lines = |page: &oxml_layout::output::PageFrame| {
2178                page.elements
2179                    .iter()
2180                    .filter(|element| {
2181                        matches!(element, PositionedElement::Text(run)
2182                            if run.text.starts_with("occupies"))
2183                    })
2184                    .count()
2185            };
2186            assert_eq!(
2187                body_lines(plain_page),
2188                body_lines(&noted.pages[index]),
2189                "page {} holds a different amount of body text",
2190                index + 1
2191            );
2192        }
2193    }
2194
2195    // F-X016, text wrapping around a floating drawing.
2196
2197    /// A document of one long paragraph, with a floating drawing anchored to
2198    /// it. `align` places the drawing, `wrap` says how text should treat it.
2199    fn make_wrapping_document(
2200        wrap: rdocx_oxml::drawing::WrapType,
2201        align: Option<rdocx_oxml::drawing::AnchorAlignH>,
2202        width_pt: f64,
2203        height_pt: f64,
2204        dist_pt: f64,
2205    ) -> LayoutInput {
2206        use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV};
2207        use rdocx_oxml::text::CT_R;
2208        use rdocx_oxml::units::Emu;
2209
2210        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
2211
2212        let mut doc = rdocx_oxml::document::CT_Document::new();
2213        let mut para = CT_P::new();
2214        // Long enough that many lines sit below the drawing, which is what
2215        // makes "returns to the margin" a meaningful assertion.
2216        let mut body = String::new();
2217        for index in 0..40 {
2218            body.push_str(&format!(
2219                "Sentence {index} of running text that fills the paragraph out. "
2220            ));
2221        }
2222        para.add_run(&body);
2223
2224        let mut anchor = CT_Anchor::background("rId1", 0, 0);
2225        anchor.extent_cx = emu(width_pt);
2226        anchor.extent_cy = emu(height_pt);
2227        anchor.behind_doc = false;
2228        anchor.wrap = wrap;
2229        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
2230        anchor.pos_h_align = align;
2231        anchor.pos_v_relative_from = ST_RelativeFromV::Paragraph;
2232        anchor.pos_v_offset = Emu(0);
2233        anchor.dist_t = emu(dist_pt);
2234        anchor.dist_b = emu(dist_pt);
2235        anchor.dist_l = emu(dist_pt);
2236        anchor.dist_r = emu(dist_pt);
2237
2238        let mut drawing_run = CT_R::new("");
2239        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
2240            inline: None,
2241            anchor: Some(anchor),
2242        })];
2243        para.runs.push(drawing_run);
2244        doc.body.add_paragraph(para);
2245
2246        let mut images = HashMap::new();
2247        images.insert(
2248            "rId1".to_string(),
2249            ImageData {
2250                data: vec![0u8; 8],
2251                content_type: "image/png".to_string(),
2252            },
2253        );
2254
2255        LayoutInput {
2256            document: doc,
2257            styles: CT_Styles::new_default(),
2258            numbering: None,
2259            headers: HashMap::new(),
2260            footers: HashMap::new(),
2261            images,
2262            core_properties: None,
2263            hyperlink_urls: HashMap::new(),
2264            footnotes: None,
2265            endnotes: None,
2266            theme: None,
2267            fonts: Vec::new(),
2268        }
2269    }
2270
2271    /// The x origin and right edge of every body text run, by line.
2272    fn text_extents(page: &oxml_layout::output::PageFrame) -> Vec<(f64, f64)> {
2273        let mut by_line: Vec<(f64, f64, f64)> = Vec::new();
2274        for element in &page.elements {
2275            let PositionedElement::Text(run) = element else {
2276                continue;
2277            };
2278            let right = run.origin.x + run.advances.iter().sum::<f64>();
2279            if let Some(entry) = by_line
2280                .iter_mut()
2281                .find(|(y, _, _)| (*y - run.origin.y).abs() < 0.01)
2282            {
2283                entry.1 = entry.1.min(run.origin.x);
2284                entry.2 = entry.2.max(right);
2285            } else {
2286                by_line.push((run.origin.y, run.origin.x, right));
2287            }
2288        }
2289        by_line.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
2290        by_line.into_iter().map(|(_, l, r)| (l, r)).collect()
2291    }
2292
2293    #[test]
2294    fn text_wraps_beside_a_left_aligned_square_drawing() {
2295        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
2296
2297        let input =
2298            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
2299        let mut engine = Engine::new();
2300        let output = engine.layout(&input).expect("layout succeeds");
2301        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2302        let extents = text_extents(&output.pages[0]);
2303
2304        assert!(
2305            extents.len() > 2,
2306            "the paragraph must wrap, got {extents:?}"
2307        );
2308
2309        // Lines beside the drawing start to its right, past width plus distR.
2310        let expected_left = geometry.margin_left + 100.0 + 5.0;
2311        assert!(
2312            (extents[0].0 - expected_left).abs() < 1.0,
2313            "first line should start at {expected_left}, got {:?}",
2314            extents[0]
2315        );
2316
2317        // A line below the drawing returns to the margin.
2318        let last = extents.last().unwrap();
2319        assert!(
2320            (last.0 - geometry.margin_left).abs() < 1.0,
2321            "the last line should return to the margin, got {last:?}"
2322        );
2323    }
2324
2325    #[test]
2326    fn text_wraps_beside_a_right_aligned_square_drawing() {
2327        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
2328
2329        let input = make_wrapping_document(
2330            WrapType::Square,
2331            Some(AnchorAlignH::Right),
2332            100.0,
2333            40.0,
2334            5.0,
2335        );
2336        let mut engine = Engine::new();
2337        let output = engine.layout(&input).expect("layout succeeds");
2338        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2339        let extents = text_extents(&output.pages[0]);
2340
2341        assert!(
2342            extents.len() > 2,
2343            "the paragraph must wrap, got {extents:?}"
2344        );
2345
2346        // Lines beside the drawing still start at the margin but end early.
2347        let text_right = geometry.page_width - geometry.margin_right;
2348        let drawing_left = text_right - 100.0;
2349        assert!(
2350            (extents[0].0 - geometry.margin_left).abs() < 1.0,
2351            "a right-aligned drawing does not move the line start, got {:?}",
2352            extents[0]
2353        );
2354        assert!(
2355            extents[0].1 <= drawing_left - 5.0 + 1.0,
2356            "the first line should stop before the drawing at {}, got {:?}",
2357            drawing_left - 5.0,
2358            extents[0]
2359        );
2360
2361        // Some line below the drawing runs past where the drawing sat, which
2362        // is only possible once the reservation stops applying. The final line
2363        // of a paragraph is naturally short, so the widest is the fair test.
2364        let widest = extents
2365            .iter()
2366            .map(|(_, right)| *right)
2367            .fold(f64::MIN, f64::max);
2368        assert!(
2369            widest > drawing_left,
2370            "a line below the drawing should reach past {drawing_left}, got {extents:?}"
2371        );
2372    }
2373
2374    #[test]
2375    fn a_top_and_bottom_drawing_pushes_text_below_it() {
2376        use rdocx_oxml::drawing::WrapType;
2377
2378        let input = make_wrapping_document(WrapType::TopAndBottom, None, 100.0, 40.0, 5.0);
2379        let mut engine = Engine::new();
2380        let output = engine.layout(&input).expect("layout succeeds");
2381        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2382        let extents = text_extents(&output.pages[0]);
2383
2384        assert!(!extents.is_empty(), "the paragraph renders");
2385
2386        // The drawing sits at the paragraph top, so text starts below its
2387        // bottom edge plus distB.
2388        let first_baseline = output.pages[0]
2389            .elements
2390            .iter()
2391            .find_map(|element| match element {
2392                PositionedElement::Text(run) => Some(run.origin.y),
2393                _ => None,
2394            })
2395            .expect("text is rendered");
2396        let drawing_bottom = geometry.margin_top + 40.0 + 5.0;
2397        assert!(
2398            first_baseline >= drawing_bottom,
2399            "the first line at {first_baseline} should sit below {drawing_bottom}"
2400        );
2401    }
2402
2403    #[test]
2404    fn a_wrap_none_drawing_leaves_text_untouched() {
2405        use rdocx_oxml::drawing::WrapType;
2406
2407        // The identity case. A drawing that does not wrap must not move a
2408        // single glyph, which is what keeps every recorded baseline still.
2409        let with = make_wrapping_document(WrapType::None, None, 100.0, 40.0, 5.0);
2410        let mut engine = Engine::new();
2411        let output = engine.layout(&with).expect("layout succeeds");
2412        let wrapped_extents = text_extents(&output.pages[0]);
2413
2414        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2415        for (left, _) in &wrapped_extents {
2416            assert!(
2417                (left - geometry.margin_left).abs() < 0.01,
2418                "a wrapNone drawing must not indent any line, got {wrapped_extents:?}"
2419            );
2420        }
2421    }
2422
2423    #[test]
2424    fn a_drawing_anchored_to_a_later_paragraph_still_pushes_text_aside() {
2425        use rdocx_oxml::drawing::{
2426            AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
2427            WrapType,
2428        };
2429        use rdocx_oxml::text::CT_R;
2430        use rdocx_oxml::units::Emu;
2431
2432        // Word routinely anchors the arrow beside a paragraph to the paragraph
2433        // after it, which is what the external contribution's own sample does.
2434        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
2435        let mut doc = rdocx_oxml::document::CT_Document::new();
2436
2437        let mut first = CT_P::new();
2438        let mut body = String::new();
2439        for index in 0..40 {
2440            body.push_str(&format!("Sentence {index} of running text to fill lines. "));
2441        }
2442        first.add_run(&body);
2443        doc.body.add_paragraph(first);
2444
2445        let mut second = CT_P::new();
2446        second.add_run("A later paragraph that owns the drawing.");
2447        let mut anchor = CT_Anchor::background("rId1", 0, 0);
2448        anchor.extent_cx = emu(100.0);
2449        anchor.extent_cy = emu(40.0);
2450        anchor.behind_doc = false;
2451        anchor.wrap = WrapType::Square;
2452        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
2453        anchor.pos_h_align = Some(AnchorAlignH::Left);
2454        // Margin-relative, so its position does not depend on where the
2455        // paragraph that owns it lands.
2456        anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
2457        anchor.pos_v_align = Some(AnchorAlignV::Top);
2458        let mut drawing_run = CT_R::new("");
2459        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
2460            inline: None,
2461            anchor: Some(anchor),
2462        })];
2463        second.runs.push(drawing_run);
2464        doc.body.add_paragraph(second);
2465
2466        let mut images = HashMap::new();
2467        images.insert(
2468            "rId1".to_string(),
2469            ImageData {
2470                data: vec![0u8; 8],
2471                content_type: "image/png".to_string(),
2472            },
2473        );
2474
2475        let input = LayoutInput {
2476            document: doc,
2477            styles: CT_Styles::new_default(),
2478            numbering: None,
2479            headers: HashMap::new(),
2480            footers: HashMap::new(),
2481            images,
2482            core_properties: None,
2483            hyperlink_urls: HashMap::new(),
2484            footnotes: None,
2485            endnotes: None,
2486            theme: None,
2487            fonts: Vec::new(),
2488        };
2489
2490        let mut engine = Engine::new();
2491        let output = engine.layout(&input).expect("layout succeeds");
2492        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2493        let extents = text_extents(&output.pages[0]);
2494
2495        assert!(!extents.is_empty(), "text renders");
2496        let expected_left = geometry.margin_left + 100.0;
2497        assert!(
2498            extents[0].0 >= expected_left - 1.0,
2499            "the first line of the earlier paragraph should clear the drawing at \
2500             {expected_left}, got {:?}",
2501            extents[0]
2502        );
2503    }
2504
2505    #[test]
2506    fn a_split_paragraph_clearing_a_drawing_stays_inside_the_page() {
2507        use rdocx_oxml::drawing::{
2508            AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
2509            WrapType,
2510        };
2511        use rdocx_oxml::text::CT_R;
2512        use rdocx_oxml::units::Emu;
2513
2514        // A top-and-bottom drawing pushes the paragraph's content down, and the
2515        // paragraph is long enough to split. The offset has to be counted where
2516        // the split point is decided, or the last lines run off the page.
2517        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
2518        let mut doc = rdocx_oxml::document::CT_Document::new();
2519        let mut para = CT_P::new();
2520        let mut body = String::new();
2521        for index in 0..300 {
2522            body.push_str(&format!("Sentence {index} of a very long paragraph. "));
2523        }
2524        para.add_run(&body);
2525
2526        let mut anchor = CT_Anchor::background("rId1", 0, 0);
2527        anchor.extent_cx = emu(200.0);
2528        anchor.extent_cy = emu(120.0);
2529        anchor.behind_doc = false;
2530        anchor.wrap = WrapType::TopAndBottom;
2531        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
2532        anchor.pos_h_align = Some(AnchorAlignH::Center);
2533        anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
2534        anchor.pos_v_align = Some(AnchorAlignV::Top);
2535        anchor.dist_b = emu(10.0);
2536        let mut drawing_run = CT_R::new("");
2537        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
2538            inline: None,
2539            anchor: Some(anchor),
2540        })];
2541        para.runs.push(drawing_run);
2542        doc.body.add_paragraph(para);
2543
2544        let mut images = HashMap::new();
2545        images.insert(
2546            "rId1".to_string(),
2547            ImageData {
2548                data: vec![0u8; 8],
2549                content_type: "image/png".to_string(),
2550            },
2551        );
2552
2553        let input = LayoutInput {
2554            document: doc,
2555            styles: CT_Styles::new_default(),
2556            numbering: None,
2557            headers: HashMap::new(),
2558            footers: HashMap::new(),
2559            images,
2560            core_properties: None,
2561            hyperlink_urls: HashMap::new(),
2562            footnotes: None,
2563            endnotes: None,
2564            theme: None,
2565            fonts: Vec::new(),
2566        };
2567
2568        let mut engine = Engine::new();
2569        let output = engine.layout(&input).expect("layout succeeds");
2570        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2571        let bottom = geometry.page_height - geometry.margin_bottom;
2572
2573        assert!(output.pages.len() > 1, "the paragraph must split");
2574        for (index, page) in output.pages.iter().enumerate() {
2575            for element in &page.elements {
2576                let PositionedElement::Text(run) = element else {
2577                    continue;
2578                };
2579                assert!(
2580                    run.origin.y <= bottom + 0.5,
2581                    "page {} draws text at {}, past the bottom margin at {bottom}",
2582                    index + 1,
2583                    run.origin.y
2584                );
2585            }
2586        }
2587    }
2588}