Skip to main content

rdocx_layout/
engine.rs

1//! Layout engine orchestrator: ties all phases together.
2
3use std::collections::{HashMap, VecDeque};
4
5use rdocx_oxml::borders::{CT_PBdr, CT_TabStop};
6use rdocx_oxml::content_control::{CT_Sdt, SdtContent};
7use rdocx_oxml::document::{BodyContent, CT_SectPr};
8use rdocx_oxml::drawing::WrapType;
9use rdocx_oxml::header_footer::{HdrFtrType, VmlWatermark};
10use rdocx_oxml::numbering::ST_LvlSuffix;
11use rdocx_oxml::properties::{CT_PPr, CT_RPr, CT_Shd};
12use rdocx_oxml::revision::{CT_Revision, RevisionContent, RevisionKind};
13use rdocx_oxml::shared::ST_HighlightColor;
14use rdocx_oxml::styles::CT_Styles;
15use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
16use rdocx_oxml::text::{
17    BreakType, CT_P, CT_R, Field, FieldArgument, RunContent, hyperlink_revision_index,
18};
19
20use crate::block::{self, LayoutBlock, ParagraphBlock};
21use crate::convert;
22use crate::input::{LayoutInput, MediaRegistry, RevisionView};
23use crate::notes::NoteRegistry;
24use crate::paginator::{self, HeaderFooterContent, PageGeometry};
25use crate::style_resolver::{self, NumberingState};
26use crate::table;
27use crate::{WordSourcePath, WordStory};
28use oxml_layout::{
29    Color, Diagnostic, DocumentMetadata, FieldKind, FontId, FontManager, GlyphRun, GroupElement,
30    InlineItem, LayoutResult, LineItem, NoteRef, NoteStream, PageFrame, Point, PositionedElement,
31    Rect, Result, SourceNodeId, SourceSpan, TextSegment, Transform, Underline, break_into_lines,
32};
33
34#[derive(Clone, Copy)]
35struct ProjectedRun<'a> {
36    run: &'a CT_R,
37    boundary: usize,
38    raw_order: RawOrder,
39    ordinary_run_index: Option<usize>,
40    hyperlink_index: Option<usize>,
41    force_underline: bool,
42    force_strike: bool,
43}
44
45#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
46enum RawOrder {
47    BeforeRaw,
48    Raw(usize),
49    AfterRaw,
50}
51
52/// Immutable source identities allocated once before layout starts.
53pub(crate) struct SourceRegistry {
54    nodes: Vec<WordSourcePath>,
55    ids: HashMap<WordSourcePath, SourceNodeId>,
56}
57
58impl SourceRegistry {
59    fn for_input(input: &LayoutInput) -> Self {
60        let mut registry = Self {
61            nodes: Vec::new(),
62            ids: HashMap::new(),
63        };
64
65        for (body_index, content) in input.document.body.content.iter().enumerate() {
66            match content {
67                BodyContent::Paragraph(_) => registry.insert(WordSourcePath {
68                    story: WordStory::Document,
69                    children: vec![body_index],
70                }),
71                BodyContent::Table(table) => {
72                    registry.collect_table(table, &WordStory::Document, &[body_index])
73                }
74                BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {}
75            }
76        }
77
78        let mut headers = input.headers.iter().collect::<Vec<_>>();
79        headers.sort_unstable_by_key(|(relationship_id, _)| *relationship_id);
80        for (relationship_id, header) in headers {
81            let story = WordStory::Header {
82                relationship_id: relationship_id.clone(),
83            };
84            for paragraph_index in 0..header.paragraphs.len() {
85                registry.insert(WordSourcePath {
86                    story: story.clone(),
87                    children: vec![paragraph_index],
88                });
89            }
90        }
91
92        let mut footers = input.footers.iter().collect::<Vec<_>>();
93        footers.sort_unstable_by_key(|(relationship_id, _)| *relationship_id);
94        for (relationship_id, footer) in footers {
95            let story = WordStory::Footer {
96                relationship_id: relationship_id.clone(),
97            };
98            for paragraph_index in 0..footer.paragraphs.len() {
99                registry.insert(WordSourcePath {
100                    story: story.clone(),
101                    children: vec![paragraph_index],
102                });
103            }
104        }
105
106        for (story_kind, stream) in [
107            (NoteStream::Footnote, input.footnotes.as_ref()),
108            (NoteStream::Endnote, input.endnotes.as_ref()),
109        ]
110        .into_iter()
111        .filter_map(|(story, stream)| stream.map(|stream| (story, stream)))
112        {
113            for note in &stream.footnotes {
114                if stream.get_by_id(note.id).is_none() {
115                    continue;
116                }
117                let story = match story_kind {
118                    NoteStream::Footnote => WordStory::Footnote { id: note.id },
119                    NoteStream::Endnote => WordStory::Endnote { id: note.id },
120                };
121                for paragraph_index in 0..note.paragraphs.len() {
122                    registry.insert(WordSourcePath {
123                        story: story.clone(),
124                        children: vec![paragraph_index],
125                    });
126                }
127            }
128        }
129
130        registry
131    }
132
133    fn collect_table(&mut self, table: &CT_Tbl, story: &WordStory, prefix: &[usize]) {
134        for (row_index, row) in table.rows.iter().enumerate() {
135            for (cell_index, cell) in row.cells.iter().enumerate() {
136                for (content_index, content) in cell.content.iter().enumerate() {
137                    let mut children = prefix.to_vec();
138                    children.extend([row_index, cell_index, content_index]);
139                    match content {
140                        CellContent::Paragraph(_) => self.insert(WordSourcePath {
141                            story: story.clone(),
142                            children,
143                        }),
144                        CellContent::Table(table) => {
145                            self.collect_table(table, story, &children);
146                        }
147                        CellContent::ContentControl(_) => {}
148                    }
149                }
150            }
151        }
152    }
153
154    fn insert(&mut self, path: WordSourcePath) {
155        if self.ids.contains_key(&path) {
156            return;
157        }
158        let index = u32::try_from(self.nodes.len() + 1)
159            .expect("a layout result cannot contain more than u32::MAX source paragraphs");
160        let id = SourceNodeId::new(index).expect("source ids are one based");
161        self.nodes.push(path.clone());
162        self.ids.insert(path, id);
163    }
164
165    pub(crate) fn id(&self, story: &WordStory, children: &[usize]) -> Option<SourceNodeId> {
166        self.ids
167            .get(&WordSourcePath {
168                story: story.clone(),
169                children: children.to_vec(),
170            })
171            .copied()
172    }
173
174    fn into_nodes(self) -> Vec<WordSourcePath> {
175        self.nodes
176    }
177}
178
179fn project_paragraph_runs(para: &CT_P, view: RevisionView) -> Vec<ProjectedRun<'_>> {
180    let mut projected = Vec::new();
181    for boundary in 0..=para.runs.len() {
182        for (_, slot, revision) in para.revisions.iter().filter(|(at, _, _)| *at == boundary) {
183            let hyperlink_index = hyperlink_revision_index(*slot);
184            let raw_order = match hyperlink_index {
185                Some(index) => {
186                    if let Some(raw_before) = para
187                        .hyperlinks
188                        .get(index)
189                        .and_then(|hyperlink| hyperlink.preserved_raw_before)
190                    {
191                        RawOrder::Raw(raw_before)
192                    } else if para
193                        .hyperlinks
194                        .get(index)
195                        .is_some_and(|hyperlink| boundary == hyperlink.run_end)
196                    {
197                        RawOrder::BeforeRaw
198                    } else {
199                        RawOrder::AfterRaw
200                    }
201                }
202                None => RawOrder::Raw(*slot),
203            };
204            project_revision_runs(
205                revision,
206                view,
207                boundary,
208                raw_order,
209                hyperlink_index,
210                false,
211                false,
212                &mut projected,
213            );
214        }
215        if let Some(run) = para.runs.get(boundary) {
216            projected.push(ProjectedRun {
217                run,
218                boundary,
219                raw_order: RawOrder::AfterRaw,
220                ordinary_run_index: Some(boundary),
221                hyperlink_index: None,
222                force_underline: false,
223                force_strike: false,
224            });
225        }
226    }
227    projected
228}
229
230fn project_revision_runs<'a>(
231    revision: &'a CT_Revision,
232    view: RevisionView,
233    boundary: usize,
234    raw_order: RawOrder,
235    hyperlink_index: Option<usize>,
236    inherited_underline: bool,
237    inherited_strike: bool,
238    projected: &mut Vec<ProjectedRun<'a>>,
239) {
240    let included = match view {
241        RevisionView::Tracked => true,
242        RevisionView::Accepted => matches!(
243            revision.kind(),
244            RevisionKind::Insertion | RevisionKind::MoveTo
245        ),
246    };
247    if !included {
248        return;
249    }
250
251    let force_underline = inherited_underline
252        || (view == RevisionView::Tracked
253            && matches!(
254                revision.kind(),
255                RevisionKind::Insertion | RevisionKind::MoveTo
256            ));
257    let force_strike = inherited_strike
258        || (view == RevisionView::Tracked
259            && matches!(
260                revision.kind(),
261                RevisionKind::Deletion | RevisionKind::MoveFrom
262            ));
263    let runs = match revision.content() {
264        RevisionContent::Runs(runs) => runs.as_slice(),
265        RevisionContent::Marker => &[],
266        RevisionContent::PriorRunProperties(_)
267        | RevisionContent::PriorParagraphProperties(_)
268        | RevisionContent::PriorTableProperties(_)
269        | RevisionContent::PriorSectionProperties(_) => return,
270    };
271
272    for run_boundary in 0..=runs.len() {
273        for (_, nested) in revision
274            .nested_revisions()
275            .iter()
276            .filter(|(at, _)| *at == run_boundary)
277        {
278            project_revision_runs(
279                nested,
280                view,
281                boundary,
282                raw_order,
283                hyperlink_index,
284                force_underline,
285                force_strike,
286                projected,
287            );
288        }
289        if let Some(run) = runs.get(run_boundary) {
290            projected.push(ProjectedRun {
291                run,
292                boundary,
293                raw_order,
294                ordinary_run_index: None,
295                hyperlink_index,
296                force_underline,
297                force_strike,
298            });
299        }
300    }
301}
302
303fn projected_paragraph_text(para: &CT_P, view: RevisionView) -> String {
304    project_paragraph_runs(para, view)
305        .iter()
306        .map(|projected| projected.run.text())
307        .collect()
308}
309
310fn projected_content_char_starts(run: &CT_R) -> Vec<usize> {
311    let mut starts = Vec::with_capacity(run.content.len());
312    let mut char_offset = 0usize;
313    for content in &run.content {
314        starts.push(char_offset);
315        char_offset += match content {
316            RunContent::Text(text) | RunContent::DeletedText(text) => text.text.chars().count(),
317            RunContent::Tab | RunContent::Break(_) => 1,
318            RunContent::Field(field) => field
319                .projected_text()
320                .map_or(0, |text| text.chars().count()),
321            RunContent::Drawing(_)
322            | RunContent::FootnoteRef { .. }
323            | RunContent::EndnoteRef { .. }
324            | RunContent::CommentReference { .. } => 0,
325        };
326    }
327    debug_assert_eq!(char_offset, run.text().chars().count());
328    starts
329}
330
331fn paragraph_has_visible_revision(para: &CT_P) -> bool {
332    let property_revision = para.properties.as_ref().is_some_and(|properties| {
333        properties.numbering_revision.is_some()
334            || properties.change.is_some()
335            || properties
336                .sect_pr
337                .as_ref()
338                .is_some_and(|section| section.change.is_some())
339            || properties
340                .rpr
341                .as_ref()
342                .is_some_and(run_properties_have_revision)
343    });
344    property_revision
345        || para
346            .runs
347            .iter()
348            .filter_map(|run| run.properties.as_ref())
349            .any(run_properties_have_revision)
350        || para
351            .revisions
352            .iter()
353            .any(|(_, _, revision)| revision_is_visible(revision))
354}
355
356fn run_properties_have_revision(properties: &rdocx_oxml::properties::CT_RPr) -> bool {
357    properties.change.is_some() || !properties.revision_markers.is_empty()
358}
359
360fn revision_is_visible(revision: &CT_Revision) -> bool {
361    match revision.content() {
362        RevisionContent::Runs(runs) => {
363            runs.iter().any(|run| {
364                run.content.iter().any(|content| match content {
365                    RunContent::Text(text) | RunContent::DeletedText(text) => !text.text.is_empty(),
366                    RunContent::CommentReference { .. } => false,
367                    RunContent::Tab
368                    | RunContent::Break(_)
369                    | RunContent::Drawing(_)
370                    | RunContent::Field(_)
371                    | RunContent::FootnoteRef { .. }
372                    | RunContent::EndnoteRef { .. } => true,
373                })
374            }) || revision
375                .nested_revisions()
376                .iter()
377                .any(|(_, nested)| revision_is_visible(nested))
378        }
379        RevisionContent::Marker => revision
380            .nested_revisions()
381            .iter()
382            .any(|(_, nested)| revision_is_visible(nested)),
383        RevisionContent::PriorRunProperties(_)
384        | RevisionContent::PriorParagraphProperties(_)
385        | RevisionContent::PriorTableProperties(_)
386        | RevisionContent::PriorSectionProperties(_) => true,
387    }
388}
389
390/// The layout engine.
391pub struct Engine {
392    font_manager: FontManager,
393    paragraph_cache_context: Option<ParagraphCacheContext>,
394    paragraph_cache: VecDeque<ParagraphCacheEntry>,
395    paragraph_cache_bytes: usize,
396    paragraph_cache_hits: usize,
397    paragraph_cache_builds: usize,
398    pending_paragraph_cache: Option<VecDeque<ParagraphCacheEntry>>,
399    pending_paragraph_cache_bytes: usize,
400    #[cfg(test)]
401    pending_paragraph_cache_peak_entries: usize,
402    #[cfg(test)]
403    pending_paragraph_cache_peak_bytes: usize,
404    paragraph_cache_reads_enabled: bool,
405}
406
407#[derive(Clone, PartialEq)]
408struct ParagraphCacheContext {
409    styles: CT_Styles,
410    theme: Option<rdocx_oxml::theme::Theme>,
411}
412
413impl ParagraphCacheContext {
414    fn for_input(input: &LayoutInput) -> Self {
415        Self {
416            styles: input.styles.clone(),
417            theme: input.theme.clone(),
418        }
419    }
420}
421
422#[derive(Clone, PartialEq)]
423struct ParagraphCacheKey {
424    paragraph: CT_P,
425    content_width_bits: u64,
426    revision_view: RevisionView,
427}
428
429struct ParagraphCacheEntry {
430    key: ParagraphCacheKey,
431    block: ParagraphBlock,
432    diagnostics: Vec<Diagnostic>,
433    font_trace: Vec<FontId>,
434    bytes: usize,
435}
436
437const PARAGRAPH_CACHE_MAX_ENTRIES: usize = 256;
438const PARAGRAPH_CACHE_MAX_BYTES: usize = 16 * 1024 * 1024;
439const CACHE_SOURCE_NODE: SourceNodeId = match SourceNodeId::new(1) {
440    Some(node) => node,
441    None => panic!("one is a valid source node id"),
442};
443
444impl Default for Engine {
445    fn default() -> Self {
446        Self::new()
447    }
448}
449
450impl Engine {
451    fn with_font_manager(font_manager: FontManager) -> Self {
452        Self {
453            font_manager,
454            paragraph_cache_context: None,
455            paragraph_cache: VecDeque::new(),
456            paragraph_cache_bytes: 0,
457            paragraph_cache_hits: 0,
458            paragraph_cache_builds: 0,
459            pending_paragraph_cache: None,
460            pending_paragraph_cache_bytes: 0,
461            #[cfg(test)]
462            pending_paragraph_cache_peak_entries: 0,
463            #[cfg(test)]
464            pending_paragraph_cache_peak_bytes: 0,
465            paragraph_cache_reads_enabled: false,
466        }
467    }
468
469    pub fn new() -> Self {
470        Self::with_font_manager(FontManager::new())
471    }
472
473    /// Create an engine that resolves fonts without system font discovery.
474    pub fn new_deterministic() -> Result<Self> {
475        Ok(Self::with_font_manager(FontManager::new_deterministic()?))
476    }
477
478    /// Create an engine whose font universe is supplied entirely by the
479    /// layout input, without bundled or system-font discovery.
480    pub(crate) fn new_with_caller_fonts() -> Self {
481        Self::with_font_manager(FontManager::new_with_fonts(Vec::new()))
482    }
483
484    /// Lay out the entire document.
485    pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
486        self.layout_inner(input, None)
487    }
488
489    /// Lay out the document and retain its result-local Word source table.
490    pub(crate) fn layout_with_provenance(
491        &mut self,
492        input: &LayoutInput,
493    ) -> Result<(LayoutResult, Vec<WordSourcePath>)> {
494        let sources = SourceRegistry::for_input(input);
495        let result = self.layout_inner(input, Some(&sources))?;
496        Ok((result, sources.into_nodes()))
497    }
498
499    fn layout_inner(
500        &mut self,
501        input: &LayoutInput,
502        sources: Option<&SourceRegistry>,
503    ) -> Result<LayoutResult> {
504        // Load user-provided / DOCX-embedded fonts (highest priority). An exact
505        // unchanged set is a no-op in a reusable engine.
506        let fonts_changed = self.font_manager.load_additional_fonts(&input.fonts);
507        self.font_manager.begin_layout();
508
509        let paragraph_context = ParagraphCacheContext::for_input(input);
510        if fonts_changed {
511            self.paragraph_cache.clear();
512            self.paragraph_cache_bytes = 0;
513        }
514        let context_matches =
515            !fonts_changed && self.paragraph_cache_context.as_ref() == Some(&paragraph_context);
516        self.paragraph_cache_reads_enabled = context_matches;
517        self.pending_paragraph_cache = Some(VecDeque::new());
518        self.pending_paragraph_cache_bytes = 0;
519        #[cfg(test)]
520        {
521            self.pending_paragraph_cache_peak_entries = 0;
522            self.pending_paragraph_cache_peak_bytes = 0;
523        }
524
525        let result = self.layout_transaction(input, sources);
526        let pending = self.pending_paragraph_cache.take().unwrap_or_default();
527        self.pending_paragraph_cache_bytes = 0;
528        self.paragraph_cache_reads_enabled = false;
529        if result.is_ok() {
530            if !context_matches {
531                self.paragraph_cache.clear();
532                self.paragraph_cache_bytes = 0;
533                self.paragraph_cache_context = Some(paragraph_context);
534            }
535            for entry in pending {
536                self.publish_paragraph_cache_entry(entry);
537            }
538        }
539        let current_fonts = self
540            .font_manager
541            .current_layout_fonts()
542            .iter()
543            .copied()
544            .collect::<std::collections::HashSet<_>>();
545        self.paragraph_cache.retain(|entry| {
546            entry
547                .font_trace
548                .iter()
549                .all(|font_id| current_fonts.contains(font_id))
550        });
551        self.paragraph_cache_bytes = self.paragraph_cache.iter().map(|entry| entry.bytes).sum();
552        self.font_manager.retain_current_fonts();
553        result
554    }
555
556    fn layout_transaction(
557        &mut self,
558        input: &LayoutInput,
559        sources: Option<&SourceRegistry>,
560    ) -> Result<LayoutResult> {
561        let styles = &input.styles;
562        let mut num_state = NumberingState::new();
563        let media = MediaRegistry::new(&input.images);
564        let mut diagnostics = Vec::new();
565
566        // Re-breaking a paragraph around a floating drawing needs its line
567        // breaking inputs kept alive past layout. Nearly no document has a
568        // drawing that wraps, so the state is dropped again unless one does.
569        let document_wraps = document_has_wrapping_drawing(input);
570
571        // Get final section properties (body-level sectPr)
572        let final_sect_pr = input
573            .document
574            .body
575            .sect_pr
576            .as_ref()
577            .cloned()
578            .unwrap_or_else(CT_SectPr::default_letter);
579
580        // Build sections: each section has blocks + geometry + header/footer
581        let mut sections: Vec<paginator::Section> = Vec::new();
582        let mut current_blocks: Vec<LayoutBlock> = Vec::new();
583        let mut current_sect_pr: Option<CT_SectPr> = None; // Will be set from paragraph sect_pr
584
585        for (body_index, content) in input.document.body.content.iter().enumerate() {
586            match content {
587                BodyContent::Paragraph(para) => {
588                    // Check if this paragraph ends a section (has sect_pr)
589                    let para_sect_pr = para.properties.as_ref().and_then(|p| p.sect_pr.clone());
590
591                    let sect_pr_for_layout = para_sect_pr
592                        .as_ref()
593                        .or(current_sect_pr.as_ref())
594                        .unwrap_or(&final_sect_pr);
595                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
596
597                    let source =
598                        sources.and_then(|sources| sources.id(&WordStory::Document, &[body_index]));
599                    let mut para_block = self.layout_body_paragraph(
600                        para,
601                        geometry.content_width(),
602                        styles,
603                        input,
604                        &media,
605                        &mut num_state,
606                        &mut diagnostics,
607                        source,
608                    )?;
609
610                    if !document_wraps {
611                        para_block.reflow = None;
612                    }
613
614                    // Detect heading style for outline generation
615                    if let Some(level) = detect_heading_level(para, styles) {
616                        para_block.heading_level = Some(level);
617                        para_block.heading_text =
618                            Some(projected_paragraph_text(para, input.revision_view));
619                    }
620
621                    current_blocks.push(LayoutBlock::Paragraph(para_block));
622
623                    // If this paragraph has sect_pr, it ends a section
624                    if let Some(sect_pr) = para_sect_pr {
625                        let geometry = sect_pr_to_geometry(&sect_pr);
626                        let header_footer = layout_header_footer(
627                            &sect_pr,
628                            input,
629                            styles,
630                            &media,
631                            &mut self.font_manager,
632                            &mut num_state,
633                            &mut diagnostics,
634                            sources,
635                        )?;
636                        let title_pg = sect_pr.title_pg.unwrap_or(false);
637                        sections.push(paginator::Section {
638                            blocks: std::mem::take(&mut current_blocks),
639                            geometry,
640                            header_footer,
641                            title_pg,
642                            page_number_start: section_page_number_start(&sect_pr),
643                        });
644                        current_sect_pr = Some(sect_pr);
645                    }
646                }
647                BodyContent::Table(tbl) => {
648                    let sect_pr_for_layout = current_sect_pr.as_ref().unwrap_or(&final_sect_pr);
649                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
650
651                    let table_block = table::layout_table_with_provenance(
652                        tbl,
653                        geometry.content_width(),
654                        styles,
655                        input,
656                        &media,
657                        &mut self.font_manager,
658                        &mut num_state,
659                        &mut diagnostics,
660                        sources,
661                        &WordStory::Document,
662                        &[body_index],
663                    )?;
664                    current_blocks.push(LayoutBlock::Table(table_block));
665                }
666                _ => {} // Skip RawXml elements during layout
667            }
668        }
669
670        // Remaining blocks belong to the final section
671        let final_geometry = sect_pr_to_geometry(&final_sect_pr);
672        let final_hf = layout_header_footer(
673            &final_sect_pr,
674            input,
675            styles,
676            &media,
677            &mut self.font_manager,
678            &mut num_state,
679            &mut diagnostics,
680            sources,
681        )?;
682        let final_title_pg = final_sect_pr.title_pg.unwrap_or(false);
683        sections.push(paginator::Section {
684            blocks: current_blocks,
685            geometry: final_geometry,
686            header_footer: final_hf,
687            title_pg: final_title_pg,
688            page_number_start: section_page_number_start(&final_sect_pr),
689        });
690
691        // Lay the notes out once per width, before pagination, so the paginator
692        // can reserve exactly the height it will later draw. A note is broken to
693        // the measure of the section carrying its reference, so every section's
694        // width is registered. The endnote pages that follow the last body page
695        // are drawn against `final_geometry`, which belongs to the section
696        // pushed just above and is therefore already in this list.
697        let content_widths: Vec<f64> = sections
698            .iter()
699            .map(|section| section.geometry.content_width())
700            .collect();
701        let notes = NoteRegistry::build(
702            input,
703            styles,
704            &media,
705            &mut self.font_manager,
706            &mut num_state,
707            &content_widths,
708            &mut diagnostics,
709            sources,
710        )?;
711
712        // Paginate across all sections
713        let (mut pages, outlines) =
714            paginator::paginate_sections(&sections, &self.font_manager, &media, &notes);
715
716        // Endnotes read at the end of the document, so they follow the last
717        // body page rather than sitting at the foot of their reference's page.
718        paginator::append_endnote_pages(&mut pages, &notes, final_geometry);
719
720        // Post-pagination pass: record bookmark targets and substitute fields.
721        let total_pages = pages.len();
722        let bookmark_pages = pages
723            .iter()
724            .flat_map(|page| {
725                page.elements.iter().filter_map(move |element| {
726                    let PositionedElement::Text(run) = element else {
727                        return None;
728                    };
729                    let Some(FieldKind::Target(target)) = run.field_kind else {
730                        return None;
731                    };
732                    Some((target, page.page_number))
733                })
734            })
735            .collect::<HashMap<_, _>>();
736        for page in &mut pages {
737            let page_num = page.page_number;
738            substitute_fields(
739                &mut page.elements,
740                page_num,
741                total_pages,
742                &bookmark_pages,
743                &mut self.font_manager,
744            );
745        }
746
747        // Post-pagination pass: apply page background color
748        apply_page_background(&mut pages, input);
749
750        // Remap persistent manager ids to result-local ids and omit faces that
751        // are no longer present in the current layout.
752        let fonts = if self.font_manager.every_loaded_font_is_current() {
753            self.font_manager.all_font_data()
754        } else {
755            let current_fonts = self.font_manager.current_layout_fonts().to_vec();
756            canonicalize_layout_fonts(&mut pages, &self.font_manager, &current_fonts)?
757        };
758
759        // Convert core properties to document metadata
760        let metadata = input.core_properties.as_ref().map(|cp| DocumentMetadata {
761            title: cp.title.clone(),
762            author: cp.creator.clone(),
763            subject: cp.subject.clone(),
764            keywords: cp.keywords.clone(),
765            creator: Some("rdocx".to_string()),
766        });
767
768        let mut result = LayoutResult::new(pages, fonts, metadata, outlines);
769        result.diagnostics = diagnostics;
770        Ok(result)
771    }
772
773    #[allow(clippy::too_many_arguments)]
774    fn layout_body_paragraph(
775        &mut self,
776        paragraph: &CT_P,
777        content_width: f64,
778        styles: &CT_Styles,
779        input: &LayoutInput,
780        media: &MediaRegistry,
781        numbering: &mut NumberingState,
782        diagnostics: &mut Vec<Diagnostic>,
783        source_node: Option<SourceNodeId>,
784    ) -> Result<ParagraphBlock> {
785        if !paragraph_is_cache_safe(paragraph, styles) {
786            return layout_paragraph_with_source(
787                paragraph,
788                content_width,
789                styles,
790                input,
791                media,
792                &mut self.font_manager,
793                numbering,
794                diagnostics,
795                source_node,
796            );
797        }
798
799        let key = ParagraphCacheKey {
800            paragraph: paragraph.clone(),
801            content_width_bits: content_width.to_bits(),
802            revision_view: input.revision_view,
803        };
804        if self.paragraph_cache_reads_enabled
805            && let Some(index) = self
806                .paragraph_cache
807                .iter()
808                .position(|entry| entry.key == key)
809        {
810            let entry = self
811                .paragraph_cache
812                .remove(index)
813                .expect("paragraph cache index exists");
814            let mut block = entry.block.clone();
815            rebind_paragraph_source(&mut block, source_node);
816            diagnostics.extend(entry.diagnostics.iter().cloned());
817            self.font_manager
818                .replay_layout_font_trace(&entry.font_trace);
819            self.paragraph_cache.push_back(entry);
820            self.paragraph_cache_hits += 1;
821            return Ok(block);
822        }
823
824        let diagnostics_start = diagnostics.len();
825        self.font_manager.begin_paragraph_font_trace();
826        let block_result = layout_paragraph_with_source(
827            paragraph,
828            content_width,
829            styles,
830            input,
831            media,
832            &mut self.font_manager,
833            numbering,
834            diagnostics,
835            Some(CACHE_SOURCE_NODE),
836        );
837        let font_trace = self.font_manager.finish_paragraph_font_trace();
838        let mut block = block_result?;
839        self.paragraph_cache_builds += 1;
840
841        let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
842        if let Some(font_trace) = font_trace {
843            let bytes = paragraph_cache_entry_bytes(
844                &key.paragraph,
845                &block,
846                &cached_diagnostics,
847                font_trace.len(),
848            );
849            self.stage_paragraph_cache_entry(ParagraphCacheEntry {
850                key,
851                block: block.clone(),
852                diagnostics: cached_diagnostics,
853                font_trace,
854                bytes,
855            });
856        }
857
858        rebind_paragraph_source(&mut block, source_node);
859        Ok(block)
860    }
861
862    #[cfg(test)]
863    fn paragraph_cache_counts(&self) -> (usize, usize) {
864        (self.paragraph_cache_hits, self.paragraph_cache_builds)
865    }
866
867    fn publish_paragraph_cache_entry(&mut self, entry: ParagraphCacheEntry) {
868        if entry.bytes > PARAGRAPH_CACHE_MAX_BYTES {
869            return;
870        }
871        while self.paragraph_cache.len() >= PARAGRAPH_CACHE_MAX_ENTRIES
872            || self.paragraph_cache_bytes.saturating_add(entry.bytes) > PARAGRAPH_CACHE_MAX_BYTES
873        {
874            let Some(evicted) = self.paragraph_cache.pop_front() else {
875                break;
876            };
877            self.paragraph_cache_bytes = self.paragraph_cache_bytes.saturating_sub(evicted.bytes);
878        }
879        self.paragraph_cache_bytes += entry.bytes;
880        self.paragraph_cache.push_back(entry);
881    }
882
883    fn stage_paragraph_cache_entry(&mut self, entry: ParagraphCacheEntry) {
884        if entry.bytes > PARAGRAPH_CACHE_MAX_BYTES {
885            return;
886        }
887        let Some(pending) = self.pending_paragraph_cache.as_mut() else {
888            return;
889        };
890        while pending.len() >= PARAGRAPH_CACHE_MAX_ENTRIES
891            || self
892                .pending_paragraph_cache_bytes
893                .saturating_add(entry.bytes)
894                > PARAGRAPH_CACHE_MAX_BYTES
895        {
896            let Some(evicted) = pending.pop_front() else {
897                break;
898            };
899            self.pending_paragraph_cache_bytes = self
900                .pending_paragraph_cache_bytes
901                .saturating_sub(evicted.bytes);
902        }
903        self.pending_paragraph_cache_bytes += entry.bytes;
904        pending.push_back(entry);
905        #[cfg(test)]
906        {
907            self.pending_paragraph_cache_peak_entries =
908                self.pending_paragraph_cache_peak_entries.max(pending.len());
909            self.pending_paragraph_cache_peak_bytes = self
910                .pending_paragraph_cache_peak_bytes
911                .max(self.pending_paragraph_cache_bytes);
912        }
913    }
914}
915
916fn paragraph_is_cache_safe(paragraph: &CT_P, styles: &CT_Styles) -> bool {
917    if !paragraph.hyperlinks.is_empty()
918        || !paragraph.comment_ranges.is_empty()
919        || !paragraph.bookmark_markers.is_empty()
920        || !paragraph.extra_xml.is_empty()
921        || !paragraph.content_controls.is_empty()
922        || !paragraph.revisions.is_empty()
923    {
924        return false;
925    }
926
927    let style_id = paragraph
928        .properties
929        .as_ref()
930        .and_then(|properties| properties.style_id.as_deref());
931    let resolved = style_resolver::resolve_paragraph_properties(style_id, styles);
932    if resolved.num_id.is_some()
933        || paragraph.properties.as_ref().is_some_and(|properties| {
934            properties.num_id.is_some()
935                || properties.sect_pr.is_some()
936                || properties.numbering_revision.is_some()
937                || !properties.numbering_revision_xml.is_empty()
938                || properties.change.is_some()
939                || !properties.revision_xml.is_empty()
940                || properties.rpr.as_ref().is_some_and(|rpr| {
941                    !rpr.revision_markers.is_empty()
942                        || rpr.change.is_some()
943                        || !rpr.revision_xml.is_empty()
944                        || !rpr.revision_xml_positions.is_empty()
945                })
946        })
947    {
948        return false;
949    }
950
951    paragraph.runs.iter().all(|run| {
952        run.alt_drawings.is_empty()
953            && run.extra_xml.is_empty()
954            && run.extra_xml_positions.is_empty()
955            && run.properties.as_ref().is_none_or(|rpr| {
956                rpr.revision_markers.is_empty()
957                    && rpr.change.is_none()
958                    && rpr.revision_xml.is_empty()
959                    && rpr.revision_xml_positions.is_empty()
960            })
961            && run.content.iter().all(|content| {
962                matches!(
963                    content,
964                    RunContent::Text(_) | RunContent::Tab | RunContent::Break(_)
965                )
966            })
967    })
968}
969
970fn canonicalize_layout_fonts(
971    pages: &mut [PageFrame],
972    font_manager: &FontManager,
973    current_fonts: &[FontId],
974) -> Result<Vec<oxml_layout::FontData>> {
975    fn collect(
976        elements: &[PositionedElement],
977        remap: &mut HashMap<FontId, FontId>,
978        order: &mut Vec<FontId>,
979    ) {
980        for element in elements {
981            match element {
982                PositionedElement::Text(run) => {
983                    if let std::collections::hash_map::Entry::Vacant(entry) =
984                        remap.entry(run.font_id)
985                    {
986                        let local = FontId(order.len() as u32);
987                        entry.insert(local);
988                        order.push(run.font_id);
989                    }
990                }
991                PositionedElement::Group(group) => collect(&group.children, remap, order),
992                _ => {}
993            }
994        }
995    }
996
997    fn rewrite(elements: &mut [PositionedElement], remap: &HashMap<FontId, FontId>) {
998        for element in elements {
999            match element {
1000                PositionedElement::Text(run) => {
1001                    run.font_id = remap[&run.font_id];
1002                }
1003                PositionedElement::Group(group) => rewrite(&mut group.children, remap),
1004                _ => {}
1005            }
1006        }
1007    }
1008
1009    let mut remap = HashMap::new();
1010    let mut order = Vec::with_capacity(current_fonts.len());
1011    for &font_id in current_fonts {
1012        if let std::collections::hash_map::Entry::Vacant(entry) = remap.entry(font_id) {
1013            let local = FontId(order.len() as u32);
1014            entry.insert(local);
1015            order.push(font_id);
1016        }
1017    }
1018    for page in pages.iter() {
1019        collect(&page.elements, &mut remap, &mut order);
1020    }
1021    let mut fonts = Vec::with_capacity(order.len());
1022    for persistent_id in order {
1023        let mut font = font_manager.font_data(persistent_id)?;
1024        font.id = remap[&persistent_id];
1025        fonts.push(font);
1026    }
1027    for page in pages {
1028        rewrite(&mut page.elements, &remap);
1029    }
1030    Ok(fonts)
1031}
1032
1033fn rebind_text_source(text: &mut TextSegment, source_node: Option<SourceNodeId>) {
1034    match (text.source.as_mut(), source_node) {
1035        (Some(source), Some(node)) => source.node = node,
1036        (Some(_), None) => text.source = None,
1037        (None, _) => {}
1038    }
1039}
1040
1041fn rebind_paragraph_source(block: &mut ParagraphBlock, source_node: Option<SourceNodeId>) {
1042    for line in &mut block.lines {
1043        for item in &mut line.items {
1044            match item {
1045                LineItem::Text(text) | LineItem::Marker(text) => {
1046                    rebind_text_source(text, source_node)
1047                }
1048                LineItem::Tab {
1049                    leader: Some(leader),
1050                    ..
1051                } => rebind_text_source(leader, source_node),
1052                _ => {}
1053            }
1054        }
1055    }
1056    if let Some(reflow) = block.reflow.as_mut() {
1057        for item in &mut reflow.items {
1058            match item {
1059                InlineItem::Text(text) | InlineItem::Marker(text) => {
1060                    rebind_text_source(text, source_node)
1061                }
1062                _ => {}
1063            }
1064        }
1065    }
1066}
1067
1068fn paragraph_cache_entry_bytes(
1069    paragraph: &CT_P,
1070    block: &ParagraphBlock,
1071    diagnostics: &[Diagnostic],
1072    font_trace_len: usize,
1073) -> usize {
1074    fn option_string_bytes(value: &Option<String>) -> usize {
1075        value.as_ref().map_or(0, String::capacity)
1076    }
1077    fn shading_bytes(shading: &CT_Shd) -> usize {
1078        shading
1079            .val
1080            .capacity()
1081            .saturating_add(option_string_bytes(&shading.color))
1082            .saturating_add(option_string_bytes(&shading.fill))
1083    }
1084    fn run_properties_bytes(properties: &CT_RPr) -> usize {
1085        [
1086            &properties.style_id,
1087            &properties.font_ascii,
1088            &properties.font_hansi,
1089            &properties.font_east_asia,
1090            &properties.font_cs,
1091            &properties.font_ascii_theme,
1092            &properties.font_hansi_theme,
1093            &properties.color,
1094            &properties.color_theme,
1095            &properties.vert_align,
1096        ]
1097        .into_iter()
1098        .map(option_string_bytes)
1099        .fold(0usize, usize::saturating_add)
1100        .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
1101    }
1102    fn border_bytes(borders: &CT_PBdr) -> usize {
1103        [
1104            &borders.top,
1105            &borders.bottom,
1106            &borders.left,
1107            &borders.right,
1108            &borders.between,
1109            &borders.bar,
1110        ]
1111        .into_iter()
1112        .map(|edge| {
1113            edge.as_ref()
1114                .and_then(|edge| edge.color.as_ref())
1115                .map_or(0, String::capacity)
1116        })
1117        .fold(0usize, usize::saturating_add)
1118    }
1119    fn paragraph_properties_bytes(properties: &CT_PPr) -> usize {
1120        option_string_bytes(&properties.style_id)
1121            .saturating_add(option_string_bytes(&properties.line_rule))
1122            .saturating_add(properties.borders.as_ref().map_or(0, border_bytes))
1123            .saturating_add(properties.tabs.as_ref().map_or(0, |tabs| {
1124                tabs.tabs
1125                    .capacity()
1126                    .saturating_mul(std::mem::size_of::<CT_TabStop>())
1127            }))
1128            .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
1129            .saturating_add(properties.rpr.as_ref().map_or(0, run_properties_bytes))
1130    }
1131    fn paragraph_key_bytes(paragraph: &CT_P) -> usize {
1132        paragraph
1133            .runs
1134            .capacity()
1135            .saturating_mul(std::mem::size_of::<CT_R>())
1136            .saturating_add(
1137                paragraph
1138                    .runs
1139                    .iter()
1140                    .map(|run| {
1141                        run.content
1142                            .capacity()
1143                            .saturating_mul(std::mem::size_of::<RunContent>())
1144                            .saturating_add(
1145                                run.content
1146                                    .iter()
1147                                    .map(|content| match content {
1148                                        RunContent::Text(text) => text.text.capacity(),
1149                                        _ => 0,
1150                                    })
1151                                    .fold(0usize, usize::saturating_add),
1152                            )
1153                            .saturating_add(run.properties.as_ref().map_or(0, run_properties_bytes))
1154                    })
1155                    .fold(0usize, usize::saturating_add),
1156            )
1157            .saturating_add(
1158                paragraph
1159                    .properties
1160                    .as_ref()
1161                    .map_or(0, paragraph_properties_bytes),
1162            )
1163    }
1164    fn text_bytes(text: &TextSegment) -> usize {
1165        text.text.capacity()
1166            + text.glyph_ids.capacity() * std::mem::size_of::<u16>()
1167            + text.advances.capacity() * std::mem::size_of::<f64>()
1168            + text.hyperlink_url.as_ref().map_or(0, String::capacity)
1169    }
1170    fn inline_bytes(item: &InlineItem) -> usize {
1171        match item {
1172            InlineItem::Text(text) | InlineItem::Marker(text) => text_bytes(text),
1173            InlineItem::Group { .. } => usize::MAX,
1174            _ => 0,
1175        }
1176    }
1177    fn line_item_bytes(item: &LineItem) -> usize {
1178        match item {
1179            LineItem::Text(text) | LineItem::Marker(text) => text_bytes(text),
1180            LineItem::Tab { leader, .. } => leader.as_ref().map_or(0, text_bytes),
1181            LineItem::Group { .. } => usize::MAX,
1182            _ => 0,
1183        }
1184    }
1185
1186    let paragraph_bytes = paragraph_key_bytes(paragraph);
1187    let line_bytes = block
1188        .lines
1189        .capacity()
1190        .saturating_mul(std::mem::size_of::<oxml_layout::LayoutLine>())
1191        .saturating_add(
1192            block
1193                .lines
1194                .iter()
1195                .map(|line| {
1196                    line.items
1197                        .capacity()
1198                        .saturating_mul(std::mem::size_of::<LineItem>())
1199                        .saturating_add(
1200                            line.items
1201                                .iter()
1202                                .map(line_item_bytes)
1203                                .fold(0usize, usize::saturating_add),
1204                        )
1205                })
1206                .fold(0usize, usize::saturating_add),
1207        );
1208    let reflow_bytes = block.reflow.as_ref().map_or(0, |reflow| {
1209        std::mem::size_of_val(reflow.as_ref())
1210            .saturating_add(
1211                reflow
1212                    .items
1213                    .capacity()
1214                    .saturating_mul(std::mem::size_of::<InlineItem>()),
1215            )
1216            .saturating_add(
1217                reflow
1218                    .items
1219                    .iter()
1220                    .map(inline_bytes)
1221                    .fold(0usize, usize::saturating_add),
1222            )
1223            .saturating_add(
1224                reflow
1225                    .params
1226                    .tab_stops
1227                    .capacity()
1228                    .saturating_mul(std::mem::size_of::<oxml_layout::TabStop>()),
1229            )
1230            .saturating_add(
1231                reflow
1232                    .params
1233                    .line_prefix_widths
1234                    .capacity()
1235                    .saturating_mul(std::mem::size_of::<f64>()),
1236            )
1237            .saturating_add(
1238                reflow
1239                    .params
1240                    .line_suffix_widths
1241                    .capacity()
1242                    .saturating_mul(std::mem::size_of::<f64>()),
1243            )
1244    });
1245    let diagnostic_bytes = diagnostics
1246        .len()
1247        .saturating_mul(std::mem::size_of::<Diagnostic>())
1248        .saturating_add(
1249            diagnostics
1250                .iter()
1251                .map(|diagnostic| diagnostic.message.capacity())
1252                .fold(0usize, usize::saturating_add),
1253        );
1254    std::mem::size_of::<ParagraphCacheEntry>()
1255        .saturating_add(paragraph_bytes)
1256        .saturating_add(line_bytes)
1257        .saturating_add(reflow_bytes)
1258        .saturating_add(if block.anchored.is_empty() {
1259            0
1260        } else {
1261            usize::MAX
1262        })
1263        .saturating_add(block.heading_text.as_ref().map_or(0, String::capacity))
1264        .saturating_add(block.borders.as_ref().map_or(0, border_bytes))
1265        .saturating_add(font_trace_len * std::mem::size_of::<FontId>())
1266        .saturating_add(diagnostic_bytes)
1267}
1268
1269/// Apply page background color from `w:background` element to all pages.
1270fn apply_page_background(pages: &mut [PageFrame], input: &LayoutInput) {
1271    let bg_xml = match &input.document.background_xml {
1272        Some(xml) => xml,
1273        None => return,
1274    };
1275
1276    // Parse w:color attribute from background XML
1277    let xml_str = std::str::from_utf8(bg_xml).unwrap_or("");
1278    let color = extract_background_color(xml_str);
1279    let color = match color {
1280        Some(c) => c,
1281        None => return,
1282    };
1283
1284    // Insert a full-page FilledRect at position 0 on every page (renders underneath everything)
1285    for page in pages.iter_mut() {
1286        page.elements.insert(
1287            0,
1288            PositionedElement::FilledRect {
1289                rect: Rect {
1290                    x: 0.0,
1291                    y: 0.0,
1292                    width: page.width,
1293                    height: page.height,
1294                },
1295                color,
1296            },
1297        );
1298    }
1299}
1300
1301/// Extract the background color hex from w:background XML.
1302fn extract_background_color(xml: &str) -> Option<Color> {
1303    // Look for w:color="RRGGBB" or color="RRGGBB"
1304    for attr in ["w:color=\"", "color=\""] {
1305        if let Some(start) = xml.find(attr) {
1306            let val_start = start + attr.len();
1307            if let Some(end) = xml[val_start..].find('"') {
1308                let hex = &xml[val_start..val_start + end];
1309                if hex.len() == 6 && hex != "auto" {
1310                    return Some(Color::from_hex(hex));
1311                }
1312            }
1313        }
1314    }
1315    None
1316}
1317
1318/// Replace field placeholder GlyphRuns with actual values.
1319fn substitute_fields(
1320    elements: &mut Vec<PositionedElement>,
1321    page_number: usize,
1322    total_pages: usize,
1323    bookmark_pages: &HashMap<usize, usize>,
1324    fm: &mut FontManager,
1325) {
1326    for element in elements.iter_mut() {
1327        if let PositionedElement::Text(run) = element
1328            && let Some(fk) = run.field_kind
1329        {
1330            let value = match fk {
1331                FieldKind::Page => page_number.to_string(),
1332                FieldKind::NumPages => total_pages.to_string(),
1333                FieldKind::TargetPage(target) => bookmark_pages
1334                    .get(&target)
1335                    .map(usize::to_string)
1336                    .unwrap_or_else(|| run.text.clone()),
1337                FieldKind::Target(_) => continue,
1338            };
1339            // Re-shape the text with the actual value
1340            if let Ok(shaped) = fm.shape_text(run.font_id, &value, run.font_size) {
1341                run.text = value;
1342                run.glyph_ids = shaped.glyph_ids;
1343                run.advances = shaped.advances;
1344            }
1345        }
1346    }
1347    elements.retain(|element| {
1348        !matches!(
1349            element,
1350            PositionedElement::Text(run)
1351                if matches!(run.field_kind, Some(FieldKind::Target(_)))
1352        )
1353    });
1354}
1355
1356/// Detect if a paragraph has a heading style, returning the level (1-9).
1357fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
1358    let style_id = para.properties.as_ref()?.style_id.as_deref()?;
1359    // Check if style ID matches "Heading1" .. "Heading9"
1360    if let Some(rest) = style_id.strip_prefix("Heading") {
1361        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
1362    }
1363    // Also check style name in the styles definitions
1364    if let Some(style_def) = styles.get_by_id(style_id)
1365        && let Some(ref name) = style_def.name
1366        && let Some(rest) = name.strip_prefix("heading ")
1367    {
1368        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
1369    }
1370    None
1371}
1372
1373/// Lay out a single paragraph into a ParagraphBlock.
1374pub fn layout_paragraph(
1375    para: &CT_P,
1376    available_width: f64,
1377    styles: &CT_Styles,
1378    input: &LayoutInput,
1379    media: &MediaRegistry,
1380    fm: &mut FontManager,
1381    num_state: &mut NumberingState,
1382    diagnostics: &mut Vec<Diagnostic>,
1383) -> Result<ParagraphBlock> {
1384    layout_paragraph_with_source(
1385        para,
1386        available_width,
1387        styles,
1388        input,
1389        media,
1390        fm,
1391        num_state,
1392        diagnostics,
1393        None,
1394    )
1395}
1396
1397pub(crate) fn layout_paragraph_with_source(
1398    para: &CT_P,
1399    available_width: f64,
1400    styles: &CT_Styles,
1401    input: &LayoutInput,
1402    media: &MediaRegistry,
1403    fm: &mut FontManager,
1404    num_state: &mut NumberingState,
1405    diagnostics: &mut Vec<Diagnostic>,
1406    source_node: Option<SourceNodeId>,
1407) -> Result<ParagraphBlock> {
1408    // Resolve paragraph properties
1409    let para_style_id = para.properties.as_ref().and_then(|p| p.style_id.as_deref());
1410
1411    let resolved_ppr = style_resolver::resolve_paragraph_properties(para_style_id, styles);
1412
1413    let mut effective_ppr = resolved_ppr;
1414
1415    // A numbering level carries paragraph properties of its own, mainly the
1416    // indentation for that level. They sit between the style and direct
1417    // formatting, so merge them before the direct properties rather than
1418    // after. Without this every level of a list draws at the same indent.
1419    let direct_ppr = para.properties.as_ref();
1420    let list_num_id = direct_ppr.and_then(|p| p.num_id).or(effective_ppr.num_id);
1421    let list_ilvl = direct_ppr
1422        .and_then(|p| p.num_ilvl)
1423        .or(effective_ppr.num_ilvl)
1424        .unwrap_or(0);
1425    if let (Some(num_id), Some(numbering)) = (list_num_id, input.numbering.as_ref())
1426        && let Some(lvl_ppr) =
1427            style_resolver::level_paragraph_properties(num_id, list_ilvl, numbering)
1428    {
1429        merge_direct_ppr(&mut effective_ppr, lvl_ppr);
1430    }
1431
1432    // Merge direct paragraph properties
1433    if let Some(direct_ppr) = direct_ppr {
1434        merge_direct_ppr(&mut effective_ppr, direct_ppr);
1435    }
1436
1437    // Convert paragraph properties to layout values
1438    let space_before = effective_ppr.space_before.map(|t| t.to_pt()).unwrap_or(0.0);
1439    let space_after = effective_ppr.space_after.map(|t| t.to_pt()).unwrap_or(0.0);
1440    let ind_left = effective_ppr.ind_left.map(|t| t.to_pt()).unwrap_or(0.0);
1441    let ind_right = effective_ppr.ind_right.map(|t| t.to_pt()).unwrap_or(0.0);
1442    let keep_next = effective_ppr.keep_next.unwrap_or(false);
1443    let keep_lines = effective_ppr.keep_lines.unwrap_or(false);
1444    let page_break_before = effective_ppr.page_break_before.unwrap_or(false);
1445    let widow_control = effective_ppr.widow_control.unwrap_or(true);
1446    let jc = convert::alignment(effective_ppr.jc);
1447
1448    // Parse shading color
1449    let shading = effective_ppr
1450        .shading
1451        .as_ref()
1452        .and_then(|shd| shd.fill.as_ref())
1453        .filter(|f| f != &"auto")
1454        .map(|f| Color::from_hex(f));
1455
1456    // Convert runs to inline items
1457    let mut inline_items = Vec::new();
1458
1459    // Handle numbering marker
1460    if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) {
1461        let ilvl = effective_ppr.num_ilvl.unwrap_or(0);
1462        if let Some(marker) = style_resolver::generate_marker(num_id, ilvl, numbering, num_state) {
1463            // Shape the marker text
1464            let marker_rpr = marker.marker_rpr;
1465            let marker_font_size = marker_rpr.sz.map(|hp| hp.to_pt()).unwrap_or_else(|| {
1466                style_resolver::resolve_run_properties(para_style_id, None, styles)
1467                    .sz
1468                    .map(|hp| hp.to_pt())
1469                    .unwrap_or(11.0)
1470            });
1471            let marker_bold = marker_rpr.bold.unwrap_or(false);
1472            let marker_italic = marker_rpr.italic.unwrap_or(false);
1473            let marker_font_family = marker_rpr.font_ascii.as_deref();
1474
1475            // Bullet glyphs are not in every font either, so the marker gets
1476            // the same coverage check as body text.
1477            if let Ok(font_id) = fm.resolve_font_for_text(
1478                marker_font_family,
1479                marker_bold,
1480                marker_italic,
1481                &marker.marker_text,
1482            ) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size)
1483            {
1484                let metrics = fm.metrics(font_id, marker_font_size)?;
1485                let color = marker_rpr
1486                    .color
1487                    .as_ref()
1488                    .map(|c| Color::from_hex(c))
1489                    .unwrap_or(Color::BLACK);
1490
1491                inline_items.push(InlineItem::Marker(TextSegment {
1492                    text: marker.marker_text,
1493                    source: None,
1494                    font_id,
1495                    font_size: marker_font_size,
1496                    glyph_ids: shaped.glyph_ids,
1497                    advances: shaped.advances,
1498                    width: shaped.width,
1499                    ascent: metrics.ascent,
1500                    descent: metrics.descent,
1501                    line_gap: 0.0,
1502                    color,
1503                    bold: marker_bold,
1504                    italic: marker_italic,
1505                    underline: None,
1506                    strike: false,
1507                    dstrike: false,
1508                    highlight: None,
1509                    baseline_offset: 0.0,
1510                    hyperlink_url: None,
1511                    field_kind: None,
1512                    note: None,
1513                }));
1514
1515                match marker.suffix {
1516                    ST_LvlSuffix::Tab => inline_items.push(InlineItem::Tab),
1517                    ST_LvlSuffix::Space => {
1518                        let shaped = fm.shape_text(font_id, " ", marker_font_size)?;
1519                        inline_items.push(InlineItem::Text(TextSegment {
1520                            text: " ".to_owned(),
1521                            source: None,
1522                            font_id,
1523                            font_size: marker_font_size,
1524                            glyph_ids: shaped.glyph_ids,
1525                            advances: shaped.advances,
1526                            width: shaped.width,
1527                            ascent: metrics.ascent,
1528                            descent: metrics.descent,
1529                            line_gap: 0.0,
1530                            color,
1531                            bold: marker_bold,
1532                            italic: marker_italic,
1533                            underline: None,
1534                            strike: false,
1535                            dstrike: false,
1536                            highlight: None,
1537                            baseline_offset: 0.0,
1538                            hyperlink_url: None,
1539                            field_kind: None,
1540                            note: None,
1541                        }));
1542                    }
1543                    ST_LvlSuffix::Nothing => {}
1544                }
1545            }
1546        }
1547    }
1548
1549    // Build hyperlink URL map: run index → URL
1550    let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
1551        std::collections::HashMap::new();
1552    for hl in &para.hyperlinks {
1553        if let Some(ref rel_id) = hl.rel_id
1554            && let Some(url) = input.hyperlink_urls.get(rel_id)
1555        {
1556            for run_idx in hl.run_start..hl.run_end {
1557                run_hyperlink_url.insert(run_idx, url.clone());
1558            }
1559        }
1560    }
1561
1562    // Process ordinary and revision-wrapped runs in their preserved order.
1563    let mut marker_boundary = None;
1564    let mut marker_raw_before = None;
1565    let mut projection_char_offset = 0usize;
1566    for projected in project_paragraph_runs(para, input.revision_view) {
1567        let run = projected.run;
1568        let projected_run_start = projection_char_offset;
1569        projection_char_offset += run.text().chars().count();
1570        if marker_boundary != Some(projected.boundary) {
1571            marker_boundary = Some(projected.boundary);
1572            marker_raw_before = None;
1573        }
1574        push_targeted_bookmark_markers(
1575            &mut inline_items,
1576            para,
1577            projected.boundary,
1578            marker_raw_before,
1579            projected.raw_order,
1580            input,
1581            fm,
1582        )?;
1583        marker_raw_before = Some(projected.raw_order);
1584        let current_hyperlink_url = projected
1585            .ordinary_run_index
1586            .and_then(|run_index| run_hyperlink_url.get(&run_index).cloned())
1587            .or_else(|| {
1588                projected
1589                    .hyperlink_index
1590                    .and_then(|index| para.hyperlinks.get(index))
1591                    .and_then(|hyperlink| hyperlink.rel_id.as_deref())
1592                    .and_then(|rel_id| input.hyperlink_urls.get(rel_id).cloned())
1593            });
1594
1595        let run_style_id = run.properties.as_ref().and_then(|p| p.style_id.as_deref());
1596
1597        let resolved_rpr =
1598            style_resolver::resolve_run_properties(para_style_id, run_style_id, styles);
1599
1600        // Merge direct run properties
1601        let mut effective_rpr = resolved_rpr;
1602        if let Some(ref direct_rpr) = run.properties {
1603            effective_rpr.merge_from(direct_rpr);
1604        }
1605
1606        // Skip hidden text
1607        if effective_rpr.vanish == Some(true) {
1608            continue;
1609        }
1610
1611        let mut font_size = effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
1612        let bold = effective_rpr.bold.unwrap_or(false);
1613        let italic = effective_rpr.italic.unwrap_or(false);
1614
1615        // Resolve font family: theme font takes priority when no explicit font is set
1616        let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
1617
1618        // Resolve color: theme color takes priority over literal color value
1619        let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
1620
1621        // Decoration properties
1622        let underline = if projected.force_underline {
1623            Some(Underline::Single)
1624        } else {
1625            convert::underline(effective_rpr.underline)
1626        };
1627        let strike = projected.force_strike || effective_rpr.strike.unwrap_or(false);
1628        let dstrike = effective_rpr.dstrike.unwrap_or(false);
1629        let highlight = effective_rpr.highlight.and_then(highlight_to_color);
1630
1631        // Superscript/subscript handling
1632        let mut baseline_offset = 0.0;
1633        if let Some(ref va) = effective_rpr.vert_align {
1634            match va.as_str() {
1635                "superscript" => {
1636                    // Reduce font size to ~58% and raise baseline
1637                    let original_size = font_size;
1638                    font_size *= 0.58;
1639                    baseline_offset = original_size * 0.33; // raise by 1/3 of original size
1640                }
1641                "subscript" => {
1642                    // Reduce font size to ~58% and lower baseline
1643                    let original_size = font_size;
1644                    font_size *= 0.58;
1645                    baseline_offset = -(original_size * 0.14); // lower
1646                }
1647                _ => {}
1648            }
1649        }
1650
1651        // Position offset (in half-points, positive=raise)
1652        if let Some(pos) = effective_rpr.position {
1653            baseline_offset += pos as f64 / 2.0; // half-points to points
1654        }
1655
1656        // Resolved against the run's own text, so a family without glyphs for
1657        // this script is replaced by one that has them.
1658        let font_id =
1659            fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?;
1660        let metrics = fm.metrics(font_id, font_size)?;
1661
1662        let content_char_starts = projected_content_char_starts(run);
1663        for (content_index, content) in run.content.iter().enumerate() {
1664            let content_char_start = projected_run_start + content_char_starts[content_index];
1665            match content {
1666                RunContent::Text(ct_text) | RunContent::DeletedText(ct_text) => {
1667                    let text = if effective_rpr.caps == Some(true) {
1668                        ct_text.text.to_uppercase()
1669                    } else {
1670                        ct_text.text.clone()
1671                    };
1672
1673                    if text.is_empty() {
1674                        continue;
1675                    }
1676
1677                    let mut shaped = fm.shape_text(font_id, &text, font_size)?;
1678                    let source = if text == ct_text.text {
1679                        source_node.and_then(|node| {
1680                            let char_start = u32::try_from(content_char_start).ok()?;
1681                            let char_end =
1682                                u32::try_from(content_char_start + ct_text.text.chars().count())
1683                                    .ok()?;
1684                            Some(SourceSpan {
1685                                node,
1686                                char_start,
1687                                char_end,
1688                            })
1689                        })
1690                    } else {
1691                        None
1692                    };
1693
1694                    // Apply character spacing from run properties (in twips)
1695                    if let Some(spacing) = effective_rpr.spacing {
1696                        let extra = spacing.to_pt();
1697                        for advance in &mut shaped.advances {
1698                            *advance += extra;
1699                        }
1700                        shaped.width += extra * shaped.advances.len() as f64;
1701                    }
1702
1703                    inline_items.extend(convert::text_segments(TextSegment {
1704                        text,
1705                        source,
1706                        font_id,
1707                        font_size,
1708                        glyph_ids: shaped.glyph_ids,
1709                        advances: shaped.advances,
1710                        width: shaped.width,
1711                        ascent: metrics.ascent,
1712                        descent: metrics.descent,
1713                        line_gap: 0.0,
1714                        color,
1715                        bold,
1716                        italic,
1717                        underline,
1718                        strike,
1719                        dstrike,
1720                        highlight,
1721                        baseline_offset,
1722                        hyperlink_url: current_hyperlink_url.clone(),
1723                        field_kind: None,
1724                        note: None,
1725                    }));
1726                }
1727                RunContent::Tab => {
1728                    inline_items.push(InlineItem::Tab);
1729                }
1730                RunContent::Break(bt) => match bt {
1731                    BreakType::Line => inline_items.push(InlineItem::LineBreak),
1732                    BreakType::Page => inline_items.push(InlineItem::PageBreak),
1733                    BreakType::Column => inline_items.push(InlineItem::ColumnBreak),
1734                },
1735                RunContent::Drawing(drawing) => {
1736                    if let Some(ref inline) = drawing.inline {
1737                        let width = inline.extent_cx.to_pt();
1738                        let height = inline.extent_cy.to_pt();
1739                        if let Some(relationship_id) = inline.chart_rel_id.as_deref() {
1740                            inline_items.push(InlineItem::Group {
1741                                width,
1742                                height,
1743                                group: render_word_chart(
1744                                    relationship_id,
1745                                    width,
1746                                    height,
1747                                    input,
1748                                    fm,
1749                                    diagnostics,
1750                                )?,
1751                            });
1752                        } else {
1753                            inline_items.push(InlineItem::Image {
1754                                width,
1755                                height,
1756                                media_id: media.id_for_relationship(&inline.embed_id),
1757                            });
1758                        }
1759                    }
1760                }
1761                RunContent::Field(field) => {
1762                    let (computed_value, field_kind) = match field.instruction.name.as_str() {
1763                        "PAGE" => (Some("99".to_owned()), Some(FieldKind::Page)),
1764                        "NUMPAGES" => (Some("99".to_owned()), Some(FieldKind::NumPages)),
1765                        "REF" => {
1766                            let Some(bookmark) = field_text_argument(field, 0) else {
1767                                continue;
1768                            };
1769                            if let Some(text) = bookmark_text(input, bookmark) {
1770                                (Some(text), None)
1771                            } else {
1772                                diagnostics.push(Diagnostic {
1773                                    message: format!(
1774                                        "REF target {bookmark} was not found, stored display retained"
1775                                    ),
1776                                });
1777                                (None, None)
1778                            }
1779                        }
1780                        "PAGEREF" => {
1781                            let Some(bookmark) = field_text_argument(field, 0) else {
1782                                continue;
1783                            };
1784                            if bookmark_text(input, bookmark).is_none() {
1785                                diagnostics.push(Diagnostic {
1786                                    message: format!(
1787                                        "PAGEREF target {bookmark} was not found, stored display retained"
1788                                    ),
1789                                });
1790                                (None, None)
1791                            } else if let Some(target) = page_ref_id(input, bookmark) {
1792                                (Some("99".to_owned()), Some(FieldKind::TargetPage(target)))
1793                            } else {
1794                                (None, None)
1795                            }
1796                        }
1797                        _ => (None, None),
1798                    };
1799                    let stored_segments = field.cached_display_segments();
1800                    let segments = if let Some(value) = computed_value.as_deref() {
1801                        let stored_properties = stored_segments
1802                            .first()
1803                            .and_then(|(_, properties)| *properties);
1804                        vec![(value, stored_properties)]
1805                    } else {
1806                        stored_segments
1807                    };
1808                    for (value, stored_properties) in segments {
1809                        let segment_style_id =
1810                            stored_properties.and_then(|properties| properties.style_id.as_deref());
1811                        let mut segment_rpr = if stored_properties.is_some() {
1812                            style_resolver::resolve_run_properties(
1813                                para_style_id,
1814                                segment_style_id,
1815                                styles,
1816                            )
1817                        } else {
1818                            effective_rpr.clone()
1819                        };
1820                        if let Some(properties) = stored_properties {
1821                            segment_rpr.merge_from(properties);
1822                        }
1823                        if segment_rpr.vanish == Some(true) {
1824                            continue;
1825                        }
1826                        let mut segment_font_size =
1827                            segment_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
1828                        let segment_bold = segment_rpr.bold.unwrap_or(false);
1829                        let segment_italic = segment_rpr.italic.unwrap_or(false);
1830                        let segment_font_family =
1831                            resolve_font_family(&segment_rpr, input.theme.as_ref());
1832                        let segment_color = resolve_run_color(&segment_rpr, input.theme.as_ref());
1833                        let segment_underline = if projected.force_underline {
1834                            Some(Underline::Single)
1835                        } else {
1836                            convert::underline(segment_rpr.underline)
1837                        };
1838                        let segment_strike =
1839                            projected.force_strike || segment_rpr.strike.unwrap_or(false);
1840                        let segment_dstrike = segment_rpr.dstrike.unwrap_or(false);
1841                        let segment_highlight = segment_rpr.highlight.and_then(highlight_to_color);
1842                        let mut segment_baseline_offset = 0.0;
1843                        if let Some(vertical) = segment_rpr.vert_align.as_deref() {
1844                            match vertical {
1845                                "superscript" => {
1846                                    let original_size = segment_font_size;
1847                                    segment_font_size *= 0.58;
1848                                    segment_baseline_offset = original_size * 0.33;
1849                                }
1850                                "subscript" => {
1851                                    let original_size = segment_font_size;
1852                                    segment_font_size *= 0.58;
1853                                    segment_baseline_offset = -(original_size * 0.14);
1854                                }
1855                                _ => {}
1856                            }
1857                        }
1858                        if let Some(position) = segment_rpr.position {
1859                            segment_baseline_offset += position as f64 / 2.0;
1860                        }
1861                        let segment_font_id = fm.resolve_font_for_text(
1862                            segment_font_family.as_deref(),
1863                            segment_bold,
1864                            segment_italic,
1865                            value,
1866                        )?;
1867                        let segment_metrics = fm.metrics(segment_font_id, segment_font_size)?;
1868
1869                        let mut start = 0usize;
1870                        for (index, character) in value
1871                            .char_indices()
1872                            .chain(std::iter::once((value.len(), '\0')))
1873                        {
1874                            let control = match character {
1875                                '\t' => Some(InlineItem::Tab),
1876                                '\n' => Some(InlineItem::LineBreak),
1877                                '\u{000c}' => Some(InlineItem::PageBreak),
1878                                '\u{000b}' => Some(InlineItem::ColumnBreak),
1879                                '\0' if index == value.len() => None,
1880                                _ => continue,
1881                            };
1882                            if start < index {
1883                                let mut text = value[start..index].to_owned();
1884                                if segment_rpr.caps == Some(true) {
1885                                    text = text.to_uppercase();
1886                                }
1887                                let mut shaped =
1888                                    fm.shape_text(segment_font_id, &text, segment_font_size)?;
1889                                if let Some(spacing) = segment_rpr.spacing {
1890                                    let extra = spacing.to_pt();
1891                                    for advance in &mut shaped.advances {
1892                                        *advance += extra;
1893                                    }
1894                                    shaped.width += extra * shaped.advances.len() as f64;
1895                                }
1896                                inline_items.extend(convert::text_segments(TextSegment {
1897                                    text,
1898                                    source: None,
1899                                    font_id: segment_font_id,
1900                                    font_size: segment_font_size,
1901                                    glyph_ids: shaped.glyph_ids,
1902                                    advances: shaped.advances,
1903                                    width: shaped.width,
1904                                    ascent: segment_metrics.ascent,
1905                                    descent: segment_metrics.descent,
1906                                    line_gap: 0.0,
1907                                    color: segment_color,
1908                                    bold: segment_bold,
1909                                    italic: segment_italic,
1910                                    underline: segment_underline,
1911                                    strike: segment_strike,
1912                                    dstrike: segment_dstrike,
1913                                    highlight: segment_highlight,
1914                                    baseline_offset: segment_baseline_offset,
1915                                    hyperlink_url: current_hyperlink_url.clone(),
1916                                    field_kind,
1917                                    note: None,
1918                                }));
1919                            }
1920                            if let Some(control) = control {
1921                                inline_items.push(control);
1922                                start = index + character.len_utf8();
1923                            }
1924                        }
1925                    }
1926                }
1927                RunContent::FootnoteRef { id } | RunContent::EndnoteRef { id } => {
1928                    // The two streams number independently, so the marker has
1929                    // to carry which one it came from.
1930                    let stream = match content {
1931                        RunContent::EndnoteRef { .. } => NoteStream::Endnote,
1932                        _ => NoteStream::Footnote,
1933                    };
1934                    // Render as superscript number
1935                    let marker = id.to_string();
1936                    let sup_size = font_size * 0.58;
1937                    let sup_offset = font_size * 0.33; // raise baseline
1938                    let shaped = fm.shape_text(font_id, &marker, sup_size)?;
1939                    let sup_metrics = fm.metrics(font_id, sup_size)?;
1940                    let revision_marker = input.revision_view == RevisionView::Tracked
1941                        && projected.ordinary_run_index.is_none();
1942                    inline_items.push(InlineItem::Text(TextSegment {
1943                        text: marker,
1944                        source: None,
1945                        font_id,
1946                        font_size: sup_size,
1947                        glyph_ids: shaped.glyph_ids,
1948                        advances: shaped.advances,
1949                        width: shaped.width,
1950                        ascent: sup_metrics.ascent,
1951                        descent: sup_metrics.descent,
1952                        line_gap: 0.0,
1953                        color,
1954                        bold,
1955                        italic,
1956                        underline: revision_marker.then_some(underline).flatten(),
1957                        strike: revision_marker && strike,
1958                        dstrike: revision_marker && dstrike,
1959                        highlight: revision_marker.then_some(highlight).flatten(),
1960                        baseline_offset: sup_offset,
1961                        hyperlink_url: None,
1962                        field_kind: None,
1963                        note: Some(NoteRef { stream, id: *id }),
1964                    }));
1965                }
1966                RunContent::CommentReference { .. } => {}
1967            }
1968        }
1969    }
1970
1971    let final_marker_lower = (marker_boundary == Some(para.runs.len()))
1972        .then_some(marker_raw_before)
1973        .flatten();
1974    push_targeted_bookmark_markers(
1975        &mut inline_items,
1976        para,
1977        para.runs.len(),
1978        final_marker_lower,
1979        RawOrder::AfterRaw,
1980        input,
1981        fm,
1982    )?;
1983
1984    // Line breaking
1985    let line_params = convert::line_break_params(&effective_ppr, available_width);
1986
1987    let mut lines = break_into_lines(&inline_items, &line_params, fm)?;
1988    convert::restore_word_line_heights(&mut lines, &effective_ppr);
1989
1990    let mut result = block::build_paragraph_block(
1991        lines,
1992        space_before,
1993        space_after,
1994        effective_ppr.borders,
1995        shading,
1996        ind_left,
1997        ind_right,
1998        jc,
1999        keep_next,
2000        keep_lines,
2001        page_break_before,
2002        widow_control,
2003    );
2004    result.has_visible_revision =
2005        input.revision_view == RevisionView::Tracked && paragraph_has_visible_revision(para);
2006    result.anchored =
2007        collect_anchored_drawings(para, styles, input, media, fm, num_state, diagnostics)?;
2008    // `inline_items` is finished with here and would otherwise be dropped, so
2009    // handing it to the reflow costs nothing but the memory it already holds.
2010    // `Engine::layout` frees it again unless the document wraps.
2011    result.reflow = Some(Box::new(block::ParagraphReflow {
2012        items: inline_items,
2013        params: line_params,
2014    }));
2015    Ok(result)
2016}
2017
2018fn push_targeted_bookmark_markers(
2019    items: &mut Vec<InlineItem>,
2020    paragraph: &CT_P,
2021    run_index: usize,
2022    after_raw: Option<RawOrder>,
2023    through_raw: RawOrder,
2024    input: &LayoutInput,
2025    fm: &mut FontManager,
2026) -> Result<()> {
2027    let mut font_id = None;
2028    for marker in paragraph.bookmark_markers.iter().filter(|marker| {
2029        marker.is_start()
2030            && marker.run_index() == run_index
2031            && after_raw.is_none_or(|after| RawOrder::Raw(marker.raw_before()) > after)
2032            && RawOrder::Raw(marker.raw_before()) <= through_raw
2033            && marker.name().is_some_and(|name| {
2034                document_has_page_ref(input, name) && bookmark_text(input, name).is_some()
2035            })
2036    }) {
2037        if let Some(target) = marker.name().and_then(|name| page_ref_id(input, name)) {
2038            let resolved_font = match font_id {
2039                Some(font_id) => font_id,
2040                None => {
2041                    let resolved = fm.resolve_font_for_text(None, false, false, " ")?;
2042                    font_id = Some(resolved);
2043                    resolved
2044                }
2045            };
2046            push_bookmark_marker(items, target, resolved_font);
2047        }
2048    }
2049    Ok(())
2050}
2051
2052fn push_bookmark_marker(items: &mut Vec<InlineItem>, target: usize, font_id: oxml_layout::FontId) {
2053    items.push(InlineItem::Text(TextSegment {
2054        text: "\u{2060}".to_owned(),
2055        source: None,
2056        font_id,
2057        font_size: 1.0,
2058        glyph_ids: vec![0],
2059        advances: vec![0.0],
2060        width: 0.0,
2061        ascent: 0.0,
2062        descent: 0.0,
2063        line_gap: 0.0,
2064        color: Color::BLACK,
2065        bold: false,
2066        italic: false,
2067        underline: None,
2068        strike: false,
2069        dstrike: false,
2070        highlight: None,
2071        baseline_offset: 0.0,
2072        hyperlink_url: None,
2073        field_kind: Some(FieldKind::Target(target)),
2074        note: None,
2075    }));
2076}
2077
2078fn page_ref_id(input: &LayoutInput, name: &str) -> Option<usize> {
2079    let mut names = Vec::<&str>::new();
2080    visit_document_paragraphs(input, &mut |paragraph| {
2081        for projected in project_paragraph_runs(paragraph, input.revision_view) {
2082            let run = projected.run;
2083            for content in &run.content {
2084                let RunContent::Field(field) = content else {
2085                    continue;
2086                };
2087                if field.instruction.name != "PAGEREF" {
2088                    continue;
2089                }
2090                let Some(bookmark) = field_text_argument(field, 0) else {
2091                    continue;
2092                };
2093                if !names.contains(&bookmark) {
2094                    names.push(bookmark);
2095                }
2096            }
2097        }
2098    });
2099    names.iter().position(|candidate| *candidate == name)
2100}
2101
2102fn field_text_argument(field: &Field, index: usize) -> Option<&str> {
2103    match field.instruction.arguments.get(index) {
2104        Some(FieldArgument::Text(value)) => Some(value),
2105        Some(FieldArgument::Nested(_)) | None => None,
2106    }
2107}
2108
2109fn document_has_page_ref(input: &LayoutInput, name: &str) -> bool {
2110    page_ref_id(input, name).is_some()
2111}
2112
2113fn visit_document_paragraphs<'a>(input: &'a LayoutInput, visit: &mut impl FnMut(&'a CT_P)) {
2114    for content in &input.document.body.content {
2115        match content {
2116            BodyContent::Paragraph(paragraph) => visit(paragraph),
2117            BodyContent::Table(table) => visit_table_paragraphs(table, visit),
2118            BodyContent::ContentControl(control) => visit_control_paragraphs(control, visit),
2119            BodyContent::RawXml(_) => {}
2120        }
2121    }
2122}
2123
2124fn visit_table_paragraphs<'a>(table: &'a CT_Tbl, visit: &mut impl FnMut(&'a CT_P)) {
2125    for (_, _, control) in &table.content_controls {
2126        visit_control_paragraphs(control, visit);
2127    }
2128    for row in &table.rows {
2129        visit_row_paragraphs(row, visit);
2130    }
2131}
2132
2133fn visit_row_paragraphs<'a>(row: &'a CT_Row, visit: &mut impl FnMut(&'a CT_P)) {
2134    for (_, _, control) in &row.content_controls {
2135        visit_control_paragraphs(control, visit);
2136    }
2137    for cell in &row.cells {
2138        visit_cell_paragraphs(cell, visit);
2139    }
2140}
2141
2142fn visit_cell_paragraphs<'a>(cell: &'a CT_Tc, visit: &mut impl FnMut(&'a CT_P)) {
2143    for content in &cell.content {
2144        match content {
2145            CellContent::Paragraph(paragraph) => visit(paragraph),
2146            CellContent::Table(table) => visit_table_paragraphs(table, visit),
2147            CellContent::ContentControl(control) => visit_control_paragraphs(control, visit),
2148        }
2149    }
2150}
2151
2152fn visit_control_paragraphs<'a>(control: &'a CT_Sdt, visit: &mut impl FnMut(&'a CT_P)) {
2153    for content in &control.content {
2154        match content {
2155            SdtContent::Paragraph(paragraph) => visit(paragraph),
2156            SdtContent::Table(table) => visit_table_paragraphs(table, visit),
2157            SdtContent::Row(row) => visit_row_paragraphs(row, visit),
2158            SdtContent::Cell(cell) => visit_cell_paragraphs(cell, visit),
2159            SdtContent::ContentControl(control) => visit_control_paragraphs(control, visit),
2160            SdtContent::Run(_) | SdtContent::RawXml(_) => {}
2161        }
2162    }
2163}
2164
2165fn bookmark_text(input: &LayoutInput, name: &str) -> Option<String> {
2166    type BodyRunPosition = (usize, usize, RawOrder);
2167    type BookmarkStart<'a> = (Option<&'a str>, BodyRunPosition);
2168
2169    let mut starts: HashMap<i32, Vec<BookmarkStart<'_>>> = HashMap::new();
2170    let mut ends: HashMap<i32, Vec<BodyRunPosition>> = HashMap::new();
2171    for (body_index, content) in input.document.body.content.iter().enumerate() {
2172        let BodyContent::Paragraph(paragraph) = content else {
2173            continue;
2174        };
2175        for marker in &paragraph.bookmark_markers {
2176            let Some(id) = marker.id() else {
2177                continue;
2178            };
2179            if marker.run_index() > paragraph.runs.len() {
2180                return None;
2181            }
2182            let position = (
2183                body_index,
2184                marker.run_index(),
2185                RawOrder::Raw(marker.raw_before()),
2186            );
2187            if marker.is_start() {
2188                starts
2189                    .entry(id)
2190                    .or_default()
2191                    .push((marker.name(), position));
2192            } else {
2193                ends.entry(id).or_default().push(position);
2194            }
2195        }
2196    }
2197    let candidates = starts
2198        .iter()
2199        .filter_map(|(id, starts)| {
2200            let ends = ends.get(id)?;
2201            (starts.len() == 1 && starts[0].0 == Some(name) && ends.len() == 1)
2202                .then_some((starts[0].1, ends[0]))
2203        })
2204        .collect::<Vec<_>>();
2205    if candidates.len() != 1 {
2206        return None;
2207    }
2208    let (start, end) = candidates[0];
2209    if start > end {
2210        return None;
2211    }
2212    let mut parts = Vec::new();
2213    for body_index in start.0..=end.0 {
2214        let BodyContent::Paragraph(paragraph) = &input.document.body.content[body_index] else {
2215            continue;
2216        };
2217        parts.push(
2218            project_paragraph_runs(paragraph, input.revision_view)
2219                .iter()
2220                .filter(|projected| {
2221                    let position = (body_index, projected.boundary, projected.raw_order);
2222                    position >= start && position < end
2223                })
2224                .map(|projected| projected.run.text())
2225                .collect::<String>(),
2226        );
2227    }
2228    Some(parts.join("\n"))
2229}
2230
2231/// Whether any drawing in the document body wraps text around itself.
2232///
2233/// A document without one can never reach the reflow path, so it does not pay
2234/// for it.
2235fn document_has_wrapping_drawing(input: &LayoutInput) -> bool {
2236    fn paragraph_wraps(para: &CT_P, view: RevisionView) -> bool {
2237        project_paragraph_runs(para, view).iter().any(|projected| {
2238            let run = projected.run;
2239            run.content
2240                .iter()
2241                .filter_map(|rc| match rc {
2242                    RunContent::Drawing(d) => Some(d),
2243                    _ => None,
2244                })
2245                .chain(run.alt_drawings.iter())
2246                .any(|drawing| {
2247                    drawing
2248                        .anchor
2249                        .as_ref()
2250                        .is_some_and(|anchor| anchor.wrap != WrapType::None)
2251                })
2252        })
2253    }
2254
2255    input
2256        .document
2257        .body
2258        .content
2259        .iter()
2260        .any(|content| match content {
2261            BodyContent::Paragraph(para) => paragraph_wraps(para, input.revision_view),
2262            BodyContent::Table(table) => table
2263                .rows
2264                .iter()
2265                .flat_map(|row| row.cells.iter())
2266                .flat_map(|cell| cell.content.iter())
2267                .any(|content| match content {
2268                    rdocx_oxml::table::CellContent::Paragraph(para) => {
2269                        paragraph_wraps(para, input.revision_view)
2270                    }
2271                    // A drawing inside a nested table is rare enough that the
2272                    // conservative answer is to look no deeper.
2273                    rdocx_oxml::table::CellContent::Table(_) => false,
2274                    rdocx_oxml::table::CellContent::ContentControl(_) => false,
2275                }),
2276            _ => false,
2277        })
2278}
2279
2280fn render_word_chart(
2281    relationship_id: &str,
2282    width: f64,
2283    height: f64,
2284    input: &LayoutInput,
2285    fm: &mut FontManager,
2286    diagnostics: &mut Vec<Diagnostic>,
2287) -> Result<GroupElement> {
2288    let bounds = Rect {
2289        x: 0.0,
2290        y: 0.0,
2291        width,
2292        height,
2293    };
2294    let rendered = match input.charts.get(relationship_id) {
2295        Some(Ok(chart)) => oxml_chart::render_chart(
2296            &chart.chart,
2297            bounds,
2298            &input.chart_theme,
2299            &input.chart_color_map,
2300            fm,
2301        )
2302        .map_err(|error| error.to_string()),
2303        Some(Err(message)) => Err(message.clone()),
2304        None => Err("relationship was not resolved from the document part".to_owned()),
2305    };
2306    match rendered {
2307        Ok(group) => Ok(group),
2308        Err(detail) => {
2309            diagnostics.push(Diagnostic {
2310                message: format!("Word chart relationship {relationship_id}: {detail}"),
2311            });
2312            oxml_chart::render_chart_placeholder(bounds, fm)
2313                .map_err(|error| oxml_layout::LayoutError::Layout(error.to_string()))
2314        }
2315    }
2316}
2317
2318/// Collect the floating drawings anchored to a paragraph.
2319///
2320/// The offsets stay paired with the frame they are measured from. Resolving
2321/// them here is not possible: a paragraph-relative offset needs the laid-out
2322/// position of the paragraph, which only the paginator knows.
2323///
2324/// A shape's text box is laid out here rather than later, because breaking it
2325/// into lines needs the font manager.
2326fn collect_anchored_drawings(
2327    para: &CT_P,
2328    styles: &CT_Styles,
2329    input: &LayoutInput,
2330    media: &MediaRegistry,
2331    fm: &mut FontManager,
2332    num_state: &mut NumberingState,
2333    diagnostics: &mut Vec<Diagnostic>,
2334) -> Result<Vec<block::AnchoredDrawing>> {
2335    let mut out = Vec::new();
2336
2337    // Drawings written plainly, and drawings recovered from an
2338    // mc:AlternateContent block, are both anchored the same way.
2339    for projected in project_paragraph_runs(para, input.revision_view) {
2340        let run = projected.run;
2341        let plain = run.content.iter().filter_map(|rc| match rc {
2342            RunContent::Drawing(d) => Some(d),
2343            _ => None,
2344        });
2345        for drawing in plain.chain(run.alt_drawings.iter()) {
2346            let Some(anchor) = drawing.anchor.as_ref() else {
2347                continue;
2348            };
2349
2350            // A picture also carries a pic:spPr, so a parsed shape alone does
2351            // not mean this is a shape. An embed id is what makes it a
2352            // picture, and that takes precedence.
2353            let shape = if anchor.embed_id.is_empty() && anchor.chart_rel_id.is_none() {
2354                anchor.shape.as_ref()
2355            } else {
2356                None
2357            };
2358
2359            let content = if let Some(relationship_id) = anchor.chart_rel_id.as_deref() {
2360                block::AnchoredContent::Group(render_word_chart(
2361                    relationship_id,
2362                    anchor.extent_cx.to_pt(),
2363                    anchor.extent_cy.to_pt(),
2364                    input,
2365                    fm,
2366                    diagnostics,
2367                )?)
2368            } else {
2369                match shape {
2370                    Some(shape) => {
2371                        // A shape's text box wraps at the shape width.
2372                        let mut text = Vec::new();
2373                        for p in &shape.text {
2374                            text.push(layout_paragraph(
2375                                p,
2376                                anchor.extent_cx.to_pt(),
2377                                styles,
2378                                input,
2379                                media,
2380                                fm,
2381                                num_state,
2382                                diagnostics,
2383                            )?);
2384                        }
2385                        block::AnchoredContent::Shape {
2386                            preset: block::ShapePreset::from_prst(shape.preset.as_deref()),
2387                            fill: shape.solid_fill.as_deref().map(Color::from_hex),
2388                            text,
2389                        }
2390                    }
2391                    None if anchor.embed_id.is_empty() => continue,
2392                    None => block::AnchoredContent::Image {
2393                        media_id: media.id_for_relationship(&anchor.embed_id),
2394                    },
2395                }
2396            };
2397
2398            out.push(block::AnchoredDrawing {
2399                behind_doc: anchor.behind_doc,
2400                rel_h: anchor.pos_h_relative_from,
2401                off_h: anchor.pos_h_offset.to_pt(),
2402                rel_v: anchor.pos_v_relative_from,
2403                off_v: anchor.pos_v_offset.to_pt(),
2404                width: anchor.extent_cx.to_pt(),
2405                height: anchor.extent_cy.to_pt(),
2406                wrap: anchor.wrap,
2407                dist_top: anchor.dist_t.to_pt(),
2408                dist_bottom: anchor.dist_b.to_pt(),
2409                dist_left: anchor.dist_l.to_pt(),
2410                dist_right: anchor.dist_r.to_pt(),
2411                align_h: anchor.pos_h_align,
2412                align_v: anchor.pos_v_align,
2413                content,
2414            });
2415        }
2416    }
2417    Ok(out)
2418}
2419
2420/// Merge direct paragraph properties (only fields explicitly set in the XML).
2421fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
2422    // Don't merge style_id — that was already used for resolution
2423    if direct.jc.is_some() {
2424        effective.jc = direct.jc;
2425    }
2426    if direct.space_before.is_some() {
2427        effective.space_before = direct.space_before;
2428    }
2429    if direct.space_after.is_some() {
2430        effective.space_after = direct.space_after;
2431    }
2432    if direct.line_spacing.is_some() {
2433        effective.line_spacing = direct.line_spacing;
2434    }
2435    if direct.line_rule.is_some() {
2436        effective.line_rule = direct.line_rule.clone();
2437    }
2438    if direct.ind_left.is_some() {
2439        effective.ind_left = direct.ind_left;
2440    }
2441    if direct.ind_right.is_some() {
2442        effective.ind_right = direct.ind_right;
2443    }
2444    if direct.ind_first_line.is_some() {
2445        effective.ind_first_line = direct.ind_first_line;
2446    }
2447    if direct.ind_hanging.is_some() {
2448        effective.ind_hanging = direct.ind_hanging;
2449    }
2450    if direct.keep_next.is_some() {
2451        effective.keep_next = direct.keep_next;
2452    }
2453    if direct.keep_lines.is_some() {
2454        effective.keep_lines = direct.keep_lines;
2455    }
2456    if direct.page_break_before.is_some() {
2457        effective.page_break_before = direct.page_break_before;
2458    }
2459    if direct.widow_control.is_some() {
2460        effective.widow_control = direct.widow_control;
2461    }
2462    if direct.borders.is_some() {
2463        effective.borders = direct.borders.clone();
2464    }
2465    if direct.tabs.is_some() {
2466        effective.tabs = direct.tabs.clone();
2467    }
2468    if direct.shading.is_some() {
2469        effective.shading = direct.shading.clone();
2470    }
2471    if direct.num_id.is_some() {
2472        effective.num_id = direct.num_id;
2473    }
2474    if direct.num_ilvl.is_some() {
2475        effective.num_ilvl = direct.num_ilvl;
2476    }
2477}
2478
2479/// Convert section properties to page geometry.
2480fn sect_pr_to_geometry(sect_pr: &CT_SectPr) -> PageGeometry {
2481    PageGeometry {
2482        page_width: sect_pr.page_width.map(|t| t.to_pt()).unwrap_or(612.0),
2483        page_height: sect_pr.page_height.map(|t| t.to_pt()).unwrap_or(792.0),
2484        margin_top: sect_pr.margin_top.map(|t| t.to_pt()).unwrap_or(72.0),
2485        margin_right: sect_pr.margin_right.map(|t| t.to_pt()).unwrap_or(72.0),
2486        margin_bottom: sect_pr.margin_bottom.map(|t| t.to_pt()).unwrap_or(72.0),
2487        margin_left: sect_pr.margin_left.map(|t| t.to_pt()).unwrap_or(72.0),
2488        header_distance: sect_pr.header_distance.map(|t| t.to_pt()).unwrap_or(36.0),
2489        footer_distance: sect_pr.footer_distance.map(|t| t.to_pt()).unwrap_or(36.0),
2490    }
2491}
2492
2493fn section_page_number_start(sect_pr: &CT_SectPr) -> Option<usize> {
2494    for raw in &sect_pr.extra_xml {
2495        let Some((name, raw_attributes)) = raw_root_start_tag(raw) else {
2496            continue;
2497        };
2498        let Some(attributes) = parse_raw_attributes(raw_attributes) else {
2499            continue;
2500        };
2501        if xml_local_name(name) != b"pgNumType"
2502            || !raw_name_has_namespace(name, &attributes, rdocx_oxml::namespace::W_NS, false)
2503        {
2504            continue;
2505        }
2506        let (_, value) = attributes.iter().find(|(attribute_name, _)| {
2507            xml_local_name(attribute_name) == b"start"
2508                && raw_name_has_namespace(
2509                    attribute_name,
2510                    &attributes,
2511                    rdocx_oxml::namespace::W_NS,
2512                    true,
2513                )
2514        })?;
2515        return decode_xml_attribute(value)?.parse().ok();
2516    }
2517    None
2518}
2519
2520fn xml_local_name(name: &[u8]) -> &[u8] {
2521    name.rsplit(|byte| *byte == b':').next().unwrap_or(name)
2522}
2523
2524fn raw_root_start_tag(raw: &[u8]) -> Option<(&[u8], &[u8])> {
2525    let mut cursor = 0usize;
2526    while cursor < raw.len() && raw[cursor].is_ascii_whitespace() {
2527        cursor += 1;
2528    }
2529    if raw.get(cursor) != Some(&b'<') {
2530        return None;
2531    }
2532    cursor += 1;
2533    if matches!(raw.get(cursor), Some(b'!' | b'?' | b'/')) {
2534        return None;
2535    }
2536    let name_start = cursor;
2537    while cursor < raw.len()
2538        && !raw[cursor].is_ascii_whitespace()
2539        && !matches!(raw[cursor], b'>' | b'/')
2540    {
2541        cursor += 1;
2542    }
2543    if cursor == name_start {
2544        return None;
2545    }
2546    let name_end = cursor;
2547    let attributes_start = cursor;
2548    let mut quote = None;
2549    while cursor < raw.len() {
2550        match (quote, raw[cursor]) {
2551            (None, b'\'' | b'"') => quote = Some(raw[cursor]),
2552            (Some(expected), found) if expected == found => quote = None,
2553            (None, b'>') => {
2554                return Some((&raw[name_start..name_end], &raw[attributes_start..cursor]));
2555            }
2556            _ => {}
2557        }
2558        cursor += 1;
2559    }
2560    None
2561}
2562
2563fn parse_raw_attributes(attributes: &[u8]) -> Option<Vec<(&[u8], &[u8])>> {
2564    let mut parsed = Vec::new();
2565    let mut cursor = 0usize;
2566    while cursor < attributes.len() {
2567        while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
2568            cursor += 1;
2569        }
2570        if cursor == attributes.len() || attributes[cursor] == b'/' {
2571            break;
2572        }
2573        let name_start = cursor;
2574        while cursor < attributes.len()
2575            && !attributes[cursor].is_ascii_whitespace()
2576            && attributes[cursor] != b'='
2577        {
2578            cursor += 1;
2579        }
2580        let name_end = cursor;
2581        while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
2582            cursor += 1;
2583        }
2584        if attributes.get(cursor) != Some(&b'=') {
2585            cursor = cursor.saturating_add(1);
2586            continue;
2587        }
2588        cursor += 1;
2589        while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
2590            cursor += 1;
2591        }
2592        let quote = *attributes.get(cursor)?;
2593        if !matches!(quote, b'\'' | b'"') {
2594            return None;
2595        }
2596        cursor += 1;
2597        let value_start = cursor;
2598        while cursor < attributes.len() && attributes[cursor] != quote {
2599            cursor += 1;
2600        }
2601        let value_end = cursor;
2602        cursor += 1;
2603        parsed.push((
2604            &attributes[name_start..name_end],
2605            &attributes[value_start..value_end],
2606        ));
2607    }
2608    Some(parsed)
2609}
2610
2611fn raw_name_has_namespace(
2612    name: &[u8],
2613    attributes: &[(&[u8], &[u8])],
2614    expected: &str,
2615    is_attribute: bool,
2616) -> bool {
2617    let prefix = name
2618        .iter()
2619        .rposition(|byte| *byte == b':')
2620        .map(|separator| &name[..separator]);
2621    let namespace = match prefix {
2622        Some(prefix) => attributes.iter().find_map(|(attribute_name, value)| {
2623            attribute_name
2624                .strip_prefix(b"xmlns:")
2625                .is_some_and(|declared| declared == prefix)
2626                .then_some(*value)
2627        }),
2628        None if !is_attribute => attributes
2629            .iter()
2630            .find_map(|(attribute_name, value)| (*attribute_name == b"xmlns").then_some(*value)),
2631        None => None,
2632    };
2633    match namespace {
2634        Some(namespace) => decode_xml_attribute(namespace).is_some_and(|value| value == expected),
2635        None => prefix == Some(b"w".as_slice()) && expected == rdocx_oxml::namespace::W_NS,
2636    }
2637}
2638
2639fn decode_xml_attribute(value: &[u8]) -> Option<String> {
2640    let value = std::str::from_utf8(value).ok()?;
2641    let mut decoded = String::with_capacity(value.len());
2642    let mut cursor = 0usize;
2643    while let Some(relative_start) = value[cursor..].find('&') {
2644        let entity_start = cursor + relative_start;
2645        decoded.push_str(&value[cursor..entity_start]);
2646        let entity_end = entity_start + value[entity_start..].find(';')?;
2647        let entity = &value[entity_start + 1..entity_end];
2648        match entity {
2649            "amp" => decoded.push('&'),
2650            "apos" => decoded.push('\''),
2651            "gt" => decoded.push('>'),
2652            "lt" => decoded.push('<'),
2653            "quot" => decoded.push('"'),
2654            numeric if numeric.starts_with("#x") => {
2655                decoded.push(char::from_u32(
2656                    u32::from_str_radix(&numeric[2..], 16).ok()?,
2657                )?);
2658            }
2659            numeric if numeric.starts_with('#') => {
2660                decoded.push(char::from_u32(numeric[1..].parse().ok()?)?);
2661            }
2662            _ => return None,
2663        }
2664        cursor = entity_end + 1;
2665    }
2666    decoded.push_str(&value[cursor..]);
2667    Some(decoded)
2668}
2669
2670/// Lay out header and footer content (both Default and First-page).
2671fn layout_header_footer(
2672    sect_pr: &CT_SectPr,
2673    input: &LayoutInput,
2674    styles: &CT_Styles,
2675    media: &MediaRegistry,
2676    fm: &mut FontManager,
2677    num_state: &mut NumberingState,
2678    diagnostics: &mut Vec<Diagnostic>,
2679    sources: Option<&SourceRegistry>,
2680) -> Result<Option<HeaderFooterContent>> {
2681    let mut has_content = false;
2682    let mut header_blocks = Vec::new();
2683    let mut footer_blocks = Vec::new();
2684    let mut first_header_blocks = Vec::new();
2685    let mut first_footer_blocks = Vec::new();
2686    let mut even_header_blocks = Vec::new();
2687    let mut even_footer_blocks = Vec::new();
2688    let mut watermark = None;
2689    let mut first_watermark = None;
2690    let mut even_watermark = None;
2691    let even_headers_active = sect_pr
2692        .header_refs
2693        .iter()
2694        .any(|reference| reference.hdr_ftr_type == HdrFtrType::Even);
2695
2696    let geometry = sect_pr_to_geometry(sect_pr);
2697    let width = geometry.content_width();
2698
2699    for href in &sect_pr.header_refs {
2700        let (target_blocks, target_watermark) = match href.hdr_ftr_type {
2701            HdrFtrType::Default => (&mut header_blocks, &mut watermark),
2702            HdrFtrType::First => (&mut first_header_blocks, &mut first_watermark),
2703            HdrFtrType::Even => (&mut even_header_blocks, &mut even_watermark),
2704        };
2705        if let Some(hdr) = input.headers.get(&href.rel_id) {
2706            if target_watermark.is_none()
2707                && let Some(projected) = hdr.watermarks().first()
2708            {
2709                *target_watermark = layout_watermark(
2710                    projected,
2711                    &href.rel_id,
2712                    input,
2713                    media,
2714                    fm,
2715                    geometry,
2716                    diagnostics,
2717                )?;
2718            }
2719            let story = WordStory::Header {
2720                relationship_id: href.rel_id.clone(),
2721            };
2722            for (paragraph_index, para) in hdr.paragraphs.iter().enumerate() {
2723                let source = sources.and_then(|sources| sources.id(&story, &[paragraph_index]));
2724                let block = layout_paragraph_with_source(
2725                    para,
2726                    width,
2727                    styles,
2728                    input,
2729                    media,
2730                    fm,
2731                    num_state,
2732                    diagnostics,
2733                    source,
2734                )?;
2735                target_blocks.push(block);
2736            }
2737            has_content = true;
2738        }
2739    }
2740
2741    for fref in &sect_pr.footer_refs {
2742        let target_blocks = match fref.hdr_ftr_type {
2743            HdrFtrType::Default => &mut footer_blocks,
2744            HdrFtrType::First => &mut first_footer_blocks,
2745            HdrFtrType::Even => &mut even_footer_blocks,
2746        };
2747        if let Some(ftr) = input.footers.get(&fref.rel_id) {
2748            let story = WordStory::Footer {
2749                relationship_id: fref.rel_id.clone(),
2750            };
2751            for (paragraph_index, para) in ftr.paragraphs.iter().enumerate() {
2752                let source = sources.and_then(|sources| sources.id(&story, &[paragraph_index]));
2753                let block = layout_paragraph_with_source(
2754                    para,
2755                    width,
2756                    styles,
2757                    input,
2758                    media,
2759                    fm,
2760                    num_state,
2761                    diagnostics,
2762                    source,
2763                )?;
2764                target_blocks.push(block);
2765            }
2766            has_content = true;
2767        }
2768    }
2769
2770    if has_content {
2771        Ok(Some(HeaderFooterContent {
2772            header_blocks,
2773            footer_blocks,
2774            first_header_blocks,
2775            first_footer_blocks,
2776            even_header_blocks,
2777            even_footer_blocks,
2778            even_headers_active,
2779            watermark,
2780            first_watermark,
2781            even_watermark,
2782        }))
2783    } else {
2784        Ok(None)
2785    }
2786}
2787
2788fn layout_watermark(
2789    watermark: &VmlWatermark,
2790    header_relationship_id: &str,
2791    input: &LayoutInput,
2792    media: &MediaRegistry,
2793    fm: &mut FontManager,
2794    geometry: PageGeometry,
2795    diagnostics: &mut Vec<Diagnostic>,
2796) -> Result<Option<GroupElement>> {
2797    let (width, height, rotation, opacity) = match watermark {
2798        VmlWatermark::Text {
2799            width_pt,
2800            height_pt,
2801            rotation_degrees,
2802            opacity,
2803            ..
2804        }
2805        | VmlWatermark::Image {
2806            width_pt,
2807            height_pt,
2808            rotation_degrees,
2809            opacity,
2810            ..
2811        } => (*width_pt, *height_pt, *rotation_degrees, *opacity),
2812    };
2813    let translate = Transform {
2814        e: geometry.margin_left + (geometry.content_width() - width) / 2.0,
2815        f: geometry.margin_top + (geometry.content_height() - height) / 2.0,
2816        ..Transform::IDENTITY
2817    };
2818    let transform = Transform::rotate_about(rotation, width / 2.0, height / 2.0).then(translate);
2819    let children = match watermark {
2820        VmlWatermark::Text {
2821            text,
2822            color,
2823            font_family,
2824            ..
2825        } => {
2826            let Some(color) = vml_color(color) else {
2827                diagnostics.push(Diagnostic {
2828                    message: format!("VML watermark colour {color:?} is unsupported"),
2829                });
2830                return Ok(None);
2831            };
2832            let estimated = width / (text.chars().count().max(1) as f64 * 0.62);
2833            let font_size = (height * 0.62).min(estimated).max(1.0);
2834            let font_id = fm.resolve_font_for_text(
2835                font_family.as_deref().or(Some("Calibri")),
2836                false,
2837                false,
2838                text,
2839            )?;
2840            let shaped = fm.shape_text(font_id, text, font_size)?;
2841            let metrics = fm.metrics(font_id, font_size)?;
2842            vec![PositionedElement::Text(GlyphRun {
2843                origin: Point {
2844                    x: (width - shaped.width) / 2.0,
2845                    y: (height + metrics.ascent - metrics.descent) / 2.0,
2846                },
2847                font_id,
2848                font_size,
2849                glyph_ids: shaped.glyph_ids,
2850                advances: shaped.advances,
2851                text: text.clone(),
2852                source: None,
2853                color,
2854                bold: false,
2855                italic: false,
2856                field_kind: None,
2857                note: None,
2858            })]
2859        }
2860        VmlWatermark::Image {
2861            relationship_id, ..
2862        } => {
2863            let scoped_id = format!("{header_relationship_id}\0{relationship_id}");
2864            let Some(image) = input.images.get(&scoped_id) else {
2865                diagnostics.push(Diagnostic {
2866                    message: format!(
2867                        "VML watermark image relationship {relationship_id} in header {header_relationship_id} was not resolved"
2868                    ),
2869                });
2870                return Ok(None);
2871            };
2872            let data = image.data.clone();
2873            vec![PositionedElement::Image {
2874                rect: Rect {
2875                    x: 0.0,
2876                    y: 0.0,
2877                    width,
2878                    height,
2879                },
2880                content_type: image.content_type.clone(),
2881                media_id: media.id_for_relationship(&scoped_id),
2882                data,
2883            }]
2884        }
2885    };
2886    Ok(Some(GroupElement {
2887        transform,
2888        clip: None,
2889        opacity,
2890        effects: Vec::new(),
2891        children,
2892    }))
2893}
2894
2895fn vml_color(value: &str) -> Option<Color> {
2896    let normalized = value.trim().to_ascii_lowercase();
2897    let hex = match normalized.as_str() {
2898        "black" => "000000",
2899        "silver" => "c0c0c0",
2900        "gray" | "grey" => "808080",
2901        "white" => "ffffff",
2902        "maroon" => "800000",
2903        "red" => "ff0000",
2904        "purple" => "800080",
2905        "fuchsia" | "magenta" => "ff00ff",
2906        "green" => "008000",
2907        "lime" => "00ff00",
2908        "olive" => "808000",
2909        "yellow" => "ffff00",
2910        "navy" => "000080",
2911        "blue" => "0000ff",
2912        "teal" => "008080",
2913        "aqua" | "cyan" => "00ffff",
2914        _ => normalized.trim_start_matches('#'),
2915    };
2916    (hex.len() == 6 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
2917        .then(|| Color::from_hex(hex))
2918}
2919
2920/// Resolve the effective font family for a run, considering theme fonts.
2921///
2922/// Priority: explicit font_ascii > theme font > None (use default).
2923fn resolve_font_family(
2924    rpr: &rdocx_oxml::properties::CT_RPr,
2925    theme: Option<&rdocx_oxml::theme::Theme>,
2926) -> Option<String> {
2927    // Explicit font name takes priority
2928    if rpr.font_ascii.is_some() {
2929        return rpr.font_ascii.clone();
2930    }
2931
2932    // Resolve theme font reference
2933    if let (Some(theme_ref), Some(theme)) = (&rpr.font_ascii_theme, theme) {
2934        let font = match theme_ref.as_str() {
2935            "majorAscii" | "majorHAnsi" | "majorBidi" | "majorEastAsia" => {
2936                theme.major_font.as_deref()
2937            }
2938            "minorAscii" | "minorHAnsi" | "minorBidi" | "minorEastAsia" => {
2939                theme.minor_font.as_deref()
2940            }
2941            _ => None,
2942        };
2943        if let Some(f) = font {
2944            return Some(f.to_string());
2945        }
2946    }
2947
2948    None
2949}
2950
2951/// Resolve the effective color for a run, considering theme colors.
2952///
2953/// Priority: literal color (non-auto) > theme color > black.
2954fn resolve_run_color(
2955    rpr: &rdocx_oxml::properties::CT_RPr,
2956    theme: Option<&rdocx_oxml::theme::Theme>,
2957) -> Color {
2958    // If theme color is specified, resolve it from the theme
2959    if let Some(ref theme_name) = rpr.color_theme
2960        && let Some(theme) = theme
2961        && let Some(hex) = theme.colors.get(theme_name)
2962    {
2963        return Color::from_hex(hex);
2964    }
2965
2966    // Fall back to literal color value
2967    rpr.color
2968        .as_ref()
2969        .filter(|c| c.as_str() != "auto")
2970        .map(|c| Color::from_hex(c))
2971        .unwrap_or(Color::BLACK)
2972}
2973
2974/// Convert a highlight color enum to an RGBA Color.
2975fn highlight_to_color(h: ST_HighlightColor) -> Option<Color> {
2976    match h {
2977        ST_HighlightColor::None => None,
2978        ST_HighlightColor::Black => Some(Color {
2979            r: 0.0,
2980            g: 0.0,
2981            b: 0.0,
2982            a: 1.0,
2983        }),
2984        ST_HighlightColor::Blue => Some(Color {
2985            r: 0.0,
2986            g: 0.0,
2987            b: 1.0,
2988            a: 1.0,
2989        }),
2990        ST_HighlightColor::Cyan => Some(Color {
2991            r: 0.0,
2992            g: 1.0,
2993            b: 1.0,
2994            a: 1.0,
2995        }),
2996        ST_HighlightColor::DarkBlue => Some(Color {
2997            r: 0.0,
2998            g: 0.0,
2999            b: 0.545,
3000            a: 1.0,
3001        }),
3002        ST_HighlightColor::DarkCyan => Some(Color {
3003            r: 0.0,
3004            g: 0.545,
3005            b: 0.545,
3006            a: 1.0,
3007        }),
3008        ST_HighlightColor::DarkGray => Some(Color {
3009            r: 0.663,
3010            g: 0.663,
3011            b: 0.663,
3012            a: 1.0,
3013        }),
3014        ST_HighlightColor::DarkGreen => Some(Color {
3015            r: 0.0,
3016            g: 0.392,
3017            b: 0.0,
3018            a: 1.0,
3019        }),
3020        ST_HighlightColor::DarkMagenta => Some(Color {
3021            r: 0.545,
3022            g: 0.0,
3023            b: 0.545,
3024            a: 1.0,
3025        }),
3026        ST_HighlightColor::DarkRed => Some(Color {
3027            r: 0.545,
3028            g: 0.0,
3029            b: 0.0,
3030            a: 1.0,
3031        }),
3032        ST_HighlightColor::DarkYellow => Some(Color {
3033            r: 0.545,
3034            g: 0.545,
3035            b: 0.0,
3036            a: 1.0,
3037        }),
3038        ST_HighlightColor::Green => Some(Color {
3039            r: 0.0,
3040            g: 1.0,
3041            b: 0.0,
3042            a: 1.0,
3043        }),
3044        ST_HighlightColor::LightGray => Some(Color {
3045            r: 0.827,
3046            g: 0.827,
3047            b: 0.827,
3048            a: 1.0,
3049        }),
3050        ST_HighlightColor::Magenta => Some(Color {
3051            r: 1.0,
3052            g: 0.0,
3053            b: 1.0,
3054            a: 1.0,
3055        }),
3056        ST_HighlightColor::Red => Some(Color {
3057            r: 1.0,
3058            g: 0.0,
3059            b: 0.0,
3060            a: 1.0,
3061        }),
3062        ST_HighlightColor::White => Some(Color {
3063            r: 1.0,
3064            g: 1.0,
3065            b: 1.0,
3066            a: 1.0,
3067        }),
3068        ST_HighlightColor::Yellow => Some(Color {
3069            r: 1.0,
3070            g: 1.0,
3071            b: 0.0,
3072            a: 1.0,
3073        }),
3074    }
3075}
3076
3077#[cfg(test)]
3078mod tests {
3079    use super::*;
3080    use crate::input::ImageData;
3081    use oxml_layout::MediaId;
3082    use std::collections::HashMap;
3083
3084    #[test]
3085    fn revision_views_project_wrapped_runs_in_document_order() {
3086        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
3087            <w:r><w:t>A</w:t></w:r>
3088            <w:ins w:id="1" w:author="Ada"><w:r><w:t>I1</w:t></w:r><w:del w:id="2" w:author="Ben"><w:r><w:delText>D</w:delText></w:r></w:del><w:r><w:t>I2</w:t></w:r></w:ins>
3089            <w:del w:id="3" w:author="Cy"><w:r><w:delText>X</w:delText></w:r></w:del>
3090            <w:moveFrom w:id="4" w:author="Dee"><w:r><w:t>F</w:t></w:r></w:moveFrom>
3091            <w:moveTo w:id="5" w:author="Eve"><w:r><w:t>T</w:t></w:r></w:moveTo>
3092            <w:r><w:t>Z</w:t></w:r>
3093        </w:p></w:body></w:document>"#;
3094        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3095        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
3096            panic!("expected paragraph");
3097        };
3098
3099        let accepted = project_paragraph_runs(paragraph, RevisionView::Accepted)
3100            .iter()
3101            .map(|projected| projected.run.text())
3102            .collect::<Vec<_>>();
3103        assert_eq!(accepted, ["A", "I1", "I2", "T", "Z"]);
3104
3105        let tracked = project_paragraph_runs(paragraph, RevisionView::Tracked)
3106            .iter()
3107            .map(|projected| projected.run.text())
3108            .collect::<Vec<_>>();
3109        assert_eq!(tracked, ["A", "I1", "D", "I2", "X", "F", "T", "Z"]);
3110    }
3111
3112    #[test]
3113    fn nested_only_revision_wrappers_project_their_visible_runs() {
3114        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
3115            <w:ins w:id="1" w:author="Ada"><w:moveTo w:id="2" w:author="Ben"><w:r><w:t>nested</w:t></w:r></w:moveTo></w:ins>
3116        </w:p></w:body></w:document>"#;
3117        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3118        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
3119            panic!("expected paragraph");
3120        };
3121        for view in [RevisionView::Accepted, RevisionView::Tracked] {
3122            assert_eq!(projected_paragraph_text(paragraph, view), "nested");
3123        }
3124        assert!(paragraph_has_visible_revision(paragraph));
3125    }
3126
3127    #[test]
3128    fn pageref_target_follows_an_earlier_revision_at_the_same_boundary() {
3129        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
3130            <w:ins w:id="1" w:author="Ada"><w:r><w:t>before</w:t></w:r></w:ins>
3131            <w:bookmarkStart w:id="7" w:name="target"/>
3132            <w:fldSimple w:instr=" PAGEREF target "><w:r><w:t>1</w:t></w:r></w:fldSimple>
3133            <w:bookmarkEnd w:id="7"/>
3134        </w:p></w:body></w:document>"#;
3135        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3136        let mut input = make_input_with_text("");
3137        input.document = document;
3138        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
3139            panic!("expected paragraph");
3140        };
3141        let media = MediaRegistry::new(&input.images);
3142        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
3143        let mut numbering = NumberingState::new();
3144        let mut diagnostics = Vec::new();
3145        let block = layout_paragraph(
3146            paragraph,
3147            468.0,
3148            &input.styles,
3149            &input,
3150            &media,
3151            &mut fonts,
3152            &mut numbering,
3153            &mut diagnostics,
3154        )
3155        .expect("paragraph lays out");
3156        let items = &block.reflow.expect("reflow items retained").items;
3157        let revision_index = items
3158            .iter()
3159            .position(|item| matches!(item, InlineItem::Text(text) if text.text == "before"))
3160            .expect("revision text");
3161        let target_index = items
3162            .iter()
3163            .position(|item| {
3164                matches!(item, InlineItem::Text(text) if matches!(text.field_kind, Some(FieldKind::Target(_))))
3165            })
3166            .expect("PAGEREF target");
3167        assert!(revision_index < target_index);
3168    }
3169
3170    #[test]
3171    fn derived_revision_text_uses_the_selected_projection() {
3172        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
3173            <w:bookmarkStart w:id="7" w:name="target"/>
3174            <w:ins w:id="1" w:author="Ada"><w:r><w:t>new</w:t></w:r></w:ins>
3175            <w:del w:id="2" w:author="Ben"><w:r><w:delText>old</w:delText></w:r></w:del>
3176            <w:bookmarkEnd w:id="7"/>
3177        </w:p></w:body></w:document>"#;
3178        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3179        let mut input = make_input_with_text("");
3180        input.document = document;
3181
3182        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("new"));
3183        input.revision_view = RevisionView::Tracked;
3184        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("newold"));
3185    }
3186
3187    #[test]
3188    fn bookmark_after_a_terminal_hyperlink_revision_excludes_that_revision() {
3189        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p>
3190            <w:hyperlink r:id="rId1"><w:r><w:t>link</w:t></w:r><w:ins w:id="1" w:author="Ada"><w:r><w:t>before bookmark</w:t></w:r></w:ins></w:hyperlink>
3191            <w:bookmarkStart w:id="7" w:name="target"/><w:r><w:t>inside</w:t></w:r><w:bookmarkEnd w:id="7"/>
3192        </w:p></w:body></w:document>"#;
3193        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3194        let mut input = make_input_with_text("");
3195        input.document = document;
3196
3197        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("inside"));
3198    }
3199
3200    #[test]
3201    fn revision_only_hyperlink_keeps_its_link_annotation() {
3202        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p>
3203            <w:hyperlink r:id="rId1"><w:ins w:id="1" w:author="Ada"><w:r><w:t>linked revision</w:t></w:r></w:ins></w:hyperlink>
3204        </w:p></w:body></w:document>"#;
3205        let mut document =
3206            rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3207        let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
3208            panic!("expected paragraph");
3209        };
3210        paragraph.hyperlinks[0].rel_id = Some("rId2".to_owned());
3211        let serialized = String::from_utf8(document.to_xml().expect("document serializes"))
3212            .expect("document XML is UTF-8");
3213        assert!(serialized.contains("r:id=\"rId2\""), "{serialized}");
3214        assert!(!serialized.contains("r:id=\"rId1\""), "{serialized}");
3215        let mut input = make_input_with_text("");
3216        input.document = document;
3217        input
3218            .hyperlink_urls
3219            .insert("rId2".to_owned(), "https://example.com".to_owned());
3220
3221        let output = Engine::new_deterministic()
3222            .expect("bundled fonts load")
3223            .layout(&input)
3224            .expect("revision hyperlink lays out");
3225        assert!(output.pages[0].elements.iter().any(|element| {
3226            matches!(element, PositionedElement::LinkAnnotation { url, .. }
3227                if url == "https://example.com")
3228        }));
3229    }
3230
3231    #[test]
3232    fn derived_revision_text_keeps_order_after_comment_run_removal() {
3233        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
3234            <w:bookmarkStart w:id="7" w:name="target"/>
3235            <w:commentRangeStart w:id="5"/><w:r><w:commentReference w:id="5"/></w:r>
3236            <w:ins w:id="1" w:author="Ada"><w:r><w:t>inside</w:t></w:r></w:ins>
3237            <w:bookmarkEnd w:id="7"/>
3238        </w:p></w:body></w:document>"#;
3239        let mut document =
3240            rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3241        let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
3242            panic!("expected paragraph");
3243        };
3244        paragraph.remove_comment_anchors(&[5]);
3245        let mut input = make_input_with_text("");
3246        input.document = document;
3247
3248        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("inside"));
3249    }
3250
3251    #[test]
3252    fn heading_text_uses_the_selected_revision_projection() {
3253        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
3254            <w:ins w:id="1" w:author="Ada"><w:r><w:t>new</w:t></w:r></w:ins>
3255            <w:del w:id="2" w:author="Ben"><w:r><w:delText>old</w:delText></w:r></w:del>
3256        </w:p></w:body></w:document>"#;
3257        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3258        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
3259            panic!("expected paragraph");
3260        };
3261        assert_eq!(
3262            projected_paragraph_text(paragraph, RevisionView::Accepted),
3263            "new"
3264        );
3265        assert_eq!(
3266            projected_paragraph_text(paragraph, RevisionView::Tracked),
3267            "newold"
3268        );
3269    }
3270
3271    #[test]
3272    fn revised_floating_anchors_follow_the_selected_projection() {
3273        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3274            xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
3275            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
3276            xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><w:body><w:p>
3277            <w:ins w:id="1" w:author="Ada"><w:r><w:drawing><wp:anchor behindDoc="0">
3278              <wp:positionH relativeFrom="margin"><wp:align>right</wp:align></wp:positionH>
3279              <wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>
3280              <wp:extent cx="914400" cy="457200"/><wp:wrapSquare wrapText="bothSides"/>
3281              <a:graphic><a:graphicData><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic>
3282            </wp:anchor></w:drawing></w:r></w:ins>
3283        </w:p></w:body></w:document>"#;
3284        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3285        let mut input = make_input_with_text("");
3286        input.document = document;
3287        assert!(document_has_wrapping_drawing(&input));
3288
3289        input.revision_view = RevisionView::Tracked;
3290        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
3291            panic!("expected paragraph");
3292        };
3293        let media = MediaRegistry::new(&input.images);
3294        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
3295        let mut numbering = NumberingState::new();
3296        let mut diagnostics = Vec::new();
3297        let anchored = collect_anchored_drawings(
3298            paragraph,
3299            &input.styles,
3300            &input,
3301            &media,
3302            &mut fonts,
3303            &mut numbering,
3304            &mut diagnostics,
3305        )
3306        .expect("tracked anchor collection succeeds");
3307        assert_eq!(anchored.len(), 1);
3308    }
3309
3310    #[test]
3311    fn tracked_revision_decorations_override_only_underline_and_strike() {
3312        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
3313            <w:ins w:id="1" w:author="Ada"><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans"/><w:b/><w:i/><w:u w:val="double"/><w:dstrike/><w:color w:val="AA0000"/><w:highlight w:val="yellow"/></w:rPr><w:t>inserted</w:t></w:r></w:ins>
3314            <w:del w:id="2" w:author="Ben"><w:r><w:rPr><w:u w:val="double"/><w:color w:val="0000AA"/></w:rPr><w:delText>deleted</w:delText></w:r></w:del>
3315            <w:ins w:id="3" w:author="Ada"><w:r><w:rPr><w:highlight w:val="yellow"/></w:rPr><w:footnoteReference w:id="11"/></w:r></w:ins>
3316            <w:del w:id="4" w:author="Ben"><w:r><w:endnoteReference w:id="12"/></w:r></w:del>
3317        </w:p></w:body></w:document>"#;
3318        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3319        let mut input = make_input_with_text("");
3320        input.document = document;
3321        input.revision_view = RevisionView::Tracked;
3322        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
3323            panic!("expected paragraph");
3324        };
3325        let media = MediaRegistry::new(&input.images);
3326        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
3327        let mut numbering = NumberingState::new();
3328        let mut diagnostics = Vec::new();
3329        let block = layout_paragraph(
3330            paragraph,
3331            468.0,
3332            &input.styles,
3333            &input,
3334            &media,
3335            &mut fonts,
3336            &mut numbering,
3337            &mut diagnostics,
3338        )
3339        .expect("tracked paragraph lays out");
3340        let segments = block
3341            .lines
3342            .iter()
3343            .flat_map(|line| &line.items)
3344            .filter_map(|item| match item {
3345                oxml_layout::LineItem::Text(segment) => Some(segment),
3346                _ => None,
3347            })
3348            .collect::<Vec<_>>();
3349        let inserted = segments
3350            .iter()
3351            .find(|segment| segment.text == "inserted")
3352            .expect("inserted segment");
3353        assert_eq!(inserted.underline, Some(Underline::Single));
3354        assert!(!inserted.strike);
3355        assert!(inserted.dstrike);
3356        assert!(inserted.bold && inserted.italic);
3357        assert_eq!(inserted.color, Color::from_hex("AA0000"));
3358        assert_eq!(inserted.highlight, Some(Color::from_hex("FFFF00")));
3359
3360        let deleted = segments
3361            .iter()
3362            .find(|segment| segment.text == "deleted")
3363            .expect("deleted segment");
3364        assert_eq!(deleted.underline, Some(Underline::Double));
3365        assert!(deleted.strike);
3366        assert_eq!(deleted.color, Color::from_hex("0000AA"));
3367
3368        let inserted_note = segments
3369            .iter()
3370            .find(|segment| segment.text == "11")
3371            .expect("inserted note marker");
3372        assert_eq!(inserted_note.underline, Some(Underline::Single));
3373        assert_eq!(inserted_note.highlight, Some(Color::from_hex("FFFF00")));
3374        let deleted_note = segments
3375            .iter()
3376            .find(|segment| segment.text == "12")
3377            .expect("deleted note marker");
3378        assert!(deleted_note.strike);
3379
3380        let mut accepted_input = input.clone();
3381        accepted_input.revision_view = RevisionView::Accepted;
3382        let BodyContent::Paragraph(accepted_paragraph) = &accepted_input.document.body.content[0]
3383        else {
3384            panic!("expected paragraph");
3385        };
3386        let accepted_media = MediaRegistry::new(&accepted_input.images);
3387        let accepted_block = layout_paragraph(
3388            accepted_paragraph,
3389            468.0,
3390            &accepted_input.styles,
3391            &accepted_input,
3392            &accepted_media,
3393            &mut fonts,
3394            &mut numbering,
3395            &mut diagnostics,
3396        )
3397        .expect("accepted paragraph lays out");
3398        let accepted_note = accepted_block
3399            .lines
3400            .iter()
3401            .flat_map(|line| &line.items)
3402            .filter_map(|item| match item {
3403                oxml_layout::LineItem::Text(segment) if segment.text == "11" => Some(segment),
3404                _ => None,
3405            })
3406            .next()
3407            .expect("accepted note marker");
3408        assert_eq!(accepted_note.underline, None);
3409        assert!(!accepted_note.strike && !accepted_note.dstrike);
3410        assert_eq!(accepted_note.highlight, None);
3411    }
3412
3413    #[test]
3414    fn a_split_changed_paragraph_draws_one_margin_bar_on_each_page() {
3415        let changed = "changed ".repeat(3_000);
3416        let xml = format!(
3417            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:ins w:id="1" w:author="Ada"><w:r><w:t xml:space="preserve">{changed}</w:t></w:r></w:ins></w:p></w:body></w:document>"#
3418        );
3419        let document =
3420            rdocx_oxml::CT_Document::from_xml(xml.as_bytes()).expect("revision document parses");
3421        let mut input = make_input_with_text("");
3422        input.document = document;
3423        let accepted = Engine::new_deterministic()
3424            .expect("bundled fonts load")
3425            .layout(&input)
3426            .expect("accepted document lays out");
3427        input.revision_view = RevisionView::Tracked;
3428        let output = Engine::new_deterministic()
3429            .expect("bundled fonts load")
3430            .layout(&input)
3431            .expect("tracked document lays out");
3432        let geometry = PageGeometry::default();
3433        assert!(output.pages.len() > 1);
3434        assert_eq!(accepted.pages.len(), output.pages.len());
3435        for (accepted_page, page) in accepted.pages.iter().zip(&output.pages) {
3436            let accepted_text = accepted_page
3437                .elements
3438                .iter()
3439                .filter_map(|element| match element {
3440                    PositionedElement::Text(text) => Some(text),
3441                    _ => None,
3442                })
3443                .collect::<Vec<_>>();
3444            let tracked_text = page
3445                .elements
3446                .iter()
3447                .filter_map(|element| match element {
3448                    PositionedElement::Text(text) => Some(text),
3449                    _ => None,
3450                })
3451                .collect::<Vec<_>>();
3452            assert_eq!(accepted_text, tracked_text);
3453            let bars = page
3454                .elements
3455                .iter()
3456                .filter_map(|element| match element {
3457                    PositionedElement::Line {
3458                        start,
3459                        end,
3460                        width,
3461                        dash_pattern,
3462                        ..
3463                    } if (*width - 1.5).abs() < f64::EPSILON
3464                        && dash_pattern.is_none()
3465                        && start.x == end.x
3466                        && (start.x < geometry.margin_left
3467                            || start.x > geometry.page_width - geometry.margin_right) =>
3468                    {
3469                        Some((*start, *end))
3470                    }
3471                    _ => None,
3472                })
3473                .collect::<Vec<_>>();
3474            assert_eq!(bars.len(), 1, "page {}", page.page_number);
3475            let (start, end) = bars[0];
3476            assert!(start.x.is_finite() && start.y.is_finite() && end.y.is_finite());
3477            assert!(end.y > start.y);
3478            if page.page_number.is_multiple_of(2) {
3479                assert!(start.x < geometry.margin_left);
3480            } else {
3481                assert!(start.x > geometry.page_width - geometry.margin_right);
3482            }
3483        }
3484    }
3485
3486    fn page_change_bar_count(page: &PageFrame) -> usize {
3487        let geometry = PageGeometry::default();
3488        page.elements
3489            .iter()
3490            .filter(|element| {
3491                matches!(element, PositionedElement::Line { start, end, width, .. }
3492                    if (*width - 1.5).abs() < f64::EPSILON
3493                        && start.x == end.x
3494                        && (start.x < geometry.margin_left
3495                            || start.x > geometry.page_width - geometry.margin_right))
3496            })
3497            .count()
3498    }
3499
3500    #[test]
3501    fn tracked_header_paragraph_draws_a_change_bar() {
3502        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
3503
3504        let mut input = make_input_with_text("body");
3505        input.revision_view = RevisionView::Tracked;
3506        input.document.body.sect_pr = Some(CT_SectPr::default_letter());
3507        input
3508            .document
3509            .body
3510            .sect_pr
3511            .as_mut()
3512            .expect("section properties")
3513            .header_refs
3514            .push(HdrFtrRef {
3515                hdr_ftr_type: HdrFtrType::Default,
3516                rel_id: "rIdHeader".to_owned(),
3517            });
3518        let header = CT_HdrFtr::from_xml(
3519            br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:ins w:id="1" w:author="Ada"><w:r><w:t>changed header</w:t></w:r></w:ins></w:p></w:hdr>"#,
3520        )
3521        .expect("header parses");
3522        input.headers.insert("rIdHeader".to_owned(), header);
3523
3524        let output = Engine::new_deterministic()
3525            .expect("bundled fonts load")
3526            .layout(&input)
3527            .expect("tracked header lays out");
3528        assert_eq!(page_change_bar_count(&output.pages[0]), 1);
3529    }
3530
3531    #[test]
3532    fn tracked_note_paragraph_draws_a_change_bar() {
3533        let mut input = make_input_with_footnote(&["plain"]);
3534        input.revision_view = RevisionView::Tracked;
3535        let changed_note = rdocx_oxml::CT_Document::from_xml(
3536            br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:ins w:id="1" w:author="Ada"><w:r><w:t>added note</w:t></w:r></w:ins><w:del w:id="2" w:author="Ben"><w:r><w:delText>removed note</w:delText></w:r></w:del></w:p></w:body></w:document>"#,
3537        )
3538        .expect("note paragraph parses");
3539        let BodyContent::Paragraph(paragraph) = &changed_note.body.content[0] else {
3540            panic!("expected paragraph");
3541        };
3542        input.footnotes.as_mut().expect("footnote stream").footnotes[0].paragraphs =
3543            vec![paragraph.clone()];
3544
3545        let output = Engine::new_deterministic()
3546            .expect("bundled fonts load")
3547            .layout(&input)
3548            .expect("tracked note lays out");
3549        assert_eq!(page_change_bar_count(&output.pages[0]), 1);
3550        let decoration_widths = output.pages[0]
3551            .elements
3552            .iter()
3553            .filter_map(|element| match element {
3554                PositionedElement::Line {
3555                    start, end, width, ..
3556                } if start.y == end.y && (*width - 0.5).abs() > f64::EPSILON => Some(*width),
3557                _ => None,
3558            })
3559            .collect::<Vec<_>>();
3560        assert!(
3561            decoration_widths
3562                .iter()
3563                .any(|width| (*width - 11.0 / 18.0).abs() < 0.001),
3564            "tracked insertion underline missing: {decoration_widths:?}"
3565        );
3566        assert!(
3567            decoration_widths
3568                .iter()
3569                .any(|width| (*width - 11.0 / 24.0).abs() < 0.001),
3570            "tracked deletion strike missing: {decoration_widths:?}"
3571        );
3572    }
3573
3574    #[test]
3575    fn property_only_revisions_mark_the_tracked_paragraph() {
3576        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:pPrChange w:id="1" w:author="Ada"><w:pPr><w:jc w:val="right"/></w:pPr></w:pPrChange></w:pPr><w:r><w:t>current</w:t></w:r></w:p></w:body></w:document>"#;
3577        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3578        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
3579            panic!("expected paragraph");
3580        };
3581        assert!(paragraph_has_visible_revision(paragraph));
3582    }
3583
3584    #[test]
3585    fn empty_revision_wrappers_do_not_mark_the_tracked_paragraph() {
3586        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:ins w:id="1" w:author="Ada"/></w:p><w:p><w:ins w:id="2" w:author="Ben"><w:r><w:t/></w:r></w:ins></w:p></w:body></w:document>"#;
3587        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
3588        for content in &document.body.content {
3589            let BodyContent::Paragraph(paragraph) = content else {
3590                panic!("expected paragraph");
3591            };
3592            assert!(!paragraph_has_visible_revision(paragraph));
3593        }
3594    }
3595
3596    fn make_input_with_text(text: &str) -> LayoutInput {
3597        let mut doc = rdocx_oxml::document::CT_Document::new();
3598        let mut p = CT_P::new();
3599        p.add_run(text);
3600        doc.body.add_paragraph(p);
3601
3602        LayoutInput {
3603            revision_view: crate::input::RevisionView::Accepted,
3604            document: doc,
3605            styles: CT_Styles::new_default(),
3606            numbering: None,
3607            headers: HashMap::new(),
3608            footers: HashMap::new(),
3609            images: HashMap::new(),
3610            charts: HashMap::new(),
3611            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
3612            chart_color_map: oxml_drawing::color::ColorMap::default(),
3613            core_properties: None,
3614            hyperlink_urls: HashMap::new(),
3615            footnotes: None,
3616            endnotes: None,
3617            theme: None,
3618            fonts: Vec::new(),
3619        }
3620    }
3621
3622    #[test]
3623    fn warm_relayout_matches_cold_and_rebuilds_only_changed_safe_paragraphs() {
3624        let mut input = make_input_with_text("first cache-safe paragraph");
3625        for text in ["second cache-safe paragraph", "third cache-safe paragraph"] {
3626            let mut paragraph = CT_P::new();
3627            paragraph.add_run(text);
3628            input.document.body.add_paragraph(paragraph);
3629        }
3630
3631        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
3632        let cold = warm_engine
3633            .layout_with_provenance(&input)
3634            .expect("cold layout succeeds");
3635        let after_cold = warm_engine.paragraph_cache_counts();
3636
3637        let BodyContent::Paragraph(changed) = &mut input.document.body.content[1] else {
3638            panic!("second body item is a paragraph");
3639        };
3640        changed.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
3641            "changed cache-safe paragraph",
3642        ))];
3643
3644        let warm = warm_engine
3645            .layout_with_provenance(&input)
3646            .expect("warm relayout succeeds");
3647        let after_warm = warm_engine.paragraph_cache_counts();
3648        let cold_after_edit = Engine::new_deterministic()
3649            .expect("bundled fonts load")
3650            .layout_with_provenance(&input)
3651            .expect("independent cold relayout succeeds");
3652
3653        assert_eq!(format!("{:?}", warm.0), format!("{:?}", cold_after_edit.0));
3654        assert_eq!(warm.1, cold_after_edit.1);
3655        assert_eq!(after_cold, (0, 3));
3656        assert_eq!(after_warm, (2, 4));
3657        assert_ne!(output_text(&cold.0), output_text(&warm.0));
3658    }
3659
3660    #[test]
3661    fn warm_relayout_rebinds_font_tables_and_ids_to_the_current_result() {
3662        let mut input = make_input_with_text("font identity changes");
3663        {
3664            let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
3665                panic!("body paragraph");
3666            };
3667            paragraph.runs[0]
3668                .properties
3669                .get_or_insert_default()
3670                .font_ascii = Some("Carlito".to_owned());
3671        }
3672
3673        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
3674        warm_engine.layout(&input).expect("prime warm font state");
3675        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
3676            panic!("body paragraph");
3677        };
3678        paragraph.runs[0]
3679            .properties
3680            .get_or_insert_default()
3681            .font_ascii = Some("Caladea".to_owned());
3682
3683        let warm = warm_engine.layout(&input).expect("warm relayout succeeds");
3684        let cold = Engine::new_deterministic()
3685            .expect("bundled fonts load")
3686            .layout(&input)
3687            .expect("cold relayout succeeds");
3688        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
3689        assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", cold.fonts));
3690    }
3691
3692    #[test]
3693    fn warm_relayout_canonicalizes_the_same_fonts_in_new_resolution_order() {
3694        let mut input = make_input_with_text("first family");
3695        let BodyContent::Paragraph(first) = &mut input.document.body.content[0] else {
3696            panic!("body paragraph");
3697        };
3698        first.runs[0].properties.get_or_insert_default().font_ascii = Some("Carlito".to_owned());
3699        let mut second = CT_P::new();
3700        second
3701            .add_run("second family")
3702            .properties
3703            .get_or_insert_default()
3704            .font_ascii = Some("Caladea".to_owned());
3705        input.document.body.add_paragraph(second);
3706
3707        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
3708        warm_engine.layout(&input).expect("prime original order");
3709        input.document.body.content.swap(0, 1);
3710
3711        let warm = warm_engine.layout(&input).expect("warm reordered layout");
3712        let cold = Engine::new_deterministic()
3713            .expect("bundled fonts load")
3714            .layout(&input)
3715            .expect("cold reordered layout");
3716        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
3717        assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", cold.fonts));
3718    }
3719
3720    #[test]
3721    fn shared_layout_context_changes_cannot_serve_stale_blocks() {
3722        let mut input = make_input_with_text("context-sensitive cache identity");
3723        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
3724        warm_engine.layout(&input).expect("prime context cache");
3725
3726        let normal = input
3727            .styles
3728            .styles
3729            .iter_mut()
3730            .find(|style| style.is_default)
3731            .expect("default style");
3732        normal.rpr.get_or_insert_default().font_ascii = Some("Caladea".to_owned());
3733        let warm = warm_engine.layout(&input).expect("warm style mutation");
3734        let cold = Engine::new_deterministic()
3735            .expect("bundled fonts load")
3736            .layout(&input)
3737            .expect("cold style mutation");
3738        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
3739
3740        input.numbering = Some(rdocx_oxml::numbering::CT_Numbering::new());
3741        let warm = warm_engine.layout(&input).expect("warm numbering mutation");
3742        let cold = Engine::new_deterministic()
3743            .expect("bundled fonts load")
3744            .layout(&input)
3745            .expect("cold numbering mutation");
3746        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
3747
3748        input.theme = Some(rdocx_oxml::theme::Theme::default());
3749        let warm = warm_engine.layout(&input).expect("warm theme mutation");
3750        let cold = Engine::new_deterministic()
3751            .expect("bundled fonts load")
3752            .layout(&input)
3753            .expect("cold theme mutation");
3754        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
3755
3756        input
3757            .hyperlink_urls
3758            .insert("rIdLink".to_owned(), "https://example.com".to_owned());
3759        input.images.insert(
3760            "rIdImage".to_owned(),
3761            crate::input::ImageData {
3762                data: vec![1, 2, 3],
3763                content_type: "image/png".to_owned(),
3764            },
3765        );
3766        let warm = warm_engine
3767            .layout(&input)
3768            .expect("warm relationship and image mutation");
3769        let cold = Engine::new_deterministic()
3770            .expect("bundled fonts load")
3771            .layout(&input)
3772            .expect("cold relationship and image mutation");
3773        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
3774
3775        input.fonts.push(oxml_layout::FontFile {
3776            family: "Embedded".to_owned(),
3777            data: oxml_layout::bundled_fonts::bundled_font_data()[0]
3778                .1
3779                .to_vec(),
3780        });
3781        let warm = warm_engine.layout(&input).expect("warm font mutation");
3782        let cold = Engine::new_deterministic()
3783            .expect("bundled fonts load")
3784            .layout(&input)
3785            .expect("cold font mutation");
3786        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
3787
3788        let contextual = rdocx_oxml::CT_Document::from_xml(
3789            br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:hyperlink r:id="rIdLink"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p><w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:body></w:document>"#,
3790        )
3791        .expect("contextual paragraphs parse");
3792        for content in &contextual.body.content {
3793            let BodyContent::Paragraph(paragraph) = content else {
3794                continue;
3795            };
3796            assert!(!paragraph_is_cache_safe(paragraph, &input.styles));
3797        }
3798    }
3799
3800    #[test]
3801    fn alternate_content_drawings_bypass_paragraph_reuse() {
3802        let document = rdocx_oxml::CT_Document::from_xml(
3803            br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><w:body><w:p><w:r><w:t>ordinary text</w:t></w:r><w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing><wp:anchor behindDoc="0"><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><a:graphic><a:graphicData><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice></mc:AlternateContent></w:r></w:p></w:body></w:document>"#,
3804        )
3805        .expect("AlternateContent drawing parses");
3806        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
3807            panic!("body paragraph");
3808        };
3809        assert!(!paragraph.runs[1].alt_drawings.is_empty());
3810        assert!(!paragraph_is_cache_safe(
3811            paragraph,
3812            &CT_Styles::new_default()
3813        ));
3814    }
3815
3816    #[test]
3817    fn warm_provenance_rebinds_to_current_word_source_nodes() {
3818        let mut input = make_input_with_text("first paragraph");
3819        for text in ["second paragraph", "third paragraph"] {
3820            let mut paragraph = CT_P::new();
3821            paragraph.add_run(text);
3822            input.document.body.add_paragraph(paragraph);
3823        }
3824        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
3825        engine
3826            .layout_with_provenance(&input)
3827            .expect("prime paragraph cache");
3828
3829        let moved = input.document.body.content.remove(2);
3830        input.document.body.content.insert(0, moved);
3831        let mut inserted = CT_P::new();
3832        inserted.add_run("new paragraph");
3833        input
3834            .document
3835            .body
3836            .content
3837            .insert(1, BodyContent::Paragraph(inserted));
3838        let (layout, sources) = engine
3839            .layout_with_provenance(&input)
3840            .expect("warm provenance layout");
3841
3842        for page in &layout.pages {
3843            oxml_layout::walk(&page.elements, &mut |element, _| {
3844                let PositionedElement::Text(run) = element else {
3845                    return;
3846                };
3847                let Some(span) = run.source else {
3848                    return;
3849                };
3850                let path = &sources[span.node.get() as usize - 1];
3851                assert_eq!(path.story, WordStory::Document);
3852                let BodyContent::Paragraph(paragraph) =
3853                    &input.document.body.content[path.children[0]]
3854                else {
3855                    panic!("source path resolves to a body paragraph");
3856                };
3857                let text = paragraph.text();
3858                let resolved = text
3859                    .chars()
3860                    .skip(span.char_start as usize)
3861                    .take((span.char_end - span.char_start) as usize)
3862                    .collect::<String>();
3863                assert_eq!(resolved, run.text);
3864            });
3865        }
3866        assert_eq!(engine.paragraph_cache_counts(), (3, 4));
3867    }
3868
3869    #[test]
3870    fn cold_and_warm_diagnostics_are_identical() {
3871        let mut input = make_input_with_text("");
3872        input.document = rdocx_oxml::CT_Document::from_xml(
3873            br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>cache-safe prefix</w:t></w:r></w:p><w:p><w:fldSimple w:instr="REF missing"><w:r><w:t>stored</w:t></w:r></w:fldSimple></w:p></w:body></w:document>"#,
3874        )
3875        .expect("diagnostic document parses");
3876        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
3877        let cold = engine.layout(&input).expect("cold layout succeeds");
3878        let warm = engine.layout(&input).expect("warm layout succeeds");
3879        assert!(!cold.diagnostics.is_empty());
3880        assert_eq!(cold.diagnostics, warm.diagnostics);
3881        assert_eq!(engine.paragraph_cache_counts(), (1, 1));
3882
3883        let (valid_family, valid_bytes) = oxml_layout::bundled_fonts::bundled_font_data()[0];
3884        let (invalid_family, invalid_source) = oxml_layout::bundled_fonts::bundled_font_data()[4];
3885        let mut invalid_bytes = invalid_source.to_vec();
3886        let table_count = u16::from_be_bytes([invalid_bytes[4], invalid_bytes[5]]) as usize;
3887        let head_offset = (0..table_count)
3888            .find_map(|table| {
3889                let record = 12 + table * 16;
3890                (&invalid_bytes[record..record + 4] == b"head").then(|| {
3891                    u32::from_be_bytes(
3892                        invalid_bytes[record + 8..record + 12]
3893                            .try_into()
3894                            .expect("head offset"),
3895                    ) as usize
3896                })
3897            })
3898            .expect("font has head table");
3899        invalid_bytes[head_offset + 18..head_offset + 20].copy_from_slice(&0u16.to_be_bytes());
3900
3901        let mut failing = Engine::with_font_manager(FontManager::new_with_fonts(vec![(
3902            valid_family.to_owned(),
3903            valid_bytes.to_vec(),
3904        )]));
3905        let mut failing_input = make_input_with_text("cache-safe successful prefix");
3906        let BodyContent::Paragraph(prefix) = &mut failing_input.document.body.content[0] else {
3907            panic!("prefix paragraph");
3908        };
3909        prefix.runs[0].properties.get_or_insert_default().font_ascii =
3910            Some(valid_family.to_owned());
3911        let mut later = CT_P::new();
3912        later
3913            .add_run("late font failure")
3914            .properties
3915            .get_or_insert_default()
3916            .font_ascii = Some(invalid_family.to_owned());
3917        failing_input.document.body.add_paragraph(later);
3918        failing_input.fonts.push(oxml_layout::FontFile {
3919            family: invalid_family.to_owned(),
3920            data: invalid_bytes,
3921        });
3922        assert!(failing.layout(&failing_input).is_err());
3923        assert!(failing.paragraph_cache.is_empty());
3924        assert_eq!(failing.paragraph_cache_counts(), (0, 1));
3925    }
3926
3927    #[test]
3928    fn paragraph_relayout_cache_is_bounded() {
3929        let mut input = make_input_with_text("bounded paragraph 0");
3930        for index in 1..(PARAGRAPH_CACHE_MAX_ENTRIES + 20) {
3931            let mut paragraph = CT_P::new();
3932            paragraph.add_run(&format!("bounded paragraph {index}"));
3933            input.document.body.add_paragraph(paragraph);
3934        }
3935        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
3936        engine.layout(&input).expect("bounded layout succeeds");
3937        assert!(engine.paragraph_cache.len() <= PARAGRAPH_CACHE_MAX_ENTRIES);
3938        assert!(engine.paragraph_cache_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
3939        assert!(engine.pending_paragraph_cache_peak_entries <= PARAGRAPH_CACHE_MAX_ENTRIES);
3940        assert!(engine.pending_paragraph_cache_peak_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
3941    }
3942
3943    #[test]
3944    fn transactional_paragraph_staging_is_bounded_before_publication() {
3945        let mut input = make_input_with_text("staged paragraph 0");
3946        for index in 1..(PARAGRAPH_CACHE_MAX_ENTRIES * 2) {
3947            let mut paragraph = CT_P::new();
3948            paragraph.add_run(&format!("staged paragraph {index}"));
3949            input.document.body.add_paragraph(paragraph);
3950        }
3951        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
3952        engine
3953            .layout(&input)
3954            .expect("transactional layout succeeds");
3955        assert_eq!(
3956            engine.pending_paragraph_cache_peak_entries,
3957            PARAGRAPH_CACHE_MAX_ENTRIES
3958        );
3959        assert!(engine.pending_paragraph_cache_peak_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
3960    }
3961
3962    #[test]
3963    fn paragraph_relayout_cache_enforces_the_reflow_byte_ceiling() {
3964        let input = make_input_with_text("reflow accounting template");
3965        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
3966        engine.layout(&input).expect("template layout succeeds");
3967        let template = engine
3968            .paragraph_cache
3969            .front()
3970            .expect("safe paragraph cached")
3971            .block
3972            .clone();
3973        let mut retained = match &template.lines[0].items[0] {
3974            LineItem::Text(text) => text.clone(),
3975            other => panic!("expected text line item, got {other:?}"),
3976        };
3977        retained.advances = vec![0.0; PARAGRAPH_CACHE_MAX_BYTES / 8 + 1];
3978
3979        let mut block = template;
3980        block.reflow = Some(Box::new(block::ParagraphReflow {
3981            items: vec![InlineItem::Text(retained)],
3982            params: oxml_layout::LineBreakParams::default(),
3983        }));
3984        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
3985            panic!("body paragraph");
3986        };
3987        let bytes = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
3988        assert!(bytes > PARAGRAPH_CACHE_MAX_BYTES);
3989
3990        engine.paragraph_cache.clear();
3991        engine.paragraph_cache_bytes = 0;
3992        engine.publish_paragraph_cache_entry(ParagraphCacheEntry {
3993            key: ParagraphCacheKey {
3994                paragraph: paragraph.clone(),
3995                content_width_bits: PageGeometry::default().content_width().to_bits(),
3996                revision_view: RevisionView::Accepted,
3997            },
3998            block,
3999            diagnostics: Vec::new(),
4000            font_trace: Vec::new(),
4001            bytes,
4002        });
4003        assert!(engine.paragraph_cache.is_empty());
4004        assert_eq!(engine.paragraph_cache_bytes, 0);
4005    }
4006
4007    #[test]
4008    fn tab_heavy_paragraph_in_wrapping_document_counts_reflow_parameter_buffers() {
4009        use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
4010        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
4011        use rdocx_oxml::shared::ST_TabJc;
4012        use rdocx_oxml::units::Twips;
4013
4014        let mut input =
4015            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
4016        let retained_per_stop =
4017            std::mem::size_of::<CT_TabStop>() + std::mem::size_of::<oxml_layout::TabStop>();
4018        let stop_count = PARAGRAPH_CACHE_MAX_BYTES / retained_per_stop + 1;
4019        let mut paragraph = CT_P::new();
4020        paragraph.properties = Some(CT_PPr {
4021            tabs: Some(CT_Tabs {
4022                tabs: (0..stop_count)
4023                    .map(|_| CT_TabStop::new(ST_TabJc::Left, Twips(720)))
4024                    .collect(),
4025            }),
4026            ..CT_PPr::default()
4027        });
4028        paragraph.add_run("cache-safe paragraph with many owned tab definitions");
4029        assert!(paragraph_is_cache_safe(&paragraph, &input.styles));
4030        input.document.body.add_paragraph(paragraph);
4031
4032        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
4033        engine.layout(&input).expect("tab-heavy layout succeeds");
4034        assert!(engine.paragraph_cache.is_empty());
4035        assert_eq!(engine.paragraph_cache_bytes, 0);
4036    }
4037
4038    #[test]
4039    fn paragraph_relayout_cache_counts_all_reflow_parameter_vectors() {
4040        let input = make_input_with_text("reflow parameter accounting template");
4041        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
4042        engine.layout(&input).expect("template layout succeeds");
4043        let mut block = engine
4044            .paragraph_cache
4045            .front()
4046            .expect("safe paragraph cached")
4047            .block
4048            .clone();
4049        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
4050            panic!("body paragraph");
4051        };
4052        let baseline = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
4053        let reflow = block.reflow.as_mut().expect("cache retains reflow inputs");
4054        reflow.params.tab_stops = vec![
4055            oxml_layout::TabStop {
4056                pos_pt: 36.0,
4057                align: oxml_layout::TabAlign::Left,
4058                leader: None,
4059            };
4060            3
4061        ];
4062        reflow.params.line_prefix_widths = vec![0.0; 5];
4063        reflow.params.line_suffix_widths = vec![0.0; 7];
4064        let with_parameters = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
4065        let expected =
4066            3 * std::mem::size_of::<oxml_layout::TabStop>() + 12 * std::mem::size_of::<f64>();
4067        assert_eq!(with_parameters - baseline, expected);
4068    }
4069
4070    #[test]
4071    fn paragraph_relayout_cache_counts_fixed_storage_in_owned_keys() {
4072        let input = make_input_with_text("key accounting template");
4073        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
4074        engine.layout(&input).expect("template layout succeeds");
4075        let block = engine
4076            .paragraph_cache
4077            .front()
4078            .expect("safe paragraph cached")
4079            .block
4080            .clone();
4081
4082        let mut paragraph = CT_P::new();
4083        let mut run = CT_R::new("");
4084        let content_count = PARAGRAPH_CACHE_MAX_BYTES / std::mem::size_of::<RunContent>() + 1;
4085        run.content = std::iter::repeat_n(RunContent::Tab, content_count).collect();
4086        paragraph.runs.push(run);
4087        assert!(paragraph_is_cache_safe(&paragraph, &input.styles));
4088        let bytes = paragraph_cache_entry_bytes(&paragraph, &block, &[], 0);
4089        assert!(bytes > PARAGRAPH_CACHE_MAX_BYTES);
4090
4091        engine.paragraph_cache.clear();
4092        engine.paragraph_cache_bytes = 0;
4093        engine.publish_paragraph_cache_entry(ParagraphCacheEntry {
4094            key: ParagraphCacheKey {
4095                paragraph,
4096                content_width_bits: PageGeometry::default().content_width().to_bits(),
4097                revision_view: RevisionView::Accepted,
4098            },
4099            block,
4100            diagnostics: Vec::new(),
4101            font_trace: Vec::new(),
4102            bytes,
4103        });
4104        assert!(engine.paragraph_cache.is_empty());
4105        assert_eq!(engine.paragraph_cache_bytes, 0);
4106    }
4107
4108    #[test]
4109    fn every_sourced_glyph_run_resolves_to_its_exact_word_text() {
4110        use rdocx_oxml::document::CT_SectPr;
4111        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
4112        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
4113
4114        let body_text = "Body ASCII 🚀 界 wraps across several exact source slices ".repeat(5);
4115        let mut input = make_input_with_text(&body_text);
4116
4117        let mut outer = CT_Tbl::new();
4118        let mut outer_row = CT_Row::new();
4119        let mut outer_cell = CT_Tc::new();
4120        outer_cell.paragraphs_mut()[0].add_run("outer cell");
4121        let mut nested = CT_Tbl::new();
4122        let mut nested_row = CT_Row::new();
4123        let mut nested_cell = CT_Tc::new();
4124        nested_cell.paragraphs_mut()[0].add_run("nested cell");
4125        nested_row.cells.push(nested_cell);
4126        nested.rows.push(nested_row);
4127        outer_cell.content.push(CellContent::Table(nested));
4128        outer_row.cells.push(outer_cell);
4129        outer.rows.push(outer_row);
4130        input.document.body.add_table(outer);
4131
4132        let mut references = CT_P::new();
4133        let mut reference_run = CT_R::new("");
4134        reference_run.content = vec![
4135            RunContent::FootnoteRef { id: 4 },
4136            RunContent::EndnoteRef { id: 9 },
4137        ];
4138        references.runs.push(reference_run);
4139        input.document.body.add_paragraph(references);
4140
4141        let mut header = CT_HdrFtr::new();
4142        let mut header_paragraph = CT_P::new();
4143        header_paragraph.add_run("header text");
4144        header.paragraphs.push(header_paragraph);
4145        input.headers.insert("rIdHeader".to_owned(), header);
4146
4147        let mut footer = CT_HdrFtr::new();
4148        let mut footer_paragraph = CT_P::new();
4149        footer_paragraph.add_run("footer text");
4150        footer.paragraphs.push(footer_paragraph);
4151        input.footers.insert("rIdFooter".to_owned(), footer);
4152
4153        let mut section = CT_SectPr::default_letter();
4154        section.header_refs.push(HdrFtrRef {
4155            hdr_ftr_type: HdrFtrType::Default,
4156            rel_id: "rIdHeader".to_owned(),
4157        });
4158        section.footer_refs.push(HdrFtrRef {
4159            hdr_ftr_type: HdrFtrType::Default,
4160            rel_id: "rIdFooter".to_owned(),
4161        });
4162        input.document.body.sect_pr = Some(section);
4163
4164        let mut footnote_paragraph = CT_P::new();
4165        footnote_paragraph.add_run("footnote text");
4166        input.footnotes = Some(CT_Footnotes {
4167            footnotes: vec![CT_Footnote {
4168                id: 4,
4169                note_type: NoteType::Normal,
4170                paragraphs: vec![footnote_paragraph],
4171            }],
4172        });
4173        let mut endnote_paragraph = CT_P::new();
4174        endnote_paragraph.add_run("endnote text");
4175        input.endnotes = Some(CT_Footnotes {
4176            footnotes: vec![CT_Footnote {
4177                id: 9,
4178                note_type: NoteType::Normal,
4179                paragraphs: vec![endnote_paragraph],
4180            }],
4181        });
4182
4183        let expected = HashMap::from([
4184            (
4185                WordSourcePath {
4186                    story: WordStory::Document,
4187                    children: vec![0],
4188                },
4189                body_text,
4190            ),
4191            (
4192                WordSourcePath {
4193                    story: WordStory::Document,
4194                    children: vec![1, 0, 0, 0],
4195                },
4196                "outer cell".to_owned(),
4197            ),
4198            (
4199                WordSourcePath {
4200                    story: WordStory::Document,
4201                    children: vec![1, 0, 0, 1, 0, 0, 0],
4202                },
4203                "nested cell".to_owned(),
4204            ),
4205            (
4206                WordSourcePath {
4207                    story: WordStory::Header {
4208                        relationship_id: "rIdHeader".to_owned(),
4209                    },
4210                    children: vec![0],
4211                },
4212                "header text".to_owned(),
4213            ),
4214            (
4215                WordSourcePath {
4216                    story: WordStory::Footer {
4217                        relationship_id: "rIdFooter".to_owned(),
4218                    },
4219                    children: vec![0],
4220                },
4221                "footer text".to_owned(),
4222            ),
4223            (
4224                WordSourcePath {
4225                    story: WordStory::Footnote { id: 4 },
4226                    children: vec![0],
4227                },
4228                "footnote text".to_owned(),
4229            ),
4230            (
4231                WordSourcePath {
4232                    story: WordStory::Endnote { id: 9 },
4233                    children: vec![0],
4234                },
4235                "endnote text".to_owned(),
4236            ),
4237        ]);
4238
4239        let result = crate::layout_document_deterministic_with_provenance(&input)
4240            .expect("layout with provenance");
4241        let mut seen = std::collections::HashSet::new();
4242        for run in result.layout.pages.iter().flat_map(|page| {
4243            page.elements.iter().filter_map(|element| match element {
4244                PositionedElement::Text(run) => Some(run),
4245                _ => None,
4246            })
4247        }) {
4248            let Some(span) = run.source else {
4249                continue;
4250            };
4251            let path = result.source_node(span.node).expect("source node resolves");
4252            let source_text = expected.get(path).expect("source path belongs to fixture");
4253            let selected = source_text
4254                .chars()
4255                .skip(span.char_start as usize)
4256                .take((span.char_end - span.char_start) as usize)
4257                .collect::<String>();
4258            assert_eq!(selected, run.text, "mismatch at {path:?}");
4259            seen.insert(path.clone());
4260        }
4261        assert_eq!(
4262            seen.len(),
4263            expected.len(),
4264            "every supported story is sourced"
4265        );
4266        for path in expected.keys() {
4267            assert!(seen.contains(path), "missing source path {path:?}");
4268        }
4269    }
4270
4271    #[test]
4272    fn repeated_text_and_repeated_stories_keep_distinct_source_nodes() {
4273        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
4274
4275        let repeated = "duplicate phrase ".repeat(220);
4276        let mut input = make_input_with_text(&repeated);
4277        let mut second = CT_P::new();
4278        second.add_run(&repeated);
4279        input.document.body.add_paragraph(second);
4280
4281        let mut header = CT_HdrFtr::new();
4282        let mut paragraph = CT_P::new();
4283        paragraph.add_run("repeated header");
4284        header.paragraphs.push(paragraph);
4285        input.headers.insert("rIdRepeated".to_owned(), header);
4286        input
4287            .document
4288            .body
4289            .sect_pr
4290            .as_mut()
4291            .expect("default section")
4292            .header_refs
4293            .push(HdrFtrRef {
4294                hdr_ftr_type: HdrFtrType::Default,
4295                rel_id: "rIdRepeated".to_owned(),
4296            });
4297
4298        let result = crate::layout_document_deterministic_with_provenance(&input)
4299            .expect("layout repeated stories");
4300        assert!(result.layout.pages.len() > 1, "header must be reused");
4301        let mut first_body = std::collections::HashSet::new();
4302        let mut second_body = std::collections::HashSet::new();
4303        let mut header_nodes = std::collections::HashSet::new();
4304        let mut header_runs = 0usize;
4305        for run in result.layout.pages.iter().flat_map(|page| {
4306            page.elements.iter().filter_map(|element| match element {
4307                PositionedElement::Text(run) => Some(run),
4308                _ => None,
4309            })
4310        }) {
4311            let Some(source) = run.source else {
4312                continue;
4313            };
4314            match result.source_node(source.node).expect("source resolves") {
4315                WordSourcePath {
4316                    story: WordStory::Document,
4317                    children,
4318                } if children == &[0] => {
4319                    first_body.insert(source.node);
4320                }
4321                WordSourcePath {
4322                    story: WordStory::Document,
4323                    children,
4324                } if children == &[1] => {
4325                    second_body.insert(source.node);
4326                }
4327                WordSourcePath {
4328                    story: WordStory::Header { relationship_id },
4329                    children,
4330                } if relationship_id == "rIdRepeated" && children == &[0] => {
4331                    header_nodes.insert(source.node);
4332                    header_runs += 1;
4333                }
4334                _ => {}
4335            }
4336        }
4337        assert_eq!(first_body.len(), 1);
4338        assert_eq!(second_body.len(), 1);
4339        assert_ne!(
4340            first_body, second_body,
4341            "duplicate paragraphs must not alias"
4342        );
4343        assert_eq!(header_nodes.len(), 1, "repeated header reuses one node");
4344        assert!(header_runs > 1, "header must be emitted more than once");
4345    }
4346
4347    #[test]
4348    fn accepted_and_tracked_views_record_projection_local_ranges() {
4349        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>A</w:t></w:r><w:del w:id="1" w:author="Ada"><w:r><w:delText>B</w:delText></w:r></w:del><w:ins w:id="2" w:author="Ada"><w:r><w:t>C</w:t></w:r></w:ins></w:p></w:body></w:document>"#;
4350        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision XML parses");
4351        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
4352            panic!("expected paragraph");
4353        };
4354
4355        for (view, expected) in [
4356            (RevisionView::Accepted, "AC"),
4357            (RevisionView::Tracked, "ABC"),
4358        ] {
4359            assert_eq!(projected_paragraph_text(paragraph, view), expected);
4360            let mut input = make_input_with_text("");
4361            input.document = document.clone();
4362            input.revision_view = view;
4363            let result = crate::layout_document_deterministic_with_provenance(&input)
4364                .expect("revision layout with provenance");
4365            assert_eq!(result.revision_view, view);
4366            let mut selected = String::new();
4367            for run in result.layout.pages.iter().flat_map(|page| {
4368                page.elements.iter().filter_map(|element| match element {
4369                    PositionedElement::Text(run) => Some(run),
4370                    _ => None,
4371                })
4372            }) {
4373                let Some(span) = run.source else {
4374                    continue;
4375                };
4376                assert!(matches!(
4377                    result.source_node(span.node),
4378                    Some(WordSourcePath {
4379                        story: WordStory::Document,
4380                        children,
4381                    }) if children == &[0]
4382                ));
4383                let exact = expected
4384                    .chars()
4385                    .skip(span.char_start as usize)
4386                    .take((span.char_end - span.char_start) as usize)
4387                    .collect::<String>();
4388                assert_eq!(exact, run.text);
4389                selected.push_str(&run.text);
4390            }
4391            assert_eq!(selected, expected);
4392        }
4393    }
4394
4395    #[test]
4396    fn field_projection_ownership_disambiguates_repeated_literal_ranges() {
4397        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>DATE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>a</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
4398        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("complex field parses");
4399        let BodyContent::Paragraph(parsed) = &document.body.content[0] else {
4400            panic!("expected paragraph");
4401        };
4402        let [RunContent::Field(complex)] = parsed.runs[0].content.as_slice() else {
4403            panic!("expected projected complex field");
4404        };
4405
4406        let cases = [
4407            (
4408                vec![
4409                    RunContent::Field(complex.clone()),
4410                    RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
4411                    RunContent::Field(Field::new("DATE", "a")),
4412                ],
4413                "aa",
4414                vec![("a", 1, 2)],
4415            ),
4416            (
4417                vec![
4418                    RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
4419                    RunContent::Field(complex.clone()),
4420                    RunContent::Text(rdocx_oxml::text::CT_Text::new("aa")),
4421                    RunContent::Field(Field::new("DATE", "a")),
4422                    RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
4423                ],
4424                "aaaaa",
4425                vec![("a", 0, 1), ("aa", 2, 4), ("a", 4, 5)],
4426            ),
4427        ];
4428
4429        for (content, expected_projection, expected_literals) in cases {
4430            let mut input = make_input_with_text("");
4431            let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
4432                panic!("expected paragraph");
4433            };
4434            let mut run = CT_R::new("");
4435            run.content = content;
4436            assert_eq!(run.text(), expected_projection);
4437            paragraph.runs = vec![run];
4438
4439            let result = crate::layout_document_deterministic_with_provenance(&input)
4440                .expect("mixed field layout");
4441            let sourced = result
4442                .layout
4443                .pages
4444                .iter()
4445                .flat_map(|page| &page.elements)
4446                .filter_map(|element| match element {
4447                    PositionedElement::Text(run) => run
4448                        .source
4449                        .map(|span| (run.text.as_str(), span.char_start, span.char_end)),
4450                    _ => None,
4451                })
4452                .collect::<Vec<_>>();
4453            assert_eq!(sourced, expected_literals);
4454        }
4455    }
4456
4457    #[test]
4458    fn generated_or_transformed_text_remains_unattributed() {
4459        use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
4460        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
4461        use rdocx_oxml::numbering::{
4462            CT_AbstractNum, CT_Lvl, CT_Num, CT_Numbering, ST_NumberFormat,
4463        };
4464        use rdocx_oxml::properties::CT_RPr;
4465        use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
4466        use rdocx_oxml::units::Twips;
4467
4468        let mut input = make_input_with_text("ordinary");
4469
4470        let mut transformed = CT_P::new();
4471        let mut caps = CT_R::new("straße");
4472        caps.properties = Some(CT_RPr {
4473            caps: Some(true),
4474            ..Default::default()
4475        });
4476        transformed.runs.push(caps);
4477        input.document.body.add_paragraph(transformed);
4478
4479        let mut generated = CT_P::new();
4480        generated.properties = Some(CT_PPr {
4481            tabs: Some(CT_Tabs {
4482                tabs: vec![CT_TabStop {
4483                    val: ST_TabJc::Left,
4484                    pos: Twips(3600),
4485                    leader: Some(ST_TabLeader::Dot),
4486                    source_occurrence: None,
4487                }],
4488            }),
4489            ..Default::default()
4490        });
4491        let mut generated_run = CT_R::new("");
4492        generated_run.content = vec![
4493            RunContent::Text(rdocx_oxml::text::CT_Text::new("left")),
4494            RunContent::Tab,
4495            RunContent::Text(rdocx_oxml::text::CT_Text::new("right")),
4496            RunContent::Field(Field::new("PAGE", "7")),
4497            RunContent::Text(rdocx_oxml::text::CT_Text::new("after")),
4498            RunContent::FootnoteRef { id: 4 },
4499        ];
4500        generated.runs.push(generated_run);
4501        input.document.body.add_paragraph(generated);
4502
4503        let mut list = CT_P::new();
4504        list.properties = Some(CT_PPr {
4505            num_id: Some(1),
4506            num_ilvl: Some(0),
4507            ..Default::default()
4508        });
4509        list.add_run("listed");
4510        input.document.body.add_paragraph(list);
4511        let mut level = CT_Lvl::new(0);
4512        level.start = Some(1);
4513        level.num_fmt = Some(ST_NumberFormat::Decimal);
4514        level.lvl_text = Some("%1.".to_owned());
4515        let mut abstract_num = CT_AbstractNum::new(1);
4516        abstract_num.levels.push(level);
4517        input.numbering = Some(CT_Numbering {
4518            abstract_nums: vec![abstract_num],
4519            nums: vec![CT_Num {
4520                num_id: 1,
4521                abstract_num_id: 1,
4522                extra_xml: Vec::new(),
4523                extra_attributes: Vec::new(),
4524            }],
4525            root_attributes: Vec::new(),
4526            extra_xml: Vec::new(),
4527        });
4528
4529        let mut note = CT_P::new();
4530        note.add_run("note body");
4531        input.footnotes = Some(CT_Footnotes {
4532            footnotes: vec![CT_Footnote {
4533                id: 4,
4534                note_type: NoteType::Normal,
4535                paragraphs: vec![note],
4536            }],
4537        });
4538
4539        let result = crate::layout_document_deterministic_with_provenance(&input)
4540            .expect("generated text layout");
4541        let runs = result
4542            .layout
4543            .pages
4544            .iter()
4545            .flat_map(|page| &page.elements)
4546            .filter_map(|element| match element {
4547                PositionedElement::Text(run) => Some(run),
4548                _ => None,
4549            })
4550            .collect::<Vec<_>>();
4551        assert!(
4552            runs.iter()
4553                .any(|run| run.text == "ordinary" && run.source.is_some())
4554        );
4555        assert!(
4556            runs.iter()
4557                .any(|run| run.text == "after" && run.source.is_some())
4558        );
4559        assert!(
4560            runs.iter()
4561                .any(|run| run.text == "STRASSE" && run.source.is_none())
4562        );
4563        assert!(
4564            runs.iter()
4565                .any(|run| run.text == "1." && run.source.is_none())
4566        );
4567        assert!(runs.iter().any(|run| {
4568            !run.text.is_empty()
4569                && run.text.chars().all(|character| character == '.')
4570                && run.source.is_none()
4571        }));
4572        assert!(
4573            runs.iter()
4574                .any(|run| run.field_kind == Some(FieldKind::Page) && run.source.is_none())
4575        );
4576        assert!(
4577            runs.iter()
4578                .any(|run| run.note.is_some() && run.source.is_none())
4579        );
4580    }
4581
4582    #[test]
4583    fn existing_low_level_layout_functions_keep_identical_output() {
4584        let input = make_input_with_text("compatibility 🚀 text that wraps ".repeat(30).as_str());
4585        let ordinary = crate::layout_document_deterministic(&input).expect("ordinary layout");
4586        let mut sourced = crate::layout_document_deterministic_with_provenance(&input)
4587            .expect("provenance layout")
4588            .into_layout_result();
4589        for page in &mut sourced.pages {
4590            for element in &mut page.elements {
4591                if let PositionedElement::Text(run) = element {
4592                    run.source = None;
4593                }
4594            }
4595        }
4596        assert_eq!(format!("{ordinary:?}"), format!("{sourced:?}"));
4597    }
4598
4599    #[test]
4600    fn caller_font_and_deterministic_provenance_variants_return_complete_maps() {
4601        let mut input = make_input_with_text("caller font provenance");
4602        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
4603            panic!("expected paragraph");
4604        };
4605        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
4606            font_ascii: Some("Caller Carlito".to_owned()),
4607            font_hansi: Some("Caller Carlito".to_owned()),
4608            ..Default::default()
4609        });
4610        input.fonts.push(oxml_layout::FontFile {
4611            family: "Caller Carlito".to_owned(),
4612            data: include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf").to_vec(),
4613        });
4614
4615        let normal = crate::layout_document_with_provenance(&input).expect("caller font layout");
4616        let deterministic = crate::layout_document_deterministic_with_provenance(&input)
4617            .expect("deterministic caller font layout");
4618        for result in [&normal, &deterministic] {
4619            assert!(
4620                result
4621                    .layout
4622                    .fonts
4623                    .iter()
4624                    .any(|font| font.data == input.fonts[0].data),
4625                "the caller-provided font bytes shaped the result"
4626            );
4627            let runs = result
4628                .layout
4629                .pages
4630                .iter()
4631                .flat_map(|page| &page.elements)
4632                .filter_map(|element| match element {
4633                    PositionedElement::Text(run) if run.source.is_some() => Some(run),
4634                    _ => None,
4635                })
4636                .collect::<Vec<_>>();
4637            assert!(!runs.is_empty(), "caller-font text is sourced");
4638            assert_eq!(
4639                runs.iter().map(|run| run.text.as_str()).collect::<String>(),
4640                "caller font provenance"
4641            );
4642            for run in runs {
4643                let source = run.source.expect("run is sourced");
4644                assert!(matches!(
4645                    result.source_node(source.node),
4646                    Some(WordSourcePath {
4647                        story: WordStory::Document,
4648                        children,
4649                    }) if children == &[0]
4650                ));
4651            }
4652        }
4653    }
4654
4655    #[test]
4656    fn layout_simple_document() {
4657        let input = make_input_with_text("Hello World");
4658        let result = Engine::new().layout(&input);
4659        // On systems without fonts, this may fail — that's OK
4660        if let Ok(result) = result {
4661            assert!(!result.pages.is_empty());
4662            assert_eq!(result.pages[0].page_number, 1);
4663            assert!((result.pages[0].width - 612.0).abs() < 0.01);
4664        }
4665    }
4666
4667    #[test]
4668    fn layout_empty_document() {
4669        let mut doc = rdocx_oxml::document::CT_Document::new();
4670        doc.body.add_paragraph(CT_P::new());
4671
4672        let input = LayoutInput {
4673            revision_view: crate::input::RevisionView::Accepted,
4674            document: doc,
4675            styles: CT_Styles::new_default(),
4676            numbering: None,
4677            headers: HashMap::new(),
4678            footers: HashMap::new(),
4679            images: HashMap::new(),
4680            charts: HashMap::new(),
4681            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
4682            chart_color_map: oxml_drawing::color::ColorMap::default(),
4683            core_properties: None,
4684            hyperlink_urls: HashMap::new(),
4685            footnotes: None,
4686            endnotes: None,
4687            theme: None,
4688            fonts: Vec::new(),
4689        };
4690
4691        let result = Engine::new().layout(&input);
4692        if let Ok(result) = result {
4693            assert_eq!(result.pages.len(), 1);
4694        }
4695    }
4696
4697    #[test]
4698    fn empty_shapeless_anchor_keeps_the_pre_cutover_omission() {
4699        let input = make_input_with_text("");
4700        let mut paragraph = CT_P::new();
4701        paragraph.add_run("").content = vec![RunContent::Drawing(
4702            rdocx_oxml::drawing::CT_Drawing::anchor(rdocx_oxml::drawing::CT_Anchor::background(
4703                "", 914_400, 914_400,
4704            )),
4705        )];
4706        let mut font_manager = FontManager::new();
4707        let mut numbering_state = NumberingState::new();
4708        let mut diagnostics = Vec::new();
4709        let media = MediaRegistry::new(&input.images);
4710
4711        let anchored = collect_anchored_drawings(
4712            &paragraph,
4713            &input.styles,
4714            &input,
4715            &media,
4716            &mut font_manager,
4717            &mut numbering_state,
4718            &mut diagnostics,
4719        )
4720        .expect("empty shapeless anchor collection should succeed");
4721
4722        assert!(anchored.is_empty());
4723    }
4724
4725    #[test]
4726    fn colliding_media_ids_keep_inline_and_anchored_image_bytes_distinct() {
4727        let mut input = make_input_with_text("");
4728        input.images.insert(
4729            "rIdInline".to_string(),
4730            ImageData {
4731                data: vec![1, 2, 3],
4732                content_type: "image/png".to_string(),
4733            },
4734        );
4735        input.images.insert(
4736            "rIdAnchor".to_string(),
4737            ImageData {
4738                data: vec![4, 5, 6],
4739                content_type: "image/jpeg".to_string(),
4740            },
4741        );
4742
4743        let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
4744        let inline_id = media.id_for_relationship("rIdInline");
4745        let anchor_id = media.id_for_relationship("rIdAnchor");
4746        assert_ne!(inline_id, anchor_id);
4747
4748        let line = oxml_layout::LayoutLine {
4749            items: vec![oxml_layout::LineItem::Image {
4750                width: 12.0,
4751                height: 10.0,
4752                media_id: inline_id,
4753            }],
4754            width: 12.0,
4755            ascent: 10.0,
4756            descent: 0.0,
4757            line_gap: 0.0,
4758            height: 10.0,
4759            indent_left: 0.0,
4760            available_width: 468.0,
4761            is_last: true,
4762        };
4763        let mut paragraph = block::build_paragraph_block(
4764            vec![line],
4765            0.0,
4766            0.0,
4767            None,
4768            None,
4769            0.0,
4770            0.0,
4771            None,
4772            false,
4773            false,
4774            false,
4775            true,
4776        );
4777        paragraph.anchored.push(block::AnchoredDrawing {
4778            behind_doc: false,
4779            rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
4780            off_h: 20.0,
4781            rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
4782            off_v: 20.0,
4783            width: 12.0,
4784            height: 10.0,
4785            wrap: rdocx_oxml::drawing::WrapType::None,
4786            dist_top: 0.0,
4787            dist_bottom: 0.0,
4788            dist_left: 0.0,
4789            dist_right: 0.0,
4790            align_h: None,
4791            align_v: None,
4792            content: block::AnchoredContent::Image {
4793                media_id: anchor_id,
4794            },
4795        });
4796        let sections = [paginator::Section {
4797            blocks: vec![LayoutBlock::Paragraph(paragraph)],
4798            geometry: PageGeometry::default(),
4799            header_footer: None,
4800            title_pg: false,
4801            page_number_start: None,
4802        }];
4803
4804        let (pages, _) = paginator::paginate_sections(
4805            &sections,
4806            &FontManager::new(),
4807            &media,
4808            &NoteRegistry::default(),
4809        );
4810        let images = pages[0]
4811            .elements
4812            .iter()
4813            .filter_map(|element| match element {
4814                PositionedElement::Image {
4815                    data,
4816                    content_type,
4817                    media_id,
4818                    ..
4819                } => Some((data.as_slice(), content_type.as_str(), *media_id)),
4820                _ => None,
4821            })
4822            .collect::<Vec<_>>();
4823
4824        assert!(images.contains(&(b"\x01\x02\x03".as_slice(), "image/png", inline_id)));
4825        assert!(images.contains(&(b"\x04\x05\x06".as_slice(), "image/jpeg", anchor_id)));
4826    }
4827
4828    #[test]
4829    fn watermark_image_uses_the_collision_safe_media_registry_id() {
4830        let mut input = make_input_with_text("body");
4831        input.images.insert(
4832            "rIdHeader\0rIdOrdinary".to_owned(),
4833            ImageData {
4834                data: vec![1],
4835                content_type: "image/png".to_owned(),
4836            },
4837        );
4838        input.images.insert(
4839            "rIdHeader\0rIdWatermark".to_owned(),
4840            ImageData {
4841                data: vec![2],
4842                content_type: "image/png".to_owned(),
4843            },
4844        );
4845        let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
4846        let expected = media.id_for_relationship("rIdHeader\0rIdWatermark");
4847        let mut font_manager = FontManager::new();
4848        let mut diagnostics = Vec::new();
4849        let group = layout_watermark(
4850            &VmlWatermark::Image {
4851                relationship_id: "rIdWatermark".to_owned(),
4852                width_pt: 72.0,
4853                height_pt: 36.0,
4854                rotation_degrees: 0.0,
4855                opacity: 0.5,
4856            },
4857            "rIdHeader",
4858            &input,
4859            &media,
4860            &mut font_manager,
4861            PageGeometry::default(),
4862            &mut diagnostics,
4863        )
4864        .unwrap()
4865        .unwrap();
4866        let PositionedElement::Image { media_id, data, .. } = &group.children[0] else {
4867            panic!("expected watermark image");
4868        };
4869        assert_eq!(*media_id, expected);
4870        assert_eq!(data, &[2]);
4871        assert!(diagnostics.is_empty());
4872    }
4873
4874    #[test]
4875    fn group_inline_item_breaks_and_positions_like_an_image() {
4876        let child = PositionedElement::FilledRect {
4877            rect: Rect {
4878                x: 2.0,
4879                y: 3.0,
4880                width: 4.0,
4881                height: 5.0,
4882            },
4883            color: Color::BLACK,
4884        };
4885        let group = GroupElement {
4886            transform: oxml_layout::Transform::IDENTITY,
4887            clip: None,
4888            opacity: 1.0,
4889            effects: Vec::new(),
4890            children: vec![child.clone()],
4891        };
4892        let line = oxml_layout::LayoutLine {
4893            items: vec![oxml_layout::LineItem::Group {
4894                width: 80.0,
4895                height: 40.0,
4896                group,
4897            }],
4898            width: 80.0,
4899            ascent: 40.0,
4900            descent: 0.0,
4901            line_gap: 0.0,
4902            height: 40.0,
4903            indent_left: 0.0,
4904            available_width: 468.0,
4905            is_last: true,
4906        };
4907        let paragraph = block::build_paragraph_block(
4908            vec![line],
4909            0.0,
4910            0.0,
4911            None,
4912            None,
4913            0.0,
4914            0.0,
4915            None,
4916            false,
4917            false,
4918            false,
4919            true,
4920        );
4921        let sections = [paginator::Section {
4922            blocks: vec![LayoutBlock::Paragraph(paragraph)],
4923            geometry: PageGeometry::default(),
4924            header_footer: None,
4925            title_pg: false,
4926            page_number_start: None,
4927        }];
4928        let media = MediaRegistry::new(&HashMap::new());
4929        let (pages, _) = paginator::paginate_sections(
4930            &sections,
4931            &FontManager::new(),
4932            &media,
4933            &NoteRegistry::default(),
4934        );
4935
4936        let PositionedElement::Group(actual) = &pages[0].elements[0] else {
4937            panic!("group line item should become a positioned group");
4938        };
4939        assert_eq!((actual.transform.e, actual.transform.f), (72.0, 72.0));
4940        assert_eq!(actual.children, vec![child]);
4941    }
4942
4943    #[test]
4944    fn layout_with_heading_style() {
4945        let mut doc = rdocx_oxml::document::CT_Document::new();
4946        let mut p = CT_P::new();
4947        p.properties = Some(CT_PPr {
4948            style_id: Some("Heading1".to_string()),
4949            ..Default::default()
4950        });
4951        p.add_run("Chapter 1");
4952        doc.body.add_paragraph(p);
4953
4954        let input = LayoutInput {
4955            revision_view: crate::input::RevisionView::Accepted,
4956            document: doc,
4957            styles: CT_Styles::new_default(),
4958            numbering: None,
4959            headers: HashMap::new(),
4960            footers: HashMap::new(),
4961            images: HashMap::new(),
4962            charts: HashMap::new(),
4963            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
4964            chart_color_map: oxml_drawing::color::ColorMap::default(),
4965            core_properties: None,
4966            hyperlink_urls: HashMap::new(),
4967            footnotes: None,
4968            endnotes: None,
4969            theme: None,
4970            fonts: Vec::new(),
4971        };
4972
4973        let result = Engine::new().layout(&input);
4974        if let Ok(result) = result {
4975            assert!(!result.pages.is_empty());
4976            // Should produce one outline entry for Heading1
4977            assert_eq!(result.outlines.len(), 1);
4978            assert_eq!(result.outlines[0].title, "Chapter 1");
4979            assert_eq!(result.outlines[0].level, 1);
4980            assert_eq!(result.outlines[0].page_index, 0);
4981        }
4982    }
4983
4984    #[test]
4985    fn layout_nested_headings_produce_outlines() {
4986        let mut doc = rdocx_oxml::document::CT_Document::new();
4987
4988        // H1
4989        let mut h1 = CT_P::new();
4990        h1.properties = Some(CT_PPr {
4991            style_id: Some("Heading1".to_string()),
4992            ..Default::default()
4993        });
4994        h1.add_run("Chapter 1");
4995        doc.body.add_paragraph(h1);
4996
4997        // H2 under H1
4998        let mut h2 = CT_P::new();
4999        h2.properties = Some(CT_PPr {
5000            style_id: Some("Heading2".to_string()),
5001            ..Default::default()
5002        });
5003        h2.add_run("Section 1.1");
5004        doc.body.add_paragraph(h2);
5005
5006        // Another H1
5007        let mut h1b = CT_P::new();
5008        h1b.properties = Some(CT_PPr {
5009            style_id: Some("Heading1".to_string()),
5010            ..Default::default()
5011        });
5012        h1b.add_run("Chapter 2");
5013        doc.body.add_paragraph(h1b);
5014
5015        let input = LayoutInput {
5016            revision_view: crate::input::RevisionView::Accepted,
5017            document: doc,
5018            styles: CT_Styles::new_default(),
5019            numbering: None,
5020            headers: HashMap::new(),
5021            footers: HashMap::new(),
5022            images: HashMap::new(),
5023            charts: HashMap::new(),
5024            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
5025            chart_color_map: oxml_drawing::color::ColorMap::default(),
5026            core_properties: None,
5027            hyperlink_urls: HashMap::new(),
5028            footnotes: None,
5029            endnotes: None,
5030            theme: None,
5031            fonts: Vec::new(),
5032        };
5033
5034        let result = Engine::new().layout(&input);
5035        if let Ok(result) = result {
5036            assert_eq!(result.outlines.len(), 3);
5037            assert_eq!(result.outlines[0].level, 1);
5038            assert_eq!(result.outlines[0].title, "Chapter 1");
5039            assert_eq!(result.outlines[1].level, 2);
5040            assert_eq!(result.outlines[1].title, "Section 1.1");
5041            assert_eq!(result.outlines[2].level, 1);
5042            assert_eq!(result.outlines[2].title, "Chapter 2");
5043        }
5044    }
5045
5046    #[test]
5047    fn sect_pr_geometry_conversion() {
5048        let sect = CT_SectPr::default_letter();
5049        let geom = sect_pr_to_geometry(&sect);
5050        assert!((geom.page_width - 612.0).abs() < 0.01);
5051        assert!((geom.page_height - 792.0).abs() < 0.01);
5052        assert!((geom.margin_top - 72.0).abs() < 0.01);
5053        assert!((geom.content_width() - 468.0).abs() < 0.01);
5054    }
5055
5056    #[test]
5057    fn section_page_number_start_requires_a_direct_word_child_and_decodes_entities() {
5058        let mut section = CT_SectPr::default_letter();
5059        section.extra_xml = vec![
5060            br#"<x:pgNumType xmlns:x="urn:producer" x:start="2"/>"#.to_vec(),
5061            br#"<w:pgNumType xmlns:w="urn:producer" w:start="2"/>"#.to_vec(),
5062            format!(
5063                r#"<w:wrapper xmlns:w="{}"><w:pgNumType w:start="2"/></w:wrapper>"#,
5064                rdocx_oxml::namespace::W_NS
5065            )
5066            .into_bytes(),
5067            format!(
5068                r#"<q:pgNumType xmlns:q="{}" q:start="&#x31;"/>"#,
5069                rdocx_oxml::namespace::W_NS
5070            )
5071            .into_bytes(),
5072        ];
5073
5074        assert_eq!(section_page_number_start(&section), Some(1));
5075    }
5076
5077    #[test]
5078    fn sect_pr_a4_geometry() {
5079        let sect = CT_SectPr::default_a4();
5080        let geom = sect_pr_to_geometry(&sect);
5081        // A4: 210mm = 595.3pt, 297mm = 841.9pt
5082        assert!((geom.page_width - 595.3).abs() < 0.5);
5083        assert!((geom.page_height - 841.9).abs() < 0.5);
5084    }
5085
5086    // F-X013a, footnote line advance.
5087
5088    /// Build a document whose single body paragraph references footnote 1, and
5089    /// whose footnote 1 is one paragraph made of `note_runs` separate runs.
5090    fn make_input_with_footnote(note_runs: &[&str]) -> LayoutInput {
5091        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
5092        use rdocx_oxml::text::CT_R;
5093
5094        let mut doc = rdocx_oxml::document::CT_Document::new();
5095        let mut body = CT_P::new();
5096        body.add_run("Body text carrying a note");
5097        let mut marker_run = CT_R::new("");
5098        marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
5099        body.runs.push(marker_run);
5100        doc.body.add_paragraph(body);
5101
5102        let mut note = CT_P::new();
5103        for text in note_runs {
5104            note.add_run(text);
5105        }
5106
5107        LayoutInput {
5108            revision_view: crate::input::RevisionView::Accepted,
5109            document: doc,
5110            styles: CT_Styles::new_default(),
5111            numbering: None,
5112            headers: HashMap::new(),
5113            footers: HashMap::new(),
5114            images: HashMap::new(),
5115            charts: HashMap::new(),
5116            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
5117            chart_color_map: oxml_drawing::color::ColorMap::default(),
5118            core_properties: None,
5119            hyperlink_urls: HashMap::new(),
5120            footnotes: Some(CT_Footnotes {
5121                footnotes: vec![CT_Footnote {
5122                    id: 1,
5123                    note_type: NoteType::Normal,
5124                    paragraphs: vec![note],
5125                }],
5126            }),
5127            endnotes: None,
5128            theme: None,
5129            fonts: Vec::new(),
5130        }
5131    }
5132
5133    /// The x origin of every glyph run sitting below the footnote separator,
5134    /// in the order the renderer emitted them. The first is the note marker.
5135    fn footnote_glyph_x(page: &oxml_layout::output::PageFrame) -> Vec<f64> {
5136        let separator_y = page
5137            .elements
5138            .iter()
5139            .find_map(|element| match element {
5140                PositionedElement::Line { start, .. } => Some(start.y),
5141                _ => None,
5142            })
5143            .expect("a page with a footnote draws a separator line");
5144
5145        page.elements
5146            .iter()
5147            .filter_map(|element| match element {
5148                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.x),
5149                _ => None,
5150            })
5151            .collect()
5152    }
5153
5154    #[test]
5155    fn a_multi_segment_footnote_does_not_stack_its_segments_at_one_x() {
5156        let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
5157        let mut engine = Engine::new();
5158        let output = engine.layout(&input).expect("layout succeeds");
5159        let xs = footnote_glyph_x(&output.pages[0]);
5160
5161        assert!(
5162            xs.len() >= 4,
5163            "expected a marker and three note segments, got {xs:?}"
5164        );
5165        for pair in xs.windows(2) {
5166            assert!(
5167                pair[1] > pair[0],
5168                "footnote segments must advance, got {xs:?}"
5169            );
5170        }
5171    }
5172
5173    #[test]
5174    fn a_single_segment_footnote_keeps_its_original_position() {
5175        let input = make_input_with_footnote(&["Solitary"]);
5176        let mut engine = Engine::new();
5177        let output = engine.layout(&input).expect("layout succeeds");
5178        let xs = footnote_glyph_x(&output.pages[0]);
5179        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
5180
5181        // The marker sits at the left margin, the single segment one indent in.
5182        assert_eq!(xs.len(), 2, "expected a marker and one segment, got {xs:?}");
5183        assert!(
5184            (xs[0] - geometry.margin_left).abs() < 0.01,
5185            "marker at {xs:?}"
5186        );
5187        assert!(
5188            (xs[1] - (geometry.margin_left + 12.0)).abs() < 0.01,
5189            "segment at {xs:?}"
5190        );
5191    }
5192
5193    #[test]
5194    fn a_long_footnote_does_not_overrun_the_right_margin() {
5195        // Long enough to wrap, which is what exposes a break width that
5196        // disagrees with the indent the note is drawn at.
5197        let long = "In paged media, footnotes are usually displayed at the \
5198                    bottom of the text. However, in ebooks, a better paradigm \
5199                    is to make them clickable endnotes that the reader can \
5200                    browse at leisure, which this sentence exists to force.";
5201        let input = make_input_with_footnote(&[long]);
5202        let mut engine = Engine::new();
5203        let output = engine.layout(&input).expect("layout succeeds");
5204        let page = &output.pages[0];
5205        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
5206        let right_margin = geometry.page_width - geometry.margin_right;
5207
5208        let separator_y = page
5209            .elements
5210            .iter()
5211            .find_map(|element| match element {
5212                PositionedElement::Line { start, .. } => Some(start.y),
5213                _ => None,
5214            })
5215            .expect("a page with a footnote draws a separator line");
5216
5217        let mut wrapped = false;
5218        let mut first_y = None;
5219        for element in &page.elements {
5220            let PositionedElement::Text(run) = element else {
5221                continue;
5222            };
5223            if run.origin.y <= separator_y {
5224                continue;
5225            }
5226            let first = *first_y.get_or_insert(run.origin.y);
5227            if run.origin.y > first + 0.01 {
5228                wrapped = true;
5229            }
5230            let right_edge = run.origin.x + run.advances.iter().sum::<f64>();
5231            assert!(
5232                right_edge <= right_margin + 0.01,
5233                "note text reaches {right_edge}, past the right margin {right_margin}"
5234            );
5235        }
5236        assert!(wrapped, "the note must wrap for this test to mean anything");
5237    }
5238
5239    #[test]
5240    fn a_tab_inside_a_footnote_still_advances_the_text_after_it() {
5241        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
5242        use rdocx_oxml::text::CT_R;
5243
5244        // Two notes differing only by a tab between their runs. The tab is not
5245        // drawn, but it occupies width, so the run after it must shift right.
5246        let build = |with_tab: bool| {
5247            let mut doc = rdocx_oxml::document::CT_Document::new();
5248            let mut body = CT_P::new();
5249            body.add_run("Body");
5250            let mut marker_run = CT_R::new("");
5251            marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
5252            body.runs.push(marker_run);
5253            doc.body.add_paragraph(body);
5254
5255            let mut note = CT_P::new();
5256            note.add_run("Alpha");
5257            if with_tab {
5258                let mut tab_run = CT_R::new("");
5259                tab_run.content = vec![RunContent::Tab];
5260                note.runs.push(tab_run);
5261            }
5262            note.add_run("Beta");
5263
5264            LayoutInput {
5265                revision_view: crate::input::RevisionView::Accepted,
5266                document: doc,
5267                styles: CT_Styles::new_default(),
5268                numbering: None,
5269                headers: HashMap::new(),
5270                footers: HashMap::new(),
5271                images: HashMap::new(),
5272                charts: HashMap::new(),
5273                chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
5274                chart_color_map: oxml_drawing::color::ColorMap::default(),
5275                core_properties: None,
5276                hyperlink_urls: HashMap::new(),
5277                footnotes: Some(CT_Footnotes {
5278                    footnotes: vec![CT_Footnote {
5279                        id: 1,
5280                        note_type: NoteType::Normal,
5281                        paragraphs: vec![note],
5282                    }],
5283                }),
5284                endnotes: None,
5285                theme: None,
5286                fonts: Vec::new(),
5287            }
5288        };
5289
5290        let mut engine = Engine::new();
5291        let plain = engine.layout(&build(false)).expect("layout succeeds");
5292        let tabbed = engine.layout(&build(true)).expect("layout succeeds");
5293
5294        let plain_x = footnote_glyph_x(&plain.pages[0]);
5295        let tabbed_x = footnote_glyph_x(&tabbed.pages[0]);
5296
5297        // Marker and both runs are drawn in each case. The tab draws nothing.
5298        assert_eq!(plain_x.len(), 3, "plain note glyphs {plain_x:?}");
5299        assert_eq!(tabbed_x.len(), 3, "tabbed note glyphs {tabbed_x:?}");
5300        assert!(
5301            tabbed_x[2] > plain_x[2] + 1.0,
5302            "the run after a tab must shift right, plain {plain_x:?} tabbed {tabbed_x:?}"
5303        );
5304    }
5305
5306    #[test]
5307    fn footnote_segment_advance_matches_body_segment_advance() {
5308        let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
5309        let mut engine = Engine::new();
5310        let output = engine.layout(&input).expect("layout succeeds");
5311        let page = &output.pages[0];
5312
5313        let separator_y = page
5314            .elements
5315            .iter()
5316            .find_map(|element| match element {
5317                PositionedElement::Line { start, .. } => Some(start.y),
5318                _ => None,
5319            })
5320            .expect("a page with a footnote draws a separator line");
5321
5322        // Gaps between consecutive note segments must equal the width of the
5323        // segment that precedes them, which is what the body path advances by.
5324        let notes: Vec<&oxml_layout::GlyphRun> = page
5325            .elements
5326            .iter()
5327            .filter_map(|element| match element {
5328                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run),
5329                _ => None,
5330            })
5331            .skip(1) // the marker, which is positioned independently
5332            .collect();
5333
5334        assert_eq!(notes.len(), 3, "expected three note segments");
5335        for pair in notes.windows(2) {
5336            let advance: f64 = pair[0].advances.iter().sum();
5337            let gap = pair[1].origin.x - pair[0].origin.x;
5338            assert!(
5339                (gap - advance).abs() < 0.01,
5340                "gap {gap} should equal preceding segment advance {advance}"
5341            );
5342        }
5343    }
5344
5345    // F-X013b, reservation and splitting.
5346
5347    /// A document of `body_paras` paragraphs. The paragraph at
5348    /// `ref_positions` each carry a reference to note 1, whose content is
5349    /// `note_paras` paragraphs of `note_text`.
5350    fn make_noted_document(
5351        body_paras: usize,
5352        ref_positions: &[usize],
5353        note_paras: usize,
5354        note_text: &str,
5355        continuation_separator: bool,
5356    ) -> LayoutInput {
5357        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
5358        use rdocx_oxml::text::CT_R;
5359
5360        let mut doc = rdocx_oxml::document::CT_Document::new();
5361        for index in 0..body_paras {
5362            let mut para = CT_P::new();
5363            para.add_run("Body paragraph text that occupies a line of the page.");
5364            if ref_positions.contains(&index) {
5365                let mut marker = CT_R::new("");
5366                marker.content = vec![RunContent::FootnoteRef { id: 1 }];
5367                para.runs.push(marker);
5368            }
5369            doc.body.add_paragraph(para);
5370        }
5371
5372        let mut entries = Vec::new();
5373        if continuation_separator {
5374            entries.push(CT_Footnote {
5375                id: 0,
5376                note_type: NoteType::ContinuationSeparator,
5377                paragraphs: vec![CT_P::new()],
5378            });
5379        }
5380        entries.push(CT_Footnote {
5381            id: 1,
5382            note_type: NoteType::Normal,
5383            paragraphs: (0..note_paras)
5384                .map(|_| {
5385                    let mut p = CT_P::new();
5386                    p.add_run(note_text);
5387                    p
5388                })
5389                .collect(),
5390        });
5391
5392        LayoutInput {
5393            revision_view: crate::input::RevisionView::Accepted,
5394            document: doc,
5395            styles: CT_Styles::new_default(),
5396            numbering: None,
5397            headers: HashMap::new(),
5398            footers: HashMap::new(),
5399            images: HashMap::new(),
5400            charts: HashMap::new(),
5401            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
5402            chart_color_map: oxml_drawing::color::ColorMap::default(),
5403            core_properties: None,
5404            hyperlink_urls: HashMap::new(),
5405            footnotes: Some(CT_Footnotes { footnotes: entries }),
5406            endnotes: None,
5407            theme: None,
5408            fonts: Vec::new(),
5409        }
5410    }
5411
5412    /// Split a page into the glyphs drawn above the note separator and those
5413    /// drawn below it. Notes are emitted after body content, so the separator
5414    /// is the boundary.
5415    fn split_at_separator(
5416        page: &oxml_layout::output::PageFrame,
5417    ) -> Option<(f64, Vec<f64>, Vec<String>)> {
5418        let separator_index = page.elements.iter().position(|element| {
5419            matches!(element, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001)
5420        })?;
5421        let PositionedElement::Line { start, end, .. } = &page.elements[separator_index] else {
5422            return None;
5423        };
5424        let separator_y = start.y;
5425        let separator_width = end.x - start.x;
5426
5427        let body_ys: Vec<f64> = page.elements[..separator_index]
5428            .iter()
5429            .filter_map(|element| match element {
5430                PositionedElement::Text(run) => Some(run.origin.y),
5431                _ => None,
5432            })
5433            .collect();
5434        let note_text: Vec<String> = page.elements[separator_index + 1..]
5435            .iter()
5436            .filter_map(|element| match element {
5437                PositionedElement::Text(run) => Some(run.text.clone()),
5438                _ => None,
5439            })
5440            .collect();
5441
5442        let _ = separator_y;
5443        Some((separator_width, body_ys, note_text))
5444    }
5445
5446    fn separator_y_of(page: &oxml_layout::output::PageFrame) -> Option<f64> {
5447        page.elements.iter().find_map(|element| match element {
5448            PositionedElement::Line { start, width, .. } if (*width - 0.5).abs() < 0.001 => {
5449                Some(start.y)
5450            }
5451            _ => None,
5452        })
5453    }
5454
5455    #[test]
5456    fn a_page_whose_body_fills_the_text_area_does_not_overlap_its_notes() {
5457        // Enough body to reach the bottom margin, with the reference early so
5458        // the note is owed by the first page.
5459        let input = make_noted_document(
5460            60,
5461            &[0],
5462            2,
5463            "A note long enough to wrap onto a second line of the note area.",
5464            false,
5465        );
5466        let mut engine = Engine::new();
5467        let output = engine.layout(&input).expect("layout succeeds");
5468        let page = &output.pages[0];
5469
5470        let separator_y = separator_y_of(page).expect("the page draws a separator");
5471        let (_, body_ys, note_text) = split_at_separator(page).unwrap();
5472
5473        assert!(!note_text.is_empty(), "the note must be drawn");
5474        let lowest_body = body_ys.iter().cloned().fold(f64::MIN, f64::max);
5475        assert!(
5476            lowest_body < separator_y,
5477            "body text reaches {lowest_body}, at or below the separator at {separator_y}"
5478        );
5479    }
5480
5481    #[test]
5482    fn a_page_referencing_one_note_twice_reserves_it_once() {
5483        let input = make_noted_document(4, &[0, 1], 1, "Referenced twice from one page.", false);
5484        let mut engine = Engine::new();
5485        let output = engine.layout(&input).expect("layout succeeds");
5486        let page = &output.pages[0];
5487
5488        let separators = page
5489            .elements
5490            .iter()
5491            .filter(|e| matches!(e, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001))
5492            .count();
5493        assert_eq!(separators, 1, "one note area, so one separator");
5494
5495        let (_, _, note_text) = split_at_separator(page).unwrap();
5496        let markers = note_text.iter().filter(|t| t.as_str() == "1").count();
5497        assert_eq!(markers, 1, "the note is drawn once, got {note_text:?}");
5498    }
5499
5500    #[test]
5501    fn a_note_taller_than_its_remaining_space_continues_on_the_next_page() {
5502        // 120 note paragraphs exceed a single page, so the note has to break.
5503        let input = make_noted_document(30, &[25], 120, "Note paragraph line.", true);
5504        let mut engine = Engine::new();
5505        let output = engine.layout(&input).expect("layout succeeds");
5506
5507        let note_pages: Vec<usize> = output
5508            .pages
5509            .iter()
5510            .enumerate()
5511            .filter(|(_, page)| separator_y_of(page).is_some())
5512            .map(|(index, _)| index)
5513            .collect();
5514
5515        assert!(
5516            note_pages.len() >= 2,
5517            "a note taller than a page must span pages, got {note_pages:?}"
5518        );
5519
5520        let first = split_at_separator(&output.pages[note_pages[0]]).unwrap().2;
5521        let second = split_at_separator(&output.pages[note_pages[1]]).unwrap().2;
5522
5523        assert!(!first.is_empty(), "the first page draws part of the note");
5524        assert!(!second.is_empty(), "the next page draws the rest");
5525        assert_eq!(
5526            first.iter().filter(|t| t.as_str() == "1").count(),
5527            1,
5528            "the marker is drawn on the page the note starts on"
5529        );
5530        assert_eq!(
5531            second.iter().filter(|t| t.as_str() == "1").count(),
5532            0,
5533            "a continuation does not repeat the marker, got {second:?}"
5534        );
5535    }
5536
5537    #[test]
5538    fn a_continued_note_draws_the_continuation_separator() {
5539        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
5540
5541        let widths = |continuation: bool| {
5542            let input = make_noted_document(30, &[25], 120, "Note paragraph line.", continuation);
5543            let mut engine = Engine::new();
5544            let output = engine.layout(&input).expect("layout succeeds");
5545            let pages: Vec<usize> = output
5546                .pages
5547                .iter()
5548                .enumerate()
5549                .filter(|(_, page)| separator_y_of(page).is_some())
5550                .map(|(index, _)| index)
5551                .collect();
5552            assert!(pages.len() >= 2, "the note must span pages");
5553            (
5554                split_at_separator(&output.pages[pages[0]]).unwrap().0,
5555                split_at_separator(&output.pages[pages[1]]).unwrap().0,
5556            )
5557        };
5558
5559        let (first, second) = widths(true);
5560        assert!(
5561            (first - geometry.content_width() * 0.33).abs() < 0.5,
5562            "a note starting on its page gets the short rule, got {first}"
5563        );
5564        assert!(
5565            (second - geometry.content_width()).abs() < 0.5,
5566            "a continued note gets the full-width rule, got {second}"
5567        );
5568
5569        // A document defining no continuation separator keeps the short rule.
5570        let (_, second) = widths(false);
5571        assert!(
5572            (second - geometry.content_width() * 0.33).abs() < 0.5,
5573            "without a continuation separator the short rule is kept, got {second}"
5574        );
5575    }
5576
5577    #[test]
5578    fn an_oversized_note_still_leaves_room_for_body_text() {
5579        // A note several pages tall, referenced from the first paragraph.
5580        let input = make_noted_document(3, &[0], 200, "A line of an enormous note.", true);
5581        let mut engine = Engine::new();
5582        let output = engine.layout(&input).expect("layout terminates");
5583
5584        let (_, body_ys, _) = split_at_separator(&output.pages[0]).unwrap();
5585        assert!(
5586            !body_ys.is_empty(),
5587            "an oversized note must not starve the page of body text"
5588        );
5589        assert!(
5590            output.pages.len() > 1 && output.pages.len() < 100,
5591            "the note spills over a bounded number of pages, got {}",
5592            output.pages.len()
5593        );
5594
5595        // The note area has to stay on the page. Placing an oversized note
5596        // whole would push its separator off the top of the sheet.
5597        for (index, page) in output.pages.iter().enumerate() {
5598            let Some(separator_y) = separator_y_of(page) else {
5599                continue;
5600            };
5601            assert!(
5602                separator_y >= 0.0,
5603                "page {} draws its separator at {separator_y}, off the sheet",
5604                index + 1
5605            );
5606        }
5607    }
5608
5609    #[test]
5610    fn a_note_is_drawn_on_the_page_that_carries_its_reference() {
5611        // Sweeping the reference across the document is what catches the two
5612        // ways a note drifts off its own page: notes claimed for a paragraph
5613        // that then moves, and a note area measured from a cursor that still
5614        // holds the previous paragraph's trailing space.
5615        let mut mismatches = Vec::new();
5616        for position in 0..60 {
5617            let input = make_noted_document(60, &[position], 1, "Note text.", false);
5618            let mut engine = Engine::new();
5619            let output = engine.layout(&input).expect("layout succeeds");
5620
5621            let reference_page = output.pages.iter().position(|page| {
5622                page.elements.iter().any(|element| {
5623                    matches!(element, PositionedElement::Text(run)
5624                    if run.note == Some(oxml_layout::NoteRef {
5625                        stream: oxml_layout::NoteStream::Footnote,
5626                        id: 1,
5627                    }))
5628                })
5629            });
5630            let note_page = output
5631                .pages
5632                .iter()
5633                .position(|page| separator_y_of(page).is_some());
5634
5635            if reference_page != note_page {
5636                mismatches.push((position, reference_page, note_page));
5637            }
5638        }
5639
5640        assert!(
5641            mismatches.is_empty(),
5642            "note and reference landed on different pages for (position, ref, note): {mismatches:?}"
5643        );
5644    }
5645
5646    // F-X013c, endnotes at the document end.
5647
5648    /// A document whose single body paragraph references footnote `id` and
5649    /// endnote `id`, with each stream giving that number different text.
5650    fn make_document_with_both_streams(id: i32, body_paras: usize) -> LayoutInput {
5651        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
5652        use rdocx_oxml::text::CT_R;
5653
5654        let mut doc = rdocx_oxml::document::CT_Document::new();
5655        for index in 0..body_paras {
5656            let mut para = CT_P::new();
5657            para.add_run("Body paragraph text that occupies a line of the page.");
5658            if index == 0 {
5659                let mut foot = CT_R::new("");
5660                foot.content = vec![RunContent::FootnoteRef { id }];
5661                para.runs.push(foot);
5662                let mut end = CT_R::new("");
5663                end.content = vec![RunContent::EndnoteRef { id }];
5664                para.runs.push(end);
5665            }
5666            doc.body.add_paragraph(para);
5667        }
5668
5669        let note = |text: &str| {
5670            let mut p = CT_P::new();
5671            p.add_run(text);
5672            CT_Footnote {
5673                id,
5674                note_type: NoteType::Normal,
5675                paragraphs: vec![p],
5676            }
5677        };
5678
5679        LayoutInput {
5680            revision_view: crate::input::RevisionView::Accepted,
5681            document: doc,
5682            styles: CT_Styles::new_default(),
5683            numbering: None,
5684            headers: HashMap::new(),
5685            footers: HashMap::new(),
5686            images: HashMap::new(),
5687            charts: HashMap::new(),
5688            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
5689            chart_color_map: oxml_drawing::color::ColorMap::default(),
5690            core_properties: None,
5691            hyperlink_urls: HashMap::new(),
5692            footnotes: Some(CT_Footnotes {
5693                footnotes: vec![note("FOOTNOTETEXT")],
5694            }),
5695            endnotes: Some(CT_Footnotes {
5696                footnotes: vec![note("ENDNOTETEXT")],
5697            }),
5698            theme: None,
5699            fonts: Vec::new(),
5700        }
5701    }
5702
5703    fn page_text(page: &oxml_layout::output::PageFrame) -> String {
5704        page.elements
5705            .iter()
5706            .filter_map(|element| match element {
5707                PositionedElement::Text(run) => Some(run.text.as_str()),
5708                _ => None,
5709            })
5710            .collect::<Vec<_>>()
5711            .join(" ")
5712    }
5713
5714    #[test]
5715    fn a_footnote_and_an_endnote_sharing_a_number_render_their_own_text() {
5716        let input = make_document_with_both_streams(2, 3);
5717        let mut engine = Engine::new();
5718        let output = engine.layout(&input).expect("layout succeeds");
5719
5720        let all: String = output
5721            .pages
5722            .iter()
5723            .map(page_text)
5724            .collect::<Vec<_>>()
5725            .join(" | ");
5726        assert!(
5727            all.contains("FOOTNOTETEXT"),
5728            "the footnote must render its own text, got {all}"
5729        );
5730        assert!(
5731            all.contains("ENDNOTETEXT"),
5732            "the endnote must render its own text, got {all}"
5733        );
5734    }
5735
5736    #[test]
5737    fn endnotes_render_after_the_last_body_page() {
5738        let input = make_document_with_both_streams(2, 3);
5739        let mut engine = Engine::new();
5740        let output = engine.layout(&input).expect("layout succeeds");
5741
5742        let endnote_page = output
5743            .pages
5744            .iter()
5745            .position(|page| page_text(page).contains("ENDNOTETEXT"))
5746            .expect("the endnote is rendered somewhere");
5747        let last_body_page = output
5748            .pages
5749            .iter()
5750            .rposition(|page| page_text(page).contains("occupies"))
5751            .expect("the body is rendered somewhere");
5752
5753        assert!(
5754            endnote_page > last_body_page,
5755            "endnotes come after every body page, endnote on {endnote_page} and body to {last_body_page}"
5756        );
5757        assert!(
5758            !page_text(&output.pages[endnote_page]).contains("occupies"),
5759            "an endnote page carries no body text"
5760        );
5761    }
5762
5763    #[test]
5764    fn footnotes_and_endnotes_keep_their_own_regions() {
5765        let input = make_document_with_both_streams(2, 3);
5766        let mut engine = Engine::new();
5767        let output = engine.layout(&input).expect("layout succeeds");
5768
5769        let footnote_page = output
5770            .pages
5771            .iter()
5772            .position(|page| page_text(page).contains("FOOTNOTETEXT"))
5773            .expect("the footnote is rendered");
5774
5775        // The footnote shares the page that carries its reference.
5776        assert!(
5777            page_text(&output.pages[footnote_page]).contains("occupies"),
5778            "a footnote sits on the page carrying its reference"
5779        );
5780        assert!(
5781            separator_y_of(&output.pages[footnote_page]).is_some(),
5782            "the footnote page draws a separator"
5783        );
5784
5785        // The endnote page is a different page, and draws no separator,
5786        // because there is no body text there to divide it from.
5787        let endnote_page = output
5788            .pages
5789            .iter()
5790            .position(|page| page_text(page).contains("ENDNOTETEXT"))
5791            .expect("the endnote is rendered");
5792        assert_ne!(footnote_page, endnote_page, "the two regions are distinct");
5793        assert!(
5794            separator_y_of(&output.pages[endnote_page]).is_none(),
5795            "an endnote page draws no separator rule"
5796        );
5797    }
5798
5799    #[test]
5800    fn an_endnote_reference_does_not_reserve_space_at_the_page_foot() {
5801        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
5802        use rdocx_oxml::text::CT_R;
5803
5804        // The same document twice, once with an endnote reference and once
5805        // with none. An endnote costs its page nothing, so the body must
5806        // paginate identically.
5807        let build = |with_endnote: bool| {
5808            let mut doc = rdocx_oxml::document::CT_Document::new();
5809            for index in 0..60 {
5810                let mut para = CT_P::new();
5811                para.add_run("Body paragraph text that occupies a line of the page.");
5812                if index == 0 && with_endnote {
5813                    let mut end = CT_R::new("");
5814                    end.content = vec![RunContent::EndnoteRef { id: 1 }];
5815                    para.runs.push(end);
5816                }
5817                doc.body.add_paragraph(para);
5818            }
5819            let mut note = CT_P::new();
5820            note.add_run("An endnote that would be tall in the margin.");
5821            LayoutInput {
5822                revision_view: crate::input::RevisionView::Accepted,
5823                document: doc,
5824                styles: CT_Styles::new_default(),
5825                numbering: None,
5826                headers: HashMap::new(),
5827                footers: HashMap::new(),
5828                images: HashMap::new(),
5829                charts: HashMap::new(),
5830                chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
5831                chart_color_map: oxml_drawing::color::ColorMap::default(),
5832                core_properties: None,
5833                hyperlink_urls: HashMap::new(),
5834                footnotes: None,
5835                endnotes: Some(CT_Footnotes {
5836                    footnotes: vec![CT_Footnote {
5837                        id: 1,
5838                        note_type: NoteType::Normal,
5839                        paragraphs: vec![note],
5840                    }],
5841                }),
5842                theme: None,
5843                fonts: Vec::new(),
5844            }
5845        };
5846
5847        let mut engine = Engine::new();
5848        let plain = engine.layout(&build(false)).expect("layout succeeds");
5849        let noted = engine.layout(&build(true)).expect("layout succeeds");
5850
5851        // One extra page for the endnote itself, and no separator anywhere.
5852        assert_eq!(
5853            noted.pages.len(),
5854            plain.pages.len() + 1,
5855            "an endnote adds its own page and takes none from the body"
5856        );
5857        for (index, page) in noted.pages.iter().enumerate() {
5858            if index < plain.pages.len() {
5859                assert!(
5860                    separator_y_of(page).is_none(),
5861                    "page {} reserved foot space for an endnote",
5862                    index + 1
5863                );
5864            }
5865        }
5866
5867        // Body pagination is untouched.
5868        for (index, plain_page) in plain.pages.iter().enumerate() {
5869            let body_lines = |page: &oxml_layout::output::PageFrame| {
5870                page.elements
5871                    .iter()
5872                    .filter(|element| {
5873                        matches!(element, PositionedElement::Text(run)
5874                            if run.text.starts_with("occupies"))
5875                    })
5876                    .count()
5877            };
5878            assert_eq!(
5879                body_lines(plain_page),
5880                body_lines(&noted.pages[index]),
5881                "page {} holds a different amount of body text",
5882                index + 1
5883            );
5884        }
5885    }
5886
5887    // F-X016, text wrapping around a floating drawing.
5888
5889    /// A document of one long paragraph, with a floating drawing anchored to
5890    /// it. `align` places the drawing, `wrap` says how text should treat it.
5891    fn make_wrapping_document(
5892        wrap: rdocx_oxml::drawing::WrapType,
5893        align: Option<rdocx_oxml::drawing::AnchorAlignH>,
5894        width_pt: f64,
5895        height_pt: f64,
5896        dist_pt: f64,
5897    ) -> LayoutInput {
5898        use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV};
5899        use rdocx_oxml::text::CT_R;
5900        use rdocx_oxml::units::Emu;
5901
5902        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
5903
5904        let mut doc = rdocx_oxml::document::CT_Document::new();
5905        let mut para = CT_P::new();
5906        // Long enough that many lines sit below the drawing, which is what
5907        // makes "returns to the margin" a meaningful assertion.
5908        let mut body = String::new();
5909        for index in 0..40 {
5910            body.push_str(&format!(
5911                "Sentence {index} of running text that fills the paragraph out. "
5912            ));
5913        }
5914        para.add_run(&body);
5915
5916        let mut anchor = CT_Anchor::background("rId1", 0, 0);
5917        anchor.extent_cx = emu(width_pt);
5918        anchor.extent_cy = emu(height_pt);
5919        anchor.behind_doc = false;
5920        anchor.wrap = wrap;
5921        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
5922        anchor.pos_h_align = align;
5923        anchor.pos_v_relative_from = ST_RelativeFromV::Paragraph;
5924        anchor.pos_v_offset = Emu(0);
5925        anchor.dist_t = emu(dist_pt);
5926        anchor.dist_b = emu(dist_pt);
5927        anchor.dist_l = emu(dist_pt);
5928        anchor.dist_r = emu(dist_pt);
5929
5930        let mut drawing_run = CT_R::new("");
5931        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
5932            inline: None,
5933            anchor: Some(anchor),
5934        })];
5935        para.runs.push(drawing_run);
5936        doc.body.add_paragraph(para);
5937
5938        let mut images = HashMap::new();
5939        images.insert(
5940            "rId1".to_string(),
5941            ImageData {
5942                data: vec![0u8; 8],
5943                content_type: "image/png".to_string(),
5944            },
5945        );
5946
5947        LayoutInput {
5948            revision_view: crate::input::RevisionView::Accepted,
5949            document: doc,
5950            styles: CT_Styles::new_default(),
5951            numbering: None,
5952            headers: HashMap::new(),
5953            footers: HashMap::new(),
5954            images,
5955            charts: HashMap::new(),
5956            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
5957            chart_color_map: oxml_drawing::color::ColorMap::default(),
5958            core_properties: None,
5959            hyperlink_urls: HashMap::new(),
5960            footnotes: None,
5961            endnotes: None,
5962            theme: None,
5963            fonts: Vec::new(),
5964        }
5965    }
5966
5967    /// The x origin and right edge of every body text run, by line.
5968    fn text_extents(page: &oxml_layout::output::PageFrame) -> Vec<(f64, f64)> {
5969        let mut by_line: Vec<(f64, f64, f64)> = Vec::new();
5970        for element in &page.elements {
5971            let PositionedElement::Text(run) = element else {
5972                continue;
5973            };
5974            let right = run.origin.x + run.advances.iter().sum::<f64>();
5975            if let Some(entry) = by_line
5976                .iter_mut()
5977                .find(|(y, _, _)| (*y - run.origin.y).abs() < 0.01)
5978            {
5979                entry.1 = entry.1.min(run.origin.x);
5980                entry.2 = entry.2.max(right);
5981            } else {
5982                by_line.push((run.origin.y, run.origin.x, right));
5983            }
5984        }
5985        by_line.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
5986        by_line.into_iter().map(|(_, l, r)| (l, r)).collect()
5987    }
5988
5989    #[test]
5990    fn text_wraps_beside_a_left_aligned_square_drawing() {
5991        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
5992
5993        let input =
5994            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
5995        let mut engine = Engine::new();
5996        let output = engine.layout(&input).expect("layout succeeds");
5997        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
5998        let extents = text_extents(&output.pages[0]);
5999
6000        assert!(
6001            extents.len() > 2,
6002            "the paragraph must wrap, got {extents:?}"
6003        );
6004
6005        // Lines beside the drawing start to its right, past width plus distR.
6006        let expected_left = geometry.margin_left + 100.0 + 5.0;
6007        assert!(
6008            (extents[0].0 - expected_left).abs() < 1.0,
6009            "first line should start at {expected_left}, got {:?}",
6010            extents[0]
6011        );
6012
6013        // A line below the drawing returns to the margin.
6014        let last = extents.last().unwrap();
6015        assert!(
6016            (last.0 - geometry.margin_left).abs() < 1.0,
6017            "the last line should return to the margin, got {last:?}"
6018        );
6019    }
6020
6021    #[test]
6022    fn text_wraps_beside_a_right_aligned_square_drawing() {
6023        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
6024
6025        let input = make_wrapping_document(
6026            WrapType::Square,
6027            Some(AnchorAlignH::Right),
6028            100.0,
6029            40.0,
6030            5.0,
6031        );
6032        let mut engine = Engine::new();
6033        let output = engine.layout(&input).expect("layout succeeds");
6034        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
6035        let extents = text_extents(&output.pages[0]);
6036
6037        assert!(
6038            extents.len() > 2,
6039            "the paragraph must wrap, got {extents:?}"
6040        );
6041
6042        // Lines beside the drawing still start at the margin but end early.
6043        let text_right = geometry.page_width - geometry.margin_right;
6044        let drawing_left = text_right - 100.0;
6045        assert!(
6046            (extents[0].0 - geometry.margin_left).abs() < 1.0,
6047            "a right-aligned drawing does not move the line start, got {:?}",
6048            extents[0]
6049        );
6050        assert!(
6051            extents[0].1 <= drawing_left - 5.0 + 1.0,
6052            "the first line should stop before the drawing at {}, got {:?}",
6053            drawing_left - 5.0,
6054            extents[0]
6055        );
6056
6057        // Some line below the drawing runs past where the drawing sat, which
6058        // is only possible once the reservation stops applying. The final line
6059        // of a paragraph is naturally short, so the widest is the fair test.
6060        let widest = extents
6061            .iter()
6062            .map(|(_, right)| *right)
6063            .fold(f64::MIN, f64::max);
6064        assert!(
6065            widest > drawing_left,
6066            "a line below the drawing should reach past {drawing_left}, got {extents:?}"
6067        );
6068    }
6069
6070    #[test]
6071    fn a_top_and_bottom_drawing_pushes_text_below_it() {
6072        use rdocx_oxml::drawing::WrapType;
6073
6074        let input = make_wrapping_document(WrapType::TopAndBottom, None, 100.0, 40.0, 5.0);
6075        let mut engine = Engine::new();
6076        let output = engine.layout(&input).expect("layout succeeds");
6077        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
6078        let extents = text_extents(&output.pages[0]);
6079
6080        assert!(!extents.is_empty(), "the paragraph renders");
6081
6082        // The drawing sits at the paragraph top, so text starts below its
6083        // bottom edge plus distB.
6084        let first_baseline = output.pages[0]
6085            .elements
6086            .iter()
6087            .find_map(|element| match element {
6088                PositionedElement::Text(run) => Some(run.origin.y),
6089                _ => None,
6090            })
6091            .expect("text is rendered");
6092        let drawing_bottom = geometry.margin_top + 40.0 + 5.0;
6093        assert!(
6094            first_baseline >= drawing_bottom,
6095            "the first line at {first_baseline} should sit below {drawing_bottom}"
6096        );
6097    }
6098
6099    #[test]
6100    fn a_wrap_none_drawing_leaves_text_untouched() {
6101        use rdocx_oxml::drawing::WrapType;
6102
6103        // The identity case. A drawing that does not wrap must not move a
6104        // single glyph, which is what keeps every recorded baseline still.
6105        let with = make_wrapping_document(WrapType::None, None, 100.0, 40.0, 5.0);
6106        let mut engine = Engine::new();
6107        let output = engine.layout(&with).expect("layout succeeds");
6108        let wrapped_extents = text_extents(&output.pages[0]);
6109
6110        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
6111        for (left, _) in &wrapped_extents {
6112            assert!(
6113                (left - geometry.margin_left).abs() < 0.01,
6114                "a wrapNone drawing must not indent any line, got {wrapped_extents:?}"
6115            );
6116        }
6117    }
6118
6119    #[test]
6120    fn a_drawing_anchored_to_a_later_paragraph_still_pushes_text_aside() {
6121        use rdocx_oxml::drawing::{
6122            AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
6123            WrapType,
6124        };
6125        use rdocx_oxml::text::CT_R;
6126        use rdocx_oxml::units::Emu;
6127
6128        // Word routinely anchors the arrow beside a paragraph to the paragraph
6129        // after it, which is what the external contribution's own sample does.
6130        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
6131        let mut doc = rdocx_oxml::document::CT_Document::new();
6132
6133        let mut first = CT_P::new();
6134        let mut body = String::new();
6135        for index in 0..40 {
6136            body.push_str(&format!("Sentence {index} of running text to fill lines. "));
6137        }
6138        first.add_run(&body);
6139        doc.body.add_paragraph(first);
6140
6141        let mut second = CT_P::new();
6142        second.add_run("A later paragraph that owns the drawing.");
6143        let mut anchor = CT_Anchor::background("rId1", 0, 0);
6144        anchor.extent_cx = emu(100.0);
6145        anchor.extent_cy = emu(40.0);
6146        anchor.behind_doc = false;
6147        anchor.wrap = WrapType::Square;
6148        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
6149        anchor.pos_h_align = Some(AnchorAlignH::Left);
6150        // Margin-relative, so its position does not depend on where the
6151        // paragraph that owns it lands.
6152        anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
6153        anchor.pos_v_align = Some(AnchorAlignV::Top);
6154        let mut drawing_run = CT_R::new("");
6155        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
6156            inline: None,
6157            anchor: Some(anchor),
6158        })];
6159        second.runs.push(drawing_run);
6160        doc.body.add_paragraph(second);
6161
6162        let mut images = HashMap::new();
6163        images.insert(
6164            "rId1".to_string(),
6165            ImageData {
6166                data: vec![0u8; 8],
6167                content_type: "image/png".to_string(),
6168            },
6169        );
6170
6171        let input = LayoutInput {
6172            revision_view: crate::input::RevisionView::Accepted,
6173            document: doc,
6174            styles: CT_Styles::new_default(),
6175            numbering: None,
6176            headers: HashMap::new(),
6177            footers: HashMap::new(),
6178            images,
6179            charts: HashMap::new(),
6180            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
6181            chart_color_map: oxml_drawing::color::ColorMap::default(),
6182            core_properties: None,
6183            hyperlink_urls: HashMap::new(),
6184            footnotes: None,
6185            endnotes: None,
6186            theme: None,
6187            fonts: Vec::new(),
6188        };
6189
6190        let mut engine = Engine::new();
6191        let output = engine.layout(&input).expect("layout succeeds");
6192        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
6193        let extents = text_extents(&output.pages[0]);
6194
6195        assert!(!extents.is_empty(), "text renders");
6196        let expected_left = geometry.margin_left + 100.0;
6197        assert!(
6198            extents[0].0 >= expected_left - 1.0,
6199            "the first line of the earlier paragraph should clear the drawing at \
6200             {expected_left}, got {:?}",
6201            extents[0]
6202        );
6203    }
6204
6205    #[test]
6206    fn a_split_paragraph_clearing_a_drawing_stays_inside_the_page() {
6207        use rdocx_oxml::drawing::{
6208            AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
6209            WrapType,
6210        };
6211        use rdocx_oxml::text::CT_R;
6212        use rdocx_oxml::units::Emu;
6213
6214        // A top-and-bottom drawing pushes the paragraph's content down, and the
6215        // paragraph is long enough to split. The offset has to be counted where
6216        // the split point is decided, or the last lines run off the page.
6217        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
6218        let mut doc = rdocx_oxml::document::CT_Document::new();
6219        let mut para = CT_P::new();
6220        let mut body = String::new();
6221        for index in 0..300 {
6222            body.push_str(&format!("Sentence {index} of a very long paragraph. "));
6223        }
6224        para.add_run(&body);
6225
6226        let mut anchor = CT_Anchor::background("rId1", 0, 0);
6227        anchor.extent_cx = emu(200.0);
6228        anchor.extent_cy = emu(120.0);
6229        anchor.behind_doc = false;
6230        anchor.wrap = WrapType::TopAndBottom;
6231        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
6232        anchor.pos_h_align = Some(AnchorAlignH::Center);
6233        anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
6234        anchor.pos_v_align = Some(AnchorAlignV::Top);
6235        anchor.dist_b = emu(10.0);
6236        let mut drawing_run = CT_R::new("");
6237        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
6238            inline: None,
6239            anchor: Some(anchor),
6240        })];
6241        para.runs.push(drawing_run);
6242        doc.body.add_paragraph(para);
6243
6244        let mut images = HashMap::new();
6245        images.insert(
6246            "rId1".to_string(),
6247            ImageData {
6248                data: vec![0u8; 8],
6249                content_type: "image/png".to_string(),
6250            },
6251        );
6252
6253        let input = LayoutInput {
6254            revision_view: crate::input::RevisionView::Accepted,
6255            document: doc,
6256            styles: CT_Styles::new_default(),
6257            numbering: None,
6258            headers: HashMap::new(),
6259            footers: HashMap::new(),
6260            images,
6261            charts: HashMap::new(),
6262            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
6263            chart_color_map: oxml_drawing::color::ColorMap::default(),
6264            core_properties: None,
6265            hyperlink_urls: HashMap::new(),
6266            footnotes: None,
6267            endnotes: None,
6268            theme: None,
6269            fonts: Vec::new(),
6270        };
6271
6272        let mut engine = Engine::new();
6273        let output = engine.layout(&input).expect("layout succeeds");
6274        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
6275        let bottom = geometry.page_height - geometry.margin_bottom;
6276
6277        assert!(output.pages.len() > 1, "the paragraph must split");
6278        for (index, page) in output.pages.iter().enumerate() {
6279            for element in &page.elements {
6280                let PositionedElement::Text(run) = element else {
6281                    continue;
6282                };
6283                assert!(
6284                    run.origin.y <= bottom + 0.5,
6285                    "page {} draws text at {}, past the bottom margin at {bottom}",
6286                    index + 1,
6287                    run.origin.y
6288                );
6289            }
6290        }
6291    }
6292
6293    // F-X017, notes broken to their own section's width.
6294
6295    /// Text long enough to wrap at either measure under test, so a change of
6296    /// measure changes the number of lines rather than nothing at all.
6297    const NOTE_PROSE: &str = "A note long enough that the measure it is broken \
6298        to decides how many lines it occupies, which is the whole point of \
6299        breaking it to the width of the section that references it rather than \
6300        to the width of whichever section happens to come last in the document.";
6301
6302    /// A document whose first section is `first_page_width` twips wide and
6303    /// whose body-level final section is letter portrait. The first section
6304    /// references note 1 and the second references note 2, and both notes carry
6305    /// the same text, so a difference in their line counts is a difference in
6306    /// the measure each was broken to.
6307    fn make_two_section_input(first_page_width: i32, endnotes_instead: bool) -> LayoutInput {
6308        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
6309        use rdocx_oxml::text::CT_R;
6310        use rdocx_oxml::units::Twips;
6311
6312        let note_of = |id: i32| {
6313            let mut note = CT_P::new();
6314            note.add_run(NOTE_PROSE);
6315            CT_Footnote {
6316                id,
6317                note_type: NoteType::Normal,
6318                paragraphs: vec![note],
6319            }
6320        };
6321        let reference = |id: i32| {
6322            let mut run = CT_R::new("");
6323            run.content = vec![if endnotes_instead {
6324                RunContent::EndnoteRef { id }
6325            } else {
6326                RunContent::FootnoteRef { id }
6327            }];
6328            run
6329        };
6330
6331        let mut first_sect = CT_SectPr::default_letter();
6332        first_sect.page_width = Some(Twips(first_page_width));
6333
6334        let mut doc = rdocx_oxml::document::CT_Document::new();
6335
6336        // The paragraph carrying a sectPr is the one that ends its section.
6337        let mut first = CT_P::new();
6338        first.add_run("Body text in the first section");
6339        first.runs.push(reference(1));
6340        first.properties = Some(rdocx_oxml::properties::CT_PPr {
6341            sect_pr: Some(first_sect),
6342            ..Default::default()
6343        });
6344        doc.body.add_paragraph(first);
6345
6346        let mut second = CT_P::new();
6347        second.add_run("Body text in the second section");
6348        second.runs.push(reference(2));
6349        doc.body.add_paragraph(second);
6350        doc.body.sect_pr = Some(CT_SectPr::default_letter());
6351
6352        let stream = CT_Footnotes {
6353            footnotes: vec![note_of(1), note_of(2)],
6354        };
6355        LayoutInput {
6356            revision_view: crate::input::RevisionView::Accepted,
6357            document: doc,
6358            styles: CT_Styles::new_default(),
6359            numbering: None,
6360            headers: HashMap::new(),
6361            footers: HashMap::new(),
6362            images: HashMap::new(),
6363            charts: HashMap::new(),
6364            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
6365            chart_color_map: oxml_drawing::color::ColorMap::default(),
6366            core_properties: None,
6367            hyperlink_urls: HashMap::new(),
6368            footnotes: (!endnotes_instead).then(|| stream.clone()),
6369            endnotes: endnotes_instead.then_some(stream),
6370            theme: None,
6371            fonts: Vec::new(),
6372        }
6373    }
6374
6375    /// How many distinct baselines a page drew below its separator rule. A page
6376    /// without notes gives zero.
6377    ///
6378    /// This is the note's line count plus one for each note drawn, because a
6379    /// marker sits a rise above the line it belongs to and so has a baseline of
6380    /// its own. Every use below compares two of these counts over documents
6381    /// drawing the same number of notes, where the offset cancels.
6382    fn note_baseline_count(page: &oxml_layout::output::PageFrame) -> usize {
6383        let Some(separator_y) = separator_y_of(page) else {
6384            return 0;
6385        };
6386        let mut baselines: Vec<f64> = page
6387            .elements
6388            .iter()
6389            .filter_map(|element| match element {
6390                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.y),
6391                _ => None,
6392            })
6393            .collect();
6394        baselines.sort_by(|a, b| a.partial_cmp(b).expect("baselines are finite"));
6395        baselines.dedup_by(|a, b| (*a - *b).abs() < 0.01);
6396        baselines.len()
6397    }
6398
6399    #[test]
6400    fn a_note_is_broken_to_the_width_of_its_own_section() {
6401        // 17 inches wide against letter's 8.5, so the wide section's measure is
6402        // unmistakably different rather than different by a rounding.
6403        let output = Engine::new()
6404            .layout(&make_two_section_input(24480, false))
6405            .expect("layout succeeds");
6406
6407        let wide = note_baseline_count(&output.pages[0]);
6408        let narrow = note_baseline_count(&output.pages[1]);
6409
6410        assert!(wide > 0 && narrow > 0, "both sections must draw their note");
6411        assert!(
6412            wide < narrow,
6413            "the same note took {wide} lines in the wide section and {narrow} \
6414             in the narrow one, so both were broken to one measure"
6415        );
6416    }
6417
6418    #[test]
6419    fn a_single_section_document_lays_notes_out_exactly_as_before() {
6420        // Two sections of identical geometry are the same document as one, so
6421        // the width key must collapse them. Any difference here is the fix
6422        // moving output it had no business moving.
6423        let two = Engine::new()
6424            .layout(&make_two_section_input(12240, false))
6425            .expect("layout succeeds");
6426
6427        let single = Engine::new()
6428            .layout(&make_input_with_footnote(&[NOTE_PROSE]))
6429            .expect("layout succeeds");
6430
6431        assert_eq!(
6432            note_baseline_count(&two.pages[0]),
6433            note_baseline_count(&single.pages[0]),
6434            "a note in a letter section stopped matching the same note in a \
6435             single-section letter document"
6436        );
6437
6438        // And the same document laid out twice is still the same document.
6439        let again = Engine::new()
6440            .layout(&make_input_with_footnote(&[NOTE_PROSE]))
6441            .expect("layout succeeds");
6442        assert_eq!(single.pages.len(), again.pages.len());
6443        assert_eq!(single.pages[0].elements, again.pages[0].elements);
6444    }
6445
6446    #[test]
6447    fn an_endnote_is_broken_to_the_final_sections_width() {
6448        // Endnotes are emitted after the last body page and drawn against the
6449        // final section's geometry, so that is the measure they must be broken
6450        // to even when the reference sits in a wider section.
6451        let wide_first = Engine::new()
6452            .layout(&make_two_section_input(24480, true))
6453            .expect("layout succeeds");
6454        let all_narrow = Engine::new()
6455            .layout(&make_two_section_input(12240, true))
6456            .expect("layout succeeds");
6457
6458        // Endnotes are emitted on their own pages after every body page, and
6459        // this document has one short paragraph per section, so everything
6460        // drawn after the second page is endnote content.
6461        let endnote_lines = |output: &LayoutResult| {
6462            output.pages[2..]
6463                .iter()
6464                .map(|page| {
6465                    page.elements
6466                        .iter()
6467                        .filter(|element| matches!(element, PositionedElement::Text(_)))
6468                        .count()
6469                })
6470                .sum::<usize>()
6471        };
6472
6473        assert_eq!(wide_first.pages.len(), all_narrow.pages.len());
6474        assert!(
6475            all_narrow.pages.len() > 2,
6476            "the endnotes must reach pages of their own"
6477        );
6478        assert!(endnote_lines(&all_narrow) > 0, "the endnotes must be drawn");
6479        assert_eq!(
6480            endnote_lines(&wide_first),
6481            endnote_lines(&all_narrow),
6482            "an endnote whose reference sits in a wide section was broken to \
6483             that section rather than to the final one it is drawn in"
6484        );
6485    }
6486
6487    // F-X019, paragraph-relative drawings in later blocks should wrap.
6488
6489    /// Two paragraphs, the second anchoring a wrapping drawing measured from
6490    /// `rel_v`. The first paragraph is the earlier text that should flow around
6491    /// it, which is the whole question: the drawing belongs to a block that has
6492    /// not been placed when the first paragraph is being laid out.
6493    fn make_lookahead_document(
6494        rel_v: rdocx_oxml::drawing::ST_RelativeFromV,
6495        wrap: rdocx_oxml::drawing::WrapType,
6496        off_v_pt: f64,
6497    ) -> LayoutInput {
6498        use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH};
6499        use rdocx_oxml::text::CT_R;
6500        use rdocx_oxml::units::Emu;
6501
6502        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
6503
6504        let mut doc = rdocx_oxml::document::CT_Document::new();
6505
6506        let mut first = CT_P::new();
6507        let mut body = String::new();
6508        for index in 0..30 {
6509            body.push_str(&format!(
6510                "Sentence {index} of running text that fills the paragraph out. "
6511            ));
6512        }
6513        first.add_run(&body);
6514        doc.body.add_paragraph(first);
6515
6516        let mut second = CT_P::new();
6517        second.add_run("The paragraph the drawing is anchored to.");
6518        let mut anchor = CT_Anchor::background("rId1", 0, 0);
6519        anchor.extent_cx = emu(200.0);
6520        anchor.extent_cy = emu(120.0);
6521        anchor.behind_doc = false;
6522        anchor.wrap = wrap;
6523        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
6524        anchor.pos_h_align = Some(rdocx_oxml::drawing::AnchorAlignH::Right);
6525        anchor.pos_v_relative_from = rel_v;
6526        // The offset is measured from `rel_v`, so the two cases need different
6527        // numbers to land in the same band of the page. Above its own
6528        // paragraph for the paragraph-relative case, and a fixed way down the
6529        // page for the page-relative one. A drawing that lands below every line
6530        // of the first paragraph pushes nothing aside and would prove nothing.
6531        anchor.pos_v_offset = emu(off_v_pt);
6532
6533        let mut drawing_run = CT_R::new("");
6534        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
6535            inline: None,
6536            anchor: Some(anchor),
6537        })];
6538        second.runs.push(drawing_run);
6539        doc.body.add_paragraph(second);
6540
6541        let mut images = HashMap::new();
6542        images.insert(
6543            "rId1".to_string(),
6544            ImageData {
6545                data: vec![0u8; 8],
6546                content_type: "image/png".to_string(),
6547            },
6548        );
6549
6550        LayoutInput {
6551            revision_view: crate::input::RevisionView::Accepted,
6552            document: doc,
6553            styles: CT_Styles::new_default(),
6554            numbering: None,
6555            headers: HashMap::new(),
6556            footers: HashMap::new(),
6557            images,
6558            charts: HashMap::new(),
6559            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
6560            chart_color_map: oxml_drawing::color::ColorMap::default(),
6561            core_properties: None,
6562            hyperlink_urls: HashMap::new(),
6563            footnotes: None,
6564            endnotes: None,
6565            theme: None,
6566            fonts: Vec::new(),
6567        }
6568    }
6569
6570    /// How many lines of body text the document drew, across every page.
6571    fn body_line_count(output: &LayoutResult) -> usize {
6572        output
6573            .pages
6574            .iter()
6575            .map(|page| text_extents(page).len())
6576            .sum()
6577    }
6578
6579    #[test]
6580    fn a_paragraph_relative_wrapping_drawing_pushes_earlier_text_aside() {
6581        use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
6582
6583        // The same document twice, differing only in whether the drawing
6584        // wraps. Narrowed lines hold less text, so the paragraph needs more of
6585        // them, and that is visible without depending on where any one line
6586        // broke.
6587        let wrapping = Engine::new()
6588            .layout(&make_lookahead_document(
6589                ST_RelativeFromV::Paragraph,
6590                WrapType::Square,
6591                -120.0,
6592            ))
6593            .expect("layout succeeds");
6594        let ignoring = Engine::new()
6595            .layout(&make_lookahead_document(
6596                ST_RelativeFromV::Paragraph,
6597                WrapType::None,
6598                -120.0,
6599            ))
6600            .expect("layout succeeds");
6601
6602        assert!(
6603            body_line_count(&wrapping) > body_line_count(&ignoring),
6604            "the earlier paragraph took {} lines against {}, so it flowed \
6605             through the drawing rather than around it",
6606            body_line_count(&wrapping),
6607            body_line_count(&ignoring)
6608        );
6609    }
6610
6611    #[test]
6612    fn a_page_relative_drawing_in_a_later_block_still_wraps() {
6613        use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
6614
6615        // F-X016's case, which the second pass must not disturb. This document
6616        // has no paragraph-relative wrap, so it paginates in one pass.
6617        let wrapping = Engine::new()
6618            .layout(&make_lookahead_document(
6619                ST_RelativeFromV::Page,
6620                WrapType::Square,
6621                150.0,
6622            ))
6623            .expect("layout succeeds");
6624        let ignoring = Engine::new()
6625            .layout(&make_lookahead_document(
6626                ST_RelativeFromV::Page,
6627                WrapType::None,
6628                150.0,
6629            ))
6630            .expect("layout succeeds");
6631
6632        assert!(body_line_count(&wrapping) > body_line_count(&ignoring));
6633    }
6634
6635    #[test]
6636    fn a_second_pass_is_stable_for_the_document_that_earns_it() {
6637        use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
6638
6639        // Two passes, not a fixed point, so the guarantee is that the answer is
6640        // the same answer every time rather than that it has converged.
6641        let build = || {
6642            Engine::new()
6643                .layout(&make_lookahead_document(
6644                    ST_RelativeFromV::Paragraph,
6645                    WrapType::Square,
6646                    -120.0,
6647                ))
6648                .expect("layout succeeds")
6649        };
6650        let first = build();
6651        let second = build();
6652
6653        assert_eq!(first.pages.len(), second.pages.len());
6654        for (index, page) in first.pages.iter().enumerate() {
6655            assert_eq!(
6656                page.elements,
6657                second.pages[index].elements,
6658                "page {} differs between two runs",
6659                index + 1
6660            );
6661        }
6662    }
6663
6664    fn cross_reference_run(instruction: &str, display: &str) -> rdocx_oxml::text::CT_R {
6665        let mut run = rdocx_oxml::text::CT_R::new("");
6666        run.content = vec![RunContent::Field(Field::new(instruction, display))];
6667        run
6668    }
6669
6670    fn target_paragraph(targets: &[(i32, &str, usize, usize)], text: &str, hidden: bool) -> CT_P {
6671        let mut paragraph = CT_P::new();
6672        paragraph.properties = Some(CT_PPr {
6673            page_break_before: Some(true),
6674            ..Default::default()
6675        });
6676        let mut run = rdocx_oxml::text::CT_R::new(text);
6677        if hidden {
6678            run.properties = Some(rdocx_oxml::properties::CT_RPr {
6679                vanish: Some(true),
6680                ..Default::default()
6681            });
6682        }
6683        paragraph.runs.push(run);
6684        for (id, name, start, end) in targets {
6685            assert!(paragraph.insert_bookmark_start(*start, *id, name));
6686            assert!(paragraph.insert_bookmark_end(*end, *id));
6687        }
6688        paragraph
6689    }
6690
6691    fn output_text(output: &LayoutResult) -> Vec<String> {
6692        let mut text = Vec::new();
6693        for page in &output.pages {
6694            oxml_layout::walk(&page.elements, &mut |element, _| {
6695                if let PositionedElement::Text(run) = element {
6696                    text.push(run.text.clone());
6697                }
6698            });
6699        }
6700        text
6701    }
6702
6703    fn deterministic_layout(input: &LayoutInput) -> LayoutResult {
6704        Engine::new_deterministic()
6705            .expect("bundled fonts")
6706            .layout(input)
6707            .expect("layout succeeds")
6708    }
6709
6710    #[test]
6711    fn an_unsupported_complex_field_keeps_its_cached_display() {
6712        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>DATE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>17 August 2026</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
6713        let mut input = make_input_with_text("");
6714        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
6715
6716        let text = output_text(&deterministic_layout(&input));
6717        assert!(text.concat().contains("17 August 2026"), "{text:?}");
6718    }
6719
6720    #[test]
6721    fn a_complex_field_keeps_each_cached_result_runs_formatting() {
6722        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>DATE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>bold</w:t></w:r><w:r><w:rPr><w:i/></w:rPr><w:t>italic</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
6723        let mut input = make_input_with_text("");
6724        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
6725
6726        let output = deterministic_layout(&input);
6727        let mut displays = Vec::new();
6728        for page in &output.pages {
6729            oxml_layout::walk(&page.elements, &mut |element, _| {
6730                if let PositionedElement::Text(run) = element
6731                    && matches!(run.text.as_str(), "bold" | "italic")
6732                {
6733                    displays.push((run.text.clone(), run.bold, run.italic));
6734                }
6735            });
6736        }
6737        assert_eq!(
6738            displays,
6739            vec![
6740                ("bold".to_owned(), true, false),
6741                ("italic".to_owned(), false, true)
6742            ]
6743        );
6744    }
6745
6746    #[test]
6747    fn a_computed_complex_field_keeps_its_cached_result_run_formatting() {
6748        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>PAGE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr><w:b/><w:i/></w:rPr><w:t>99</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
6749        let mut input = make_input_with_text("");
6750        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
6751        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
6752            panic!("expected paragraph")
6753        };
6754        let RunContent::Field(field) = &mut paragraph.runs[0].content[0] else {
6755            panic!("expected field")
6756        };
6757        field.cached_result = "edited stored value".to_owned();
6758
6759        let output = deterministic_layout(&input);
6760        let mut displays = Vec::new();
6761        for page in &output.pages {
6762            oxml_layout::walk(&page.elements, &mut |element, _| {
6763                if let PositionedElement::Text(run) = element
6764                    && run.text == "1"
6765                {
6766                    displays.push((run.bold, run.italic));
6767                }
6768            });
6769        }
6770        assert_eq!(displays, vec![(true, true)]);
6771    }
6772
6773    #[test]
6774    fn a_pageref_inside_a_table_uses_the_final_target_page() {
6775        use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
6776
6777        let mut input = make_input_with_text("");
6778        input.document.body.content.clear();
6779        let mut field = CT_P::new();
6780        field
6781            .runs
6782            .push(cross_reference_run("PAGEREF destination", "cached"));
6783        let mut cell = CT_Tc::new();
6784        cell.content = vec![CellContent::Paragraph(field)];
6785        let mut row = CT_Row::new();
6786        row.cells.push(cell);
6787        let mut table = CT_Tbl::new();
6788        table.rows.push(row);
6789        input.document.body.content.push(BodyContent::Table(table));
6790        input.document.body.add_paragraph(target_paragraph(
6791            &[(4, "destination", 0, 1)],
6792            "target",
6793            false,
6794        ));
6795
6796        let output = deterministic_layout(&input);
6797        let text = output_text(&output);
6798        assert!(text.iter().any(|value| value == "2"), "{text:?}");
6799        assert!(!text.iter().any(|value| value == "cached"), "{text:?}");
6800    }
6801
6802    #[test]
6803    fn a_resolved_pageref_uses_a_fixed_pagination_placeholder() {
6804        let build = |display: &str| {
6805            let mut input = make_input_with_text("");
6806            input.document.body.content.clear();
6807            let mut field = CT_P::new();
6808            field
6809                .runs
6810                .push(cross_reference_run("PAGEREF destination", display));
6811            input.document.body.add_paragraph(field);
6812            input.document.body.add_paragraph(target_paragraph(
6813                &[(4, "destination", 0, 1)],
6814                "target",
6815                false,
6816            ));
6817            deterministic_layout(&input)
6818        };
6819        let short = build("7");
6820        let long = build(&"stale display ".repeat(1000));
6821
6822        assert_eq!(short.pages.len(), long.pages.len());
6823        assert_eq!(output_text(&short), output_text(&long));
6824    }
6825
6826    #[test]
6827    fn every_target_at_a_paragraph_end_is_retained() {
6828        let mut input = make_input_with_text("");
6829        input.document.body.content.clear();
6830        let mut fields = CT_P::new();
6831        for name in ["first", "second"] {
6832            fields
6833                .runs
6834                .push(cross_reference_run(&format!("PAGEREF {name}"), "cached"));
6835        }
6836        input.document.body.add_paragraph(fields);
6837        input.document.body.add_paragraph(target_paragraph(
6838            &[(4, "first", 1, 1), (5, "second", 1, 1)],
6839            "target",
6840            false,
6841        ));
6842
6843        let text = output_text(&deterministic_layout(&input));
6844        assert_eq!(
6845            text.iter().filter(|value| value.as_str() == "2").count(),
6846            2,
6847            "{text:?}"
6848        );
6849    }
6850
6851    #[test]
6852    fn a_target_before_hidden_text_is_retained() {
6853        let mut input = make_input_with_text("");
6854        input.document.body.content.clear();
6855        let mut field = CT_P::new();
6856        field
6857            .runs
6858            .push(cross_reference_run("PAGEREF destination", "cached"));
6859        input.document.body.add_paragraph(field);
6860        input.document.body.add_paragraph(target_paragraph(
6861            &[(4, "destination", 0, 1)],
6862            "hidden target",
6863            true,
6864        ));
6865
6866        let text = output_text(&deterministic_layout(&input));
6867        assert!(text.iter().any(|value| value == "2"), "{text:?}");
6868        assert!(!text.iter().any(|value| value == "cached"), "{text:?}");
6869    }
6870}