Skip to main content

rdocx_layout/
engine.rs

1//! Layout engine orchestrator: ties all phases together.
2
3use std::collections::{HashMap, VecDeque};
4use std::sync::Arc;
5
6#[cfg(test)]
7use std::cell::Cell;
8
9use rdocx_oxml::borders::{CT_PBdr, CT_TabStop};
10use rdocx_oxml::content_control::{CT_Sdt, SdtContent};
11use rdocx_oxml::document::{BodyContent, CT_Document, CT_SectPr};
12use rdocx_oxml::drawing::WrapType;
13use rdocx_oxml::header_footer::{HdrFtrType, VmlWatermark};
14use rdocx_oxml::numbering::ST_LvlSuffix;
15use rdocx_oxml::properties::{CT_PPr, CT_RPr, CT_Shd};
16use rdocx_oxml::revision::{CT_Revision, RevisionContent, RevisionKind};
17use rdocx_oxml::shared::ST_HighlightColor;
18use rdocx_oxml::styles::CT_Styles;
19use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
20use rdocx_oxml::text::{
21    BookmarkMarker, BreakType, CT_P, CT_R, Field, FieldArgument, RunContent,
22    hyperlink_revision_index,
23};
24
25use crate::block::{
26    self, CellBlockSemantics, LayoutBlock, LayoutBlockLike, ParagraphBlock, ParagraphSemantics,
27    SharedLayoutBlock, TableSemantics,
28};
29use crate::convert;
30use crate::input::{LayoutInput, MediaRegistry, RevisionView};
31use crate::notes::NoteRegistry;
32use crate::paginator::{self, HeaderFooterContent, HeaderFooterSemantics, PageGeometry};
33use crate::style_resolver::{self, NumberingState};
34use crate::table;
35use crate::{WordSourcePath, WordStory};
36use oxml_layout::{
37    Color, Diagnostic, DocumentMetadata, DocumentStructure, FieldKind, FontId, FontManager,
38    GlyphRun, GroupElement, InlineItem, LayoutResult, LineItem, NoteRef, NoteStream, PageFrame,
39    Point, PositionedElement, Rect, Result, SourceNodeId, SourceSpan, StructureId, StructureNode,
40    StructureRole, TextDirection, TextSegment, Transform, Underline, break_into_lines,
41    break_multilingual_into_lines,
42};
43
44#[derive(Clone)]
45struct WordMultilingualStyle {
46    language: Option<String>,
47    language_east_asia: Option<String>,
48    language_bidi: Option<String>,
49    direction: TextDirection,
50    spacing: f64,
51}
52
53// Word positions exact-spaced text from a stable em baseline instead of each
54// fallback font's hhea ascent. Keeping this Word-specific prevents script font
55// metrics from moving otherwise identical lines vertically.
56const WORD_EXACT_LINE_BASELINE_EM: f64 = 0.8;
57
58#[derive(Clone, Copy)]
59struct ProjectedRun<'a> {
60    run: &'a CT_R,
61    boundary: usize,
62    raw_order: RawOrder,
63    ordinary_run_index: Option<usize>,
64    hyperlink_index: Option<usize>,
65    force_underline: bool,
66    force_strike: bool,
67}
68
69#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
70enum RawOrder {
71    BeforeRaw,
72    Raw(usize),
73    AfterRaw,
74}
75
76/// Immutable source identities allocated once before layout starts.
77pub(crate) struct SourceRegistry {
78    nodes: Vec<WordSourcePath>,
79    ids: HashMap<WordSourcePath, SourceNodeId>,
80    body_ids: Vec<Option<SourceNodeId>>,
81}
82
83impl SourceRegistry {
84    fn for_input(input: &LayoutInput) -> Self {
85        let mut registry = Self {
86            nodes: Vec::new(),
87            ids: HashMap::new(),
88            body_ids: Vec::with_capacity(input.document.body.content.len()),
89        };
90
91        for (body_index, content) in input.document.body.content.iter().enumerate() {
92            match content {
93                BodyContent::Paragraph(_) => {
94                    let id = registry.insert_node(WordSourcePath {
95                        story: WordStory::Document,
96                        children: vec![body_index],
97                    });
98                    registry.body_ids.push(Some(id));
99                }
100                BodyContent::Table(table) => {
101                    registry.body_ids.push(None);
102                    registry.collect_table(table, &WordStory::Document, &[body_index])
103                }
104                BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
105                    registry.body_ids.push(None);
106                }
107            }
108        }
109
110        let mut headers = input.headers.iter().collect::<Vec<_>>();
111        headers.sort_unstable_by_key(|(relationship_id, _)| *relationship_id);
112        for (relationship_id, header) in headers {
113            let story = WordStory::Header {
114                relationship_id: relationship_id.clone(),
115            };
116            for paragraph_index in 0..header.paragraphs.len() {
117                registry.insert(WordSourcePath {
118                    story: story.clone(),
119                    children: vec![paragraph_index],
120                });
121            }
122        }
123
124        let mut footers = input.footers.iter().collect::<Vec<_>>();
125        footers.sort_unstable_by_key(|(relationship_id, _)| *relationship_id);
126        for (relationship_id, footer) in footers {
127            let story = WordStory::Footer {
128                relationship_id: relationship_id.clone(),
129            };
130            for paragraph_index in 0..footer.paragraphs.len() {
131                registry.insert(WordSourcePath {
132                    story: story.clone(),
133                    children: vec![paragraph_index],
134                });
135            }
136        }
137
138        for (story_kind, stream) in [
139            (NoteStream::Footnote, input.footnotes.as_ref()),
140            (NoteStream::Endnote, input.endnotes.as_ref()),
141        ]
142        .into_iter()
143        .filter_map(|(story, stream)| stream.map(|stream| (story, stream)))
144        {
145            for note in &stream.footnotes {
146                if stream.get_by_id(note.id).is_none() {
147                    continue;
148                }
149                let story = match story_kind {
150                    NoteStream::Footnote => WordStory::Footnote { id: note.id },
151                    NoteStream::Endnote => WordStory::Endnote { id: note.id },
152                };
153                for paragraph_index in 0..note.paragraphs.len() {
154                    registry.insert(WordSourcePath {
155                        story: story.clone(),
156                        children: vec![paragraph_index],
157                    });
158                }
159            }
160        }
161
162        registry
163    }
164
165    fn collect_table(&mut self, table: &CT_Tbl, story: &WordStory, prefix: &[usize]) {
166        for (row_index, row) in table.rows.iter().enumerate() {
167            for (cell_index, cell) in row.cells.iter().enumerate() {
168                for (content_index, content) in cell.content.iter().enumerate() {
169                    let mut children = prefix.to_vec();
170                    children.extend([row_index, cell_index, content_index]);
171                    match content {
172                        CellContent::Paragraph(_) => self.insert(WordSourcePath {
173                            story: story.clone(),
174                            children,
175                        }),
176                        CellContent::Table(table) => {
177                            self.collect_table(table, story, &children);
178                        }
179                        CellContent::ContentControl(_) => {}
180                    }
181                }
182            }
183        }
184    }
185
186    fn insert(&mut self, path: WordSourcePath) {
187        if self.ids.contains_key(&path) {
188            return;
189        }
190        let id = self.insert_node(path.clone());
191        self.ids.insert(path, id);
192    }
193
194    fn insert_node(&mut self, path: WordSourcePath) -> SourceNodeId {
195        let index = u32::try_from(self.nodes.len() + 1)
196            .expect("a layout result cannot contain more than u32::MAX source paragraphs");
197        let id = SourceNodeId::new(index).expect("source ids are one based");
198        self.nodes.push(path);
199        id
200    }
201
202    fn body_id(&self, body_index: usize) -> Option<SourceNodeId> {
203        self.body_ids.get(body_index).copied().flatten()
204    }
205
206    pub(crate) fn id(&self, story: &WordStory, children: &[usize]) -> Option<SourceNodeId> {
207        self.ids
208            .get(&WordSourcePath {
209                story: story.clone(),
210                children: children.to_vec(),
211            })
212            .copied()
213    }
214
215    fn into_nodes(self) -> Vec<WordSourcePath> {
216        self.nodes
217    }
218}
219
220fn project_paragraph_runs(para: &CT_P, view: RevisionView) -> Vec<ProjectedRun<'_>> {
221    let mut projected = Vec::new();
222    for boundary in 0..=para.runs.len() {
223        for (_, slot, revision) in para.revisions.iter().filter(|(at, _, _)| *at == boundary) {
224            let hyperlink_index = hyperlink_revision_index(*slot);
225            let raw_order = match hyperlink_index {
226                Some(index) => {
227                    if let Some(raw_before) = para
228                        .hyperlinks
229                        .get(index)
230                        .and_then(|hyperlink| hyperlink.preserved_raw_before)
231                    {
232                        RawOrder::Raw(raw_before)
233                    } else if para
234                        .hyperlinks
235                        .get(index)
236                        .is_some_and(|hyperlink| boundary == hyperlink.run_end)
237                    {
238                        RawOrder::BeforeRaw
239                    } else {
240                        RawOrder::AfterRaw
241                    }
242                }
243                None => RawOrder::Raw(*slot),
244            };
245            project_revision_runs(
246                revision,
247                view,
248                boundary,
249                raw_order,
250                hyperlink_index,
251                false,
252                false,
253                &mut projected,
254            );
255        }
256        if let Some(run) = para.runs.get(boundary) {
257            projected.push(ProjectedRun {
258                run,
259                boundary,
260                raw_order: RawOrder::AfterRaw,
261                ordinary_run_index: Some(boundary),
262                hyperlink_index: None,
263                force_underline: false,
264                force_strike: false,
265            });
266        }
267    }
268    projected
269}
270
271fn project_revision_runs<'a>(
272    revision: &'a CT_Revision,
273    view: RevisionView,
274    boundary: usize,
275    raw_order: RawOrder,
276    hyperlink_index: Option<usize>,
277    inherited_underline: bool,
278    inherited_strike: bool,
279    projected: &mut Vec<ProjectedRun<'a>>,
280) {
281    let included = match view {
282        RevisionView::Tracked => true,
283        RevisionView::Accepted => matches!(
284            revision.kind(),
285            RevisionKind::Insertion | RevisionKind::MoveTo
286        ),
287    };
288    if !included {
289        return;
290    }
291
292    let force_underline = inherited_underline
293        || (view == RevisionView::Tracked
294            && matches!(
295                revision.kind(),
296                RevisionKind::Insertion | RevisionKind::MoveTo
297            ));
298    let force_strike = inherited_strike
299        || (view == RevisionView::Tracked
300            && matches!(
301                revision.kind(),
302                RevisionKind::Deletion | RevisionKind::MoveFrom
303            ));
304    let runs = match revision.content() {
305        RevisionContent::Runs(runs) => runs.as_slice(),
306        RevisionContent::Marker => &[],
307        RevisionContent::PriorRunProperties(_)
308        | RevisionContent::PriorParagraphProperties(_)
309        | RevisionContent::PriorTableProperties(_)
310        | RevisionContent::PriorSectionProperties(_) => return,
311    };
312
313    for run_boundary in 0..=runs.len() {
314        for (_, nested) in revision
315            .nested_revisions()
316            .iter()
317            .filter(|(at, _)| *at == run_boundary)
318        {
319            project_revision_runs(
320                nested,
321                view,
322                boundary,
323                raw_order,
324                hyperlink_index,
325                force_underline,
326                force_strike,
327                projected,
328            );
329        }
330        if let Some(run) = runs.get(run_boundary) {
331            projected.push(ProjectedRun {
332                run,
333                boundary,
334                raw_order,
335                ordinary_run_index: None,
336                hyperlink_index,
337                force_underline,
338                force_strike,
339            });
340        }
341    }
342}
343
344fn projected_paragraph_text(para: &CT_P, view: RevisionView) -> String {
345    project_paragraph_runs(para, view)
346        .iter()
347        .map(|projected| projected.run.text())
348        .collect()
349}
350
351fn projected_content_char_starts(run: &CT_R) -> Vec<usize> {
352    let mut starts = Vec::with_capacity(run.content.len());
353    let mut char_offset = 0usize;
354    for content in &run.content {
355        starts.push(char_offset);
356        char_offset += match content {
357            RunContent::Text(text) | RunContent::DeletedText(text) => text.text.chars().count(),
358            RunContent::Tab | RunContent::Break(_) => 1,
359            RunContent::Field(field) => field
360                .projected_text()
361                .map_or(0, |text| text.chars().count()),
362            RunContent::Drawing(_)
363            | RunContent::FootnoteRef { .. }
364            | RunContent::EndnoteRef { .. }
365            | RunContent::CommentReference { .. } => 0,
366        };
367    }
368    debug_assert_eq!(char_offset, run.text().chars().count());
369    starts
370}
371
372fn paragraph_has_visible_revision(para: &CT_P) -> bool {
373    let property_revision = para.properties.as_ref().is_some_and(|properties| {
374        properties.numbering_revision.is_some()
375            || properties.change.is_some()
376            || properties
377                .sect_pr
378                .as_ref()
379                .is_some_and(|section| section.change.is_some())
380            || properties
381                .rpr
382                .as_ref()
383                .is_some_and(run_properties_have_revision)
384    });
385    property_revision
386        || para
387            .runs
388            .iter()
389            .filter_map(|run| run.properties.as_ref())
390            .any(run_properties_have_revision)
391        || para
392            .revisions
393            .iter()
394            .any(|(_, _, revision)| revision_is_visible(revision))
395}
396
397fn run_properties_have_revision(properties: &rdocx_oxml::properties::CT_RPr) -> bool {
398    properties.change.is_some() || !properties.revision_markers.is_empty()
399}
400
401fn revision_is_visible(revision: &CT_Revision) -> bool {
402    match revision.content() {
403        RevisionContent::Runs(runs) => {
404            runs.iter().any(|run| {
405                run.content.iter().any(|content| match content {
406                    RunContent::Text(text) | RunContent::DeletedText(text) => !text.text.is_empty(),
407                    RunContent::CommentReference { .. } => false,
408                    RunContent::Tab
409                    | RunContent::Break(_)
410                    | RunContent::Drawing(_)
411                    | RunContent::Field(_)
412                    | RunContent::FootnoteRef { .. }
413                    | RunContent::EndnoteRef { .. } => true,
414                })
415            }) || revision
416                .nested_revisions()
417                .iter()
418                .any(|(_, nested)| revision_is_visible(nested))
419        }
420        RevisionContent::Marker => revision
421            .nested_revisions()
422            .iter()
423            .any(|(_, nested)| revision_is_visible(nested)),
424        RevisionContent::PriorRunProperties(_)
425        | RevisionContent::PriorParagraphProperties(_)
426        | RevisionContent::PriorTableProperties(_)
427        | RevisionContent::PriorSectionProperties(_) => true,
428    }
429}
430
431/// The layout engine.
432pub struct Engine {
433    font_manager: FontManager,
434    caller_font_aliases: Vec<(String, String)>,
435    paragraph_cache_context: Option<ReusableEngineContext>,
436    paragraph_cache: VecDeque<ParagraphCacheEntry>,
437    paragraph_cache_bytes: usize,
438    paragraph_cache_hits: usize,
439    paragraph_cache_builds: usize,
440    pending_paragraph_cache: Option<VecDeque<ParagraphCacheEntry>>,
441    pending_paragraph_cache_bytes: usize,
442    #[cfg(test)]
443    pending_paragraph_cache_peak_entries: usize,
444    #[cfg(test)]
445    pending_paragraph_cache_peak_bytes: usize,
446    paragraph_cache_reads_enabled: bool,
447    table_cache: VecDeque<TableCacheEntry>,
448    table_cache_bytes: usize,
449    table_cache_hits: usize,
450    table_cache_builds: usize,
451    pending_table_cache: Option<VecDeque<TableCacheEntry>>,
452    pending_table_cache_bytes: usize,
453    #[cfg(test)]
454    pending_table_cache_peak_entries: usize,
455    #[cfg(test)]
456    pending_table_cache_peak_bytes: usize,
457    header_footer_cache: VecDeque<HeaderFooterCacheEntry>,
458    header_footer_cache_bytes: usize,
459    header_footer_cache_hits: usize,
460    header_footer_cache_builds: usize,
461    pending_header_footer_cache: Option<VecDeque<HeaderFooterCacheEntry>>,
462    pending_header_footer_cache_bytes: usize,
463    #[cfg(test)]
464    pending_header_footer_cache_peak_entries: usize,
465    #[cfg(test)]
466    pending_header_footer_cache_peak_bytes: usize,
467    header_footer_cache_reads_enabled: bool,
468    restart_cache: Option<RestartCache>,
469    #[cfg(test)]
470    owned_context_builds: usize,
471    #[cfg(test)]
472    body_debug_work: usize,
473    #[cfg(test)]
474    retained_page_deep_copies: usize,
475    #[cfg(test)]
476    last_restart_candidate_bytes: usize,
477    #[cfg(test)]
478    last_rebuilt_page_range: Option<std::ops::Range<usize>>,
479    #[cfg(test)]
480    last_shared_block_counts: (usize, usize),
481    #[cfg(test)]
482    page_layout_invocations: usize,
483}
484
485#[derive(Clone, PartialEq)]
486struct ReusableEngineContext {
487    revision_view: RevisionView,
488    automatic_hyphenation: bool,
489    has_wrapping_drawing: bool,
490    styles: CT_Styles,
491    numbering: Option<rdocx_oxml::numbering::CT_Numbering>,
492    sections: Vec<CT_SectPr>,
493    headers: HashMap<String, rdocx_oxml::header_footer::CT_HdrFtr>,
494    footers: HashMap<String, rdocx_oxml::header_footer::CT_HdrFtr>,
495    images: HashMap<String, crate::input::ImageData>,
496    charts: HashMap<String, std::result::Result<Box<oxml_chart::CT_ChartSpace>, String>>,
497    chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet,
498    chart_color_map: oxml_drawing::color::ColorMap,
499    core_properties: Option<rdocx_oxml::core_properties::CoreProperties>,
500    hyperlink_urls: HashMap<String, String>,
501    footnotes: Option<rdocx_oxml::footnotes::CT_Footnotes>,
502    endnotes: Option<rdocx_oxml::footnotes::CT_Footnotes>,
503    theme: Option<rdocx_oxml::theme::Theme>,
504    fonts: Vec<oxml_layout::FontFile>,
505    caller_font_aliases: Vec<(String, String)>,
506    background_xml: Option<Vec<u8>>,
507}
508
509#[cfg(test)]
510thread_local! {
511    static RETAINED_CONTEXT_FONT_BYTES_COMPARED: Cell<usize> = const { Cell::new(0) };
512}
513
514fn retained_context_fonts_match(
515    retained: &[oxml_layout::FontFile],
516    input: &[oxml_layout::FontFile],
517) -> bool {
518    #[cfg(test)]
519    RETAINED_CONTEXT_FONT_BYTES_COMPARED.set(
520        RETAINED_CONTEXT_FONT_BYTES_COMPARED
521            .get()
522            .saturating_add(retained.iter().map(|font| font.data.len()).sum::<usize>()),
523    );
524    retained == input
525}
526
527#[cfg(test)]
528fn reset_retained_context_font_bytes_compared() {
529    RETAINED_CONTEXT_FONT_BYTES_COMPARED.set(0);
530}
531
532#[cfg(test)]
533fn retained_context_font_bytes_compared() -> usize {
534    RETAINED_CONTEXT_FONT_BYTES_COMPARED.get()
535}
536
537impl ReusableEngineContext {
538    #[cfg(test)]
539    fn for_input(input: &LayoutInput, caller_font_aliases: &[(String, String)]) -> Self {
540        Self::for_input_with_wrap(
541            input,
542            caller_font_aliases,
543            document_has_wrapping_drawing(input),
544        )
545    }
546
547    fn for_input_with_wrap(
548        input: &LayoutInput,
549        caller_font_aliases: &[(String, String)],
550        has_wrapping_drawing: bool,
551    ) -> Self {
552        let mut sections = input
553            .document
554            .body
555            .content
556            .iter()
557            .filter_map(|content| match content {
558                BodyContent::Paragraph(paragraph) => paragraph
559                    .properties
560                    .as_ref()
561                    .and_then(|properties| properties.sect_pr.clone()),
562                _ => None,
563            })
564            .collect::<Vec<_>>();
565        sections.extend(input.document.body.sect_pr.iter().cloned());
566        Self {
567            revision_view: input.revision_view,
568            automatic_hyphenation: input.automatic_hyphenation,
569            has_wrapping_drawing,
570            styles: input.styles.clone(),
571            numbering: input.numbering.clone(),
572            sections,
573            headers: input.headers.clone(),
574            footers: input.footers.clone(),
575            images: input.images.clone(),
576            charts: input.charts.clone(),
577            chart_theme: input.chart_theme.clone(),
578            chart_color_map: input.chart_color_map.clone(),
579            core_properties: input.core_properties.clone(),
580            hyperlink_urls: input.hyperlink_urls.clone(),
581            footnotes: input.footnotes.clone(),
582            endnotes: input.endnotes.clone(),
583            theme: input.theme.clone(),
584            fonts: input.fonts.clone(),
585            caller_font_aliases: bounded_caller_aliases(caller_font_aliases),
586            background_xml: input.document.background_xml.clone(),
587        }
588    }
589
590    fn matches_input(
591        &self,
592        input: &LayoutInput,
593        caller_font_aliases: &[(String, String)],
594        has_wrapping_drawing: bool,
595    ) -> bool {
596        self.matches_input_after_unchanged_fonts(input, caller_font_aliases, has_wrapping_drawing)
597            && retained_context_fonts_match(&self.fonts, &input.fonts)
598    }
599
600    fn matches_input_after_unchanged_fonts(
601        &self,
602        input: &LayoutInput,
603        caller_font_aliases: &[(String, String)],
604        has_wrapping_drawing: bool,
605    ) -> bool {
606        let sections_match = self.sections.iter().eq(input
607            .document
608            .body
609            .content
610            .iter()
611            .filter_map(|content| match content {
612                BodyContent::Paragraph(paragraph) => paragraph
613                    .properties
614                    .as_ref()
615                    .and_then(|properties| properties.sect_pr.as_ref()),
616                BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
617                    None
618                }
619            })
620            .chain(input.document.body.sect_pr.iter()));
621        self.revision_view == input.revision_view
622            && self.automatic_hyphenation == input.automatic_hyphenation
623            && self.has_wrapping_drawing == has_wrapping_drawing
624            && self.styles == input.styles
625            && self.numbering == input.numbering
626            && sections_match
627            && self.headers == input.headers
628            && self.footers == input.footers
629            && self.images == input.images
630            && self.charts == input.charts
631            && self.chart_theme == input.chart_theme
632            && self.chart_color_map == input.chart_color_map
633            && self.core_properties == input.core_properties
634            && self.hyperlink_urls == input.hyperlink_urls
635            && self.footnotes == input.footnotes
636            && self.endnotes == input.endnotes
637            && self.theme == input.theme
638            && self.caller_font_aliases == caller_font_aliases
639            && self.background_xml == input.document.background_xml
640    }
641}
642
643#[derive(Clone, PartialEq)]
644struct ParagraphCacheKey {
645    paragraph: CT_P,
646    content_width_bits: u64,
647    revision_view: RevisionView,
648}
649
650struct ParagraphCacheEntry {
651    fingerprint: u64,
652    key: ParagraphCacheKey,
653    block: Arc<ParagraphBlock>,
654    diagnostics: Vec<Diagnostic>,
655    font_trace: Vec<FontId>,
656    reflow_direction: TextDirection,
657    bytes: usize,
658}
659
660#[derive(Clone, PartialEq)]
661struct TableCacheKey {
662    table: CT_Tbl,
663    content_width_bits: u64,
664    revision_view: RevisionView,
665    with_provenance: bool,
666}
667
668struct TableCacheEntry {
669    fingerprint: u64,
670    key: TableCacheKey,
671    block: Arc<table::TableBlock>,
672    semantics: TableSemantics,
673    diagnostics: Vec<Diagnostic>,
674    font_trace: Vec<FontId>,
675    bytes: usize,
676}
677
678#[derive(Clone, Copy, PartialEq, Eq)]
679enum HeaderFooterStoryKind {
680    Header,
681    Footer,
682}
683
684#[derive(Clone, PartialEq)]
685struct HeaderFooterCacheKey {
686    story: HeaderFooterStoryKind,
687    variant: HdrFtrType,
688    section: CT_SectPr,
689    relationship_id: String,
690    part: rdocx_oxml::header_footer::CT_HdrFtr,
691    resolved_part_bytes: Vec<u8>,
692    with_provenance: bool,
693}
694
695#[derive(Clone)]
696struct HeaderFooterVariantContent {
697    blocks: Vec<ParagraphBlock>,
698    directions: Vec<TextDirection>,
699    watermark: Option<GroupElement>,
700}
701
702struct HeaderFooterCacheEntry {
703    key: HeaderFooterCacheKey,
704    content: HeaderFooterVariantContent,
705    diagnostics: Vec<Diagnostic>,
706    font_trace: Vec<FontId>,
707    bytes: usize,
708}
709
710struct RestartCache {
711    body: Vec<RestartBodyEntry>,
712    with_provenance: bool,
713    raw_pages: Vec<Arc<PageFrame>>,
714    pages: Vec<Arc<PageFrame>>,
715    substitution_inputs: Vec<Option<FieldSubstitutionInputs>>,
716    outlines: Vec<oxml_layout::OutlineEntry>,
717    checkpoints: Vec<paginator::PaginationCheckpoint>,
718    font_trace: Vec<FontId>,
719    bytes: usize,
720}
721
722#[derive(Clone)]
723enum RestartBodyEntry {
724    Paragraph {
725        fingerprint: u64,
726        identity: Vec<u8>,
727        note_references: Vec<NoteRef>,
728        bytes: usize,
729    },
730    Table {
731        fingerprint: u64,
732        identity: Vec<u8>,
733        bytes: usize,
734    },
735}
736
737impl RestartBodyEntry {
738    fn matches(&self, content: &BodyContent) -> bool {
739        match (self, content) {
740            (
741                Self::Paragraph {
742                    fingerprint,
743                    identity,
744                    ..
745                },
746                BodyContent::Paragraph(paragraph),
747            ) => {
748                *fingerprint == paragraph_fingerprint(paragraph)
749                    && restart_body_identity(content).as_ref() == Some(identity)
750            }
751            (
752                Self::Table {
753                    fingerprint,
754                    identity,
755                    ..
756                },
757                BodyContent::Table(table),
758            ) => {
759                *fingerprint == table_fingerprint(table)
760                    && restart_body_identity(content).as_ref() == Some(identity)
761            }
762            _ => false,
763        }
764    }
765
766    fn for_content(content: &BodyContent, view: RevisionView) -> Option<Self> {
767        let identity = restart_body_identity(content)?;
768        match content {
769            BodyContent::Paragraph(paragraph) => {
770                let mut note_references = paragraph_note_references(paragraph, view);
771                note_references.shrink_to_fit();
772                let bytes = identity.capacity().saturating_add(
773                    note_references
774                        .capacity()
775                        .saturating_mul(std::mem::size_of::<NoteRef>()),
776                );
777                Some(Self::Paragraph {
778                    fingerprint: paragraph_fingerprint(paragraph),
779                    identity,
780                    note_references,
781                    bytes,
782                })
783            }
784            BodyContent::Table(table) => Some(Self::Table {
785                fingerprint: table_fingerprint(table),
786                bytes: identity.capacity(),
787                identity,
788            }),
789            BodyContent::ContentControl(_) | BodyContent::RawXml(_) => None,
790        }
791    }
792
793    fn bytes(&self) -> usize {
794        match self {
795            Self::Paragraph { bytes, .. } | Self::Table { bytes, .. } => *bytes,
796        }
797    }
798
799    fn note_references(&self) -> &[NoteRef] {
800        match self {
801            Self::Paragraph {
802                note_references, ..
803            } => note_references,
804            Self::Table { .. } => &[],
805        }
806    }
807}
808
809fn restart_body_identity(content: &BodyContent) -> Option<Vec<u8>> {
810    if !matches!(content, BodyContent::Paragraph(_) | BodyContent::Table(_)) {
811        return None;
812    }
813    let mut document = CT_Document::new();
814    document.body.sect_pr = None;
815    document.body.content.push(content.clone());
816    let mut identity = document.to_xml().ok()?;
817    identity.shrink_to_fit();
818    Some(identity)
819}
820
821fn paragraph_note_references(paragraph: &CT_P, view: RevisionView) -> Vec<NoteRef> {
822    project_paragraph_runs(paragraph, view)
823        .into_iter()
824        .flat_map(|projected| projected.run.content.iter())
825        .filter_map(|content| match content {
826            RunContent::FootnoteRef { id } => Some(NoteRef {
827                stream: NoteStream::Footnote,
828                id: *id,
829            }),
830            RunContent::EndnoteRef { id } => Some(NoteRef {
831                stream: NoteStream::Endnote,
832                id: *id,
833            }),
834            _ => None,
835        })
836        .collect()
837}
838
839fn body_note_references(input: &LayoutInput) -> Vec<NoteRef> {
840    input
841        .document
842        .body
843        .content
844        .iter()
845        .flat_map(|content| match content {
846            BodyContent::Paragraph(paragraph) => {
847                paragraph_note_references(paragraph, input.revision_view)
848            }
849            BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
850                Vec::new()
851            }
852        })
853        .collect()
854}
855
856const fn arc_allocation_bytes<T>() -> usize {
857    std::mem::size_of::<T>() + 2 * std::mem::size_of::<usize>()
858}
859
860#[derive(Clone, PartialEq, Eq)]
861struct FieldSubstitutionInputs {
862    page_index: usize,
863    page_number: usize,
864    total_pages: usize,
865    bookmark_pages: Vec<(usize, usize)>,
866    font_identity: Vec<FontId>,
867    revision_view: RevisionView,
868}
869
870const CACHE_MAX_ENTRIES: usize = 5_216;
871const CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
872const PARAGRAPH_CACHE_MAX_ENTRIES: usize = 4_096;
873const PARAGRAPH_CACHE_MAX_BYTES: usize = 50 * 1024 * 1024;
874const TABLE_CACHE_MAX_ENTRIES: usize = 32;
875const TABLE_CACHE_MAX_BYTES: usize = 2 * 1024 * 1024;
876const HEADER_FOOTER_CACHE_MAX_ENTRIES: usize = 64;
877const HEADER_FOOTER_CACHE_MAX_BYTES: usize = 4 * 1024 * 1024;
878const RESTART_CACHE_MAX_ENTRIES: usize = 1_024;
879const CALLER_ALIAS_MAX_ENTRIES: usize = 256;
880const CALLER_ALIAS_MAX_RETAINED_BYTES: usize = 64 * 1024;
881const _: () = assert!(PARAGRAPH_CACHE_MAX_ENTRIES == 4_096);
882const _: () = assert!(PARAGRAPH_CACHE_MAX_BYTES == 50 * 1024 * 1024);
883const _: () = assert!(HEADER_FOOTER_CACHE_MAX_ENTRIES == 64);
884const _: () = assert!(HEADER_FOOTER_CACHE_MAX_BYTES == 4 * 1024 * 1024);
885const _: () = assert!(CACHE_MAX_ENTRIES == 5_216);
886const _: () = assert!(CACHE_MAX_BYTES == 64 * 1024 * 1024);
887const _: () = assert!(
888    PARAGRAPH_CACHE_MAX_ENTRIES
889        + TABLE_CACHE_MAX_ENTRIES
890        + HEADER_FOOTER_CACHE_MAX_ENTRIES
891        + RESTART_CACHE_MAX_ENTRIES
892        <= CACHE_MAX_ENTRIES
893);
894const _: () = assert!(
895    PARAGRAPH_CACHE_MAX_BYTES + TABLE_CACHE_MAX_BYTES + HEADER_FOOTER_CACHE_MAX_BYTES
896        <= CACHE_MAX_BYTES
897);
898const CACHE_SOURCE_NODE: SourceNodeId = match SourceNodeId::new(1) {
899    Some(node) => node,
900    None => panic!("one is a valid source node id"),
901};
902
903/// Return the deterministic prefix that fits the private caller-alias bounds.
904///
905/// The byte accounting matches `oxml-layout`: requested and target strings in
906/// the ordered identity plus the normalized requested key and target value in
907/// the font lookup map.
908fn bounded_caller_aliases(aliases: &[(String, String)]) -> Vec<(String, String)> {
909    let mut bounded = Vec::with_capacity(aliases.len().min(CALLER_ALIAS_MAX_ENTRIES));
910    let mut retained_bytes = 0usize;
911    for (requested, target) in aliases {
912        if bounded.len() == CALLER_ALIAS_MAX_ENTRIES {
913            break;
914        }
915        let normalized_requested = requested.to_lowercase();
916        let entry_bytes = requested
917            .len()
918            .saturating_add(target.len())
919            .saturating_add(normalized_requested.len())
920            .saturating_add(target.len());
921        let next_retained_bytes = retained_bytes.saturating_add(entry_bytes);
922        if next_retained_bytes > CALLER_ALIAS_MAX_RETAINED_BYTES {
923            break;
924        }
925        bounded.push((requested.clone(), target.clone()));
926        retained_bytes = next_retained_bytes;
927    }
928    bounded
929}
930
931impl Default for Engine {
932    fn default() -> Self {
933        Self::new()
934    }
935}
936
937impl Engine {
938    fn with_font_manager(font_manager: FontManager) -> Self {
939        Self {
940            font_manager,
941            caller_font_aliases: Vec::new(),
942            paragraph_cache_context: None,
943            paragraph_cache: VecDeque::new(),
944            paragraph_cache_bytes: 0,
945            paragraph_cache_hits: 0,
946            paragraph_cache_builds: 0,
947            pending_paragraph_cache: None,
948            pending_paragraph_cache_bytes: 0,
949            #[cfg(test)]
950            pending_paragraph_cache_peak_entries: 0,
951            #[cfg(test)]
952            pending_paragraph_cache_peak_bytes: 0,
953            paragraph_cache_reads_enabled: false,
954            table_cache: VecDeque::new(),
955            table_cache_bytes: 0,
956            table_cache_hits: 0,
957            table_cache_builds: 0,
958            pending_table_cache: None,
959            pending_table_cache_bytes: 0,
960            #[cfg(test)]
961            pending_table_cache_peak_entries: 0,
962            #[cfg(test)]
963            pending_table_cache_peak_bytes: 0,
964            header_footer_cache: VecDeque::new(),
965            header_footer_cache_bytes: 0,
966            header_footer_cache_hits: 0,
967            header_footer_cache_builds: 0,
968            pending_header_footer_cache: None,
969            pending_header_footer_cache_bytes: 0,
970            #[cfg(test)]
971            pending_header_footer_cache_peak_entries: 0,
972            #[cfg(test)]
973            pending_header_footer_cache_peak_bytes: 0,
974            header_footer_cache_reads_enabled: false,
975            restart_cache: None,
976            #[cfg(test)]
977            owned_context_builds: 0,
978            #[cfg(test)]
979            body_debug_work: 0,
980            #[cfg(test)]
981            retained_page_deep_copies: 0,
982            #[cfg(test)]
983            last_restart_candidate_bytes: 0,
984            #[cfg(test)]
985            last_rebuilt_page_range: None,
986            #[cfg(test)]
987            last_shared_block_counts: (0, 0),
988            #[cfg(test)]
989            page_layout_invocations: 0,
990        }
991    }
992
993    pub fn new() -> Self {
994        Self::with_font_manager(FontManager::new())
995    }
996
997    /// Create an engine that resolves fonts without system font discovery.
998    pub fn new_deterministic() -> Result<Self> {
999        Ok(Self::with_font_manager(FontManager::new_deterministic()?))
1000    }
1001
1002    /// Create an engine whose font universe is supplied entirely by the
1003    /// layout input, without bundled or system-font discovery.
1004    pub(crate) fn new_with_caller_fonts() -> Self {
1005        Self::with_font_manager(FontManager::new_with_fonts(Vec::new()))
1006    }
1007
1008    /// Set byte-free caller aliases from requested family to loaded family.
1009    pub fn set_caller_font_aliases(&mut self, aliases: &[(String, String)]) {
1010        let aliases = bounded_caller_aliases(aliases);
1011        if self.caller_font_aliases != aliases {
1012            self.caller_font_aliases = aliases;
1013        }
1014    }
1015
1016    /// Take a reusable engine only when its complete retained-work context
1017    /// matches the proposed receiver input.
1018    #[doc(hidden)]
1019    pub fn take_if_compatible(source: &mut Option<Self>, input: &LayoutInput) -> Option<Self> {
1020        Self::take_if_compatible_with_caller_aliases(source, input, &[])
1021    }
1022
1023    /// Take a reusable engine only when its complete caller-font and alias
1024    /// context matches the proposed receiver input.
1025    #[doc(hidden)]
1026    pub fn take_if_compatible_with_caller_aliases(
1027        source: &mut Option<Self>,
1028        input: &LayoutInput,
1029        caller_font_aliases: &[(String, String)],
1030    ) -> Option<Self> {
1031        let caller_font_aliases = bounded_caller_aliases(caller_font_aliases);
1032        let has_wrapping_drawing = document_has_wrapping_drawing(input);
1033        let compatible = source.as_ref().is_some_and(|engine| {
1034            engine.caller_font_aliases == caller_font_aliases
1035                && engine
1036                    .paragraph_cache_context
1037                    .as_ref()
1038                    .is_some_and(|context| {
1039                        context.matches_input(input, &caller_font_aliases, has_wrapping_drawing)
1040                    })
1041                && engine.pending_paragraph_cache.is_none()
1042                && engine.pending_header_footer_cache.is_none()
1043        });
1044        compatible.then(|| source.take()).flatten()
1045    }
1046
1047    /// Lay out the entire document.
1048    pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
1049        self.layout_inner(input, None)
1050    }
1051
1052    /// Lay out the document and retain its result-local Word source table.
1053    pub(crate) fn layout_with_provenance(
1054        &mut self,
1055        input: &LayoutInput,
1056    ) -> Result<(LayoutResult, Vec<WordSourcePath>)> {
1057        let sources = SourceRegistry::for_input(input);
1058        let result = self.layout_inner(input, Some(&sources))?;
1059        Ok((result, sources.into_nodes()))
1060    }
1061
1062    fn layout_inner(
1063        &mut self,
1064        input: &LayoutInput,
1065        sources: Option<&SourceRegistry>,
1066    ) -> Result<LayoutResult> {
1067        // Load user-provided / DOCX-embedded fonts (highest priority). An exact
1068        // unchanged set is a no-op in a reusable engine.
1069        let font_context_changed = self.font_manager.load_additional_fonts(&input.fonts)
1070            | self
1071                .font_manager
1072                .set_caller_aliases(&self.caller_font_aliases);
1073        self.font_manager.begin_layout();
1074        let has_wrapping_drawing = document_has_wrapping_drawing(input);
1075
1076        if font_context_changed {
1077            self.paragraph_cache.clear();
1078            self.paragraph_cache_bytes = 0;
1079            self.table_cache.clear();
1080            self.table_cache_bytes = 0;
1081            self.header_footer_cache.clear();
1082            self.header_footer_cache_bytes = 0;
1083        }
1084        let context_matches = !font_context_changed
1085            && self
1086                .paragraph_cache_context
1087                .as_ref()
1088                .is_some_and(|context| {
1089                    context.matches_input_after_unchanged_fonts(
1090                        input,
1091                        &self.caller_font_aliases,
1092                        has_wrapping_drawing,
1093                    )
1094                });
1095        self.paragraph_cache_reads_enabled = context_matches;
1096        self.header_footer_cache_reads_enabled = context_matches;
1097        self.pending_paragraph_cache = Some(VecDeque::new());
1098        self.pending_paragraph_cache_bytes = 0;
1099        self.pending_table_cache = Some(VecDeque::new());
1100        self.pending_table_cache_bytes = 0;
1101        self.pending_header_footer_cache = Some(VecDeque::new());
1102        self.pending_header_footer_cache_bytes = 0;
1103        #[cfg(test)]
1104        {
1105            self.pending_paragraph_cache_peak_entries = 0;
1106            self.pending_paragraph_cache_peak_bytes = 0;
1107            self.pending_table_cache_peak_entries = 0;
1108            self.pending_table_cache_peak_bytes = 0;
1109            self.pending_header_footer_cache_peak_entries = 0;
1110            self.pending_header_footer_cache_peak_bytes = 0;
1111            self.page_layout_invocations = 0;
1112        }
1113
1114        let result = self.layout_transaction(input, sources, has_wrapping_drawing);
1115        let pending = self.pending_paragraph_cache.take().unwrap_or_default();
1116        let pending_tables = self.pending_table_cache.take().unwrap_or_default();
1117        let pending_header_footers = self.pending_header_footer_cache.take().unwrap_or_default();
1118        self.pending_paragraph_cache_bytes = 0;
1119        self.pending_table_cache_bytes = 0;
1120        self.pending_header_footer_cache_bytes = 0;
1121        self.paragraph_cache_reads_enabled = false;
1122        self.header_footer_cache_reads_enabled = false;
1123        if result.is_ok() {
1124            if !context_matches {
1125                self.paragraph_cache.clear();
1126                self.paragraph_cache_bytes = 0;
1127                self.table_cache.clear();
1128                self.table_cache_bytes = 0;
1129                self.header_footer_cache.clear();
1130                self.header_footer_cache_bytes = 0;
1131                self.paragraph_cache_context = Some(ReusableEngineContext::for_input_with_wrap(
1132                    input,
1133                    &self.caller_font_aliases,
1134                    has_wrapping_drawing,
1135                ));
1136                #[cfg(test)]
1137                {
1138                    self.owned_context_builds += 1;
1139                }
1140            }
1141            for entry in pending {
1142                self.publish_paragraph_cache_entry(entry);
1143            }
1144            for entry in pending_tables {
1145                self.publish_table_cache_entry(entry);
1146            }
1147            for entry in pending_header_footers {
1148                self.publish_header_footer_cache_entry(entry);
1149            }
1150        }
1151        let current_fonts = self
1152            .font_manager
1153            .current_layout_fonts()
1154            .iter()
1155            .copied()
1156            .collect::<std::collections::HashSet<_>>();
1157        self.paragraph_cache.retain(|entry| {
1158            entry
1159                .font_trace
1160                .iter()
1161                .all(|font_id| current_fonts.contains(font_id))
1162        });
1163        self.paragraph_cache_bytes = self.paragraph_cache.iter().map(|entry| entry.bytes).sum();
1164        self.table_cache.retain(|entry| {
1165            entry
1166                .font_trace
1167                .iter()
1168                .all(|font_id| current_fonts.contains(font_id))
1169        });
1170        self.table_cache_bytes = self.table_cache.iter().map(|entry| entry.bytes).sum();
1171        self.header_footer_cache.retain(|entry| {
1172            entry
1173                .font_trace
1174                .iter()
1175                .all(|font_id| current_fonts.contains(font_id))
1176        });
1177        self.header_footer_cache_bytes = self
1178            .header_footer_cache
1179            .iter()
1180            .map(|entry| entry.bytes)
1181            .sum();
1182        self.font_manager.retain_current_fonts();
1183        result
1184    }
1185
1186    fn layout_transaction(
1187        &mut self,
1188        input: &LayoutInput,
1189        sources: Option<&SourceRegistry>,
1190        document_wraps: bool,
1191    ) -> Result<LayoutResult> {
1192        let retained_context_matches = self.paragraph_cache_reads_enabled;
1193        let styles = &input.styles;
1194        let mut num_state = NumberingState::new();
1195        let media = MediaRegistry::new(&input.images);
1196        let mut diagnostics = Vec::new();
1197
1198        // Re-breaking a paragraph around a floating drawing needs its line
1199        // breaking inputs kept alive past layout. Nearly no document has a
1200        // drawing that wraps, so the state is dropped again unless one does.
1201        // Get final section properties (body-level sectPr)
1202        let final_sect_pr = input
1203            .document
1204            .body
1205            .sect_pr
1206            .as_ref()
1207            .cloned()
1208            .unwrap_or_else(CT_SectPr::default_letter);
1209
1210        // Build sections: each section has blocks + geometry + header/footer
1211        let mut sections: Vec<paginator::SharedSection> = Vec::new();
1212        let mut current_blocks: Vec<SharedLayoutBlock> = Vec::new();
1213        let mut current_sect_pr: Option<CT_SectPr> = None; // Will be set from paragraph sect_pr
1214
1215        for (body_index, content) in input.document.body.content.iter().enumerate() {
1216            match content {
1217                BodyContent::Paragraph(para) => {
1218                    // Check if this paragraph ends a section (has sect_pr)
1219                    let para_sect_pr = para.properties.as_ref().and_then(|p| p.sect_pr.clone());
1220
1221                    let sect_pr_for_layout = para_sect_pr
1222                        .as_ref()
1223                        .or(current_sect_pr.as_ref())
1224                        .unwrap_or(&final_sect_pr);
1225                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
1226
1227                    let source = sources.and_then(|sources| sources.body_id(body_index));
1228                    let mut block = self.layout_body_paragraph(
1229                        para,
1230                        geometry.content_width(),
1231                        styles,
1232                        input,
1233                        &media,
1234                        &mut num_state,
1235                        &mut diagnostics,
1236                        source,
1237                    )?;
1238
1239                    // Detect heading style for outline generation
1240                    if let Some(level) = detect_heading_level(para, styles) {
1241                        let heading_text = projected_paragraph_text(para, input.revision_view);
1242                        let replacement = match &mut block {
1243                            SharedLayoutBlock::Owned { block, .. } => match block.as_mut() {
1244                                LayoutBlock::Paragraph(paragraph) => {
1245                                    paragraph.heading_level = Some(level);
1246                                    paragraph.heading_text = Some(heading_text);
1247                                    None
1248                                }
1249                                LayoutBlock::Table(_) => unreachable!(),
1250                            },
1251                            SharedLayoutBlock::Paragraph {
1252                                block: shared,
1253                                semantics,
1254                            } => {
1255                                let mut paragraph = shared.as_ref().clone();
1256                                rebind_paragraph_source(&mut paragraph, semantics.source_node)?;
1257                                paragraph.heading_level = Some(level);
1258                                paragraph.heading_text = Some(heading_text);
1259                                Some(SharedLayoutBlock::Owned {
1260                                    block: Box::new(LayoutBlock::Paragraph(paragraph)),
1261                                    reflow_direction: semantics.reflow_direction,
1262                                })
1263                            }
1264                            SharedLayoutBlock::Table { .. } => unreachable!(),
1265                        };
1266                        if let Some(replacement) = replacement {
1267                            block = replacement;
1268                        }
1269                    }
1270
1271                    current_blocks.push(block);
1272
1273                    // If this paragraph has sect_pr, it ends a section
1274                    if let Some(sect_pr) = para_sect_pr {
1275                        let geometry = sect_pr_to_geometry(&sect_pr);
1276                        let header_footer = layout_header_footer(
1277                            self,
1278                            &sect_pr,
1279                            input,
1280                            styles,
1281                            &media,
1282                            &mut num_state,
1283                            &mut diagnostics,
1284                            sources,
1285                        )?;
1286                        let title_pg = sect_pr.title_pg.unwrap_or(false);
1287                        let (header_footer, header_footer_semantics) = header_footer
1288                            .map_or((None, None), |(content, semantics)| {
1289                                (Some(content), Some(semantics))
1290                            });
1291                        sections.push(paginator::SharedSection {
1292                            blocks: std::mem::take(&mut current_blocks),
1293                            geometry,
1294                            header_footer,
1295                            header_footer_semantics,
1296                            title_pg,
1297                            page_number_start: section_page_number_start(&sect_pr),
1298                        });
1299                        current_sect_pr = Some(sect_pr);
1300                    }
1301                }
1302                BodyContent::Table(tbl) => {
1303                    let sect_pr_for_layout = current_sect_pr.as_ref().unwrap_or(&final_sect_pr);
1304                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
1305
1306                    let table_block = self.layout_body_table(
1307                        tbl,
1308                        geometry.content_width(),
1309                        styles,
1310                        input,
1311                        &media,
1312                        &mut num_state,
1313                        &mut diagnostics,
1314                        sources,
1315                        &WordStory::Document,
1316                        &[body_index],
1317                    )?;
1318                    current_blocks.push(table_block);
1319                }
1320                _ => {} // Skip RawXml elements during layout
1321            }
1322        }
1323
1324        // Remaining blocks belong to the final section
1325        let final_geometry = sect_pr_to_geometry(&final_sect_pr);
1326        let final_hf = layout_header_footer(
1327            self,
1328            &final_sect_pr,
1329            input,
1330            styles,
1331            &media,
1332            &mut num_state,
1333            &mut diagnostics,
1334            sources,
1335        )?;
1336        let final_title_pg = final_sect_pr.title_pg.unwrap_or(false);
1337        let (final_hf, final_hf_semantics) = final_hf
1338            .map_or((None, None), |(content, semantics)| {
1339                (Some(content), Some(semantics))
1340            });
1341        sections.push(paginator::SharedSection {
1342            blocks: current_blocks,
1343            geometry: final_geometry,
1344            header_footer: final_hf,
1345            header_footer_semantics: final_hf_semantics,
1346            title_pg: final_title_pg,
1347            page_number_start: section_page_number_start(&final_sect_pr),
1348        });
1349        let structure = assign_shared_document_structure(&mut sections);
1350        #[cfg(test)]
1351        {
1352            self.last_shared_block_counts = sections
1353                .iter()
1354                .flat_map(|section| &section.blocks)
1355                .fold((0, 0), |(paragraphs, tables), block| match block {
1356                    SharedLayoutBlock::Paragraph { block, .. } if Arc::strong_count(block) >= 2 => {
1357                        (paragraphs + 1, tables)
1358                    }
1359                    SharedLayoutBlock::Table { block, .. } if Arc::strong_count(block) >= 2 => {
1360                        (paragraphs, tables + 1)
1361                    }
1362                    SharedLayoutBlock::Owned { .. }
1363                    | SharedLayoutBlock::Paragraph { .. }
1364                    | SharedLayoutBlock::Table { .. } => (paragraphs, tables),
1365                });
1366        }
1367
1368        // Lay the notes out once per width, before pagination, so the paginator
1369        // can reserve exactly the height it will later draw. A note is broken to
1370        // the measure of the section carrying its reference, so every section's
1371        // width is registered. The endnote pages that follow the last body page
1372        // are drawn against `final_geometry`, which belongs to the section
1373        // pushed just above and is therefore already in this list.
1374        let content_widths: Vec<f64> = sections
1375            .iter()
1376            .map(|section| section.geometry.content_width())
1377            .collect();
1378        let notes = NoteRegistry::build(
1379            input,
1380            styles,
1381            &media,
1382            &mut self.font_manager,
1383            &mut num_state,
1384            &content_widths,
1385            &mut diagnostics,
1386            sources,
1387        )?;
1388
1389        let mut font_trace = self.font_manager.current_layout_fonts().to_vec();
1390        let restart_record_eligible = sections.len() == 1
1391            && input.document.background_xml.is_none()
1392            && !document_wraps
1393            && input
1394                .document
1395                .body
1396                .content
1397                .iter()
1398                .zip(&sections[0].blocks)
1399                .all(|(content, block)| match content {
1400                    BodyContent::Paragraph(paragraph) if block.paragraph().is_some() => {
1401                        paragraph_is_restart_record_source_safe(paragraph, styles)
1402                            && restart_record_block_is_safe(block)
1403                    }
1404                    BodyContent::Table(table) if block.table().is_some() => {
1405                        table_is_cache_safe(table, styles)
1406                    }
1407                    _ => false,
1408                });
1409        let restart_eligible = restart_record_eligible
1410            && input
1411                .document
1412                .body
1413                .content
1414                .iter()
1415                .zip(&sections[0].blocks)
1416                .all(|(content, block)| {
1417                    !matches!(content, BodyContent::Paragraph(paragraph) if paragraph_has_field(paragraph))
1418                        && restart_block_is_safe(block)
1419                });
1420        let reusable_restart_record = restart_record_eligible
1421            && retained_context_matches
1422            && self.restart_cache.as_ref().is_some_and(|cache| {
1423                cache.font_trace == font_trace
1424                    && cache.with_provenance == sources.is_some()
1425                    && cache
1426                        .body
1427                        .iter()
1428                        .flat_map(|entry| entry.note_references().iter().copied())
1429                        .eq(body_note_references(input))
1430                    && (sources.is_none() || cache.body.len() == input.document.body.content.len())
1431            });
1432        let reusable_restart = restart_eligible && reusable_restart_record;
1433        let body_unchanged = reusable_restart_record
1434            && self.restart_cache.as_ref().is_some_and(|cache| {
1435                cache.body.len() == input.document.body.content.len()
1436                    && input
1437                        .document
1438                        .body
1439                        .content
1440                        .iter()
1441                        .zip(&cache.body)
1442                        .all(|(content, retained)| retained.matches(content))
1443            });
1444        let first_changed = reusable_restart.then(|| {
1445            let cache = self.restart_cache.as_ref().expect("restart cache exists");
1446            input
1447                .document
1448                .body
1449                .content
1450                .iter()
1451                .zip(&cache.body)
1452                .position(|(current, previous)| !previous.matches(current))
1453                .unwrap_or_else(|| input.document.body.content.len().min(cache.body.len()))
1454        });
1455        let common_suffix = reusable_restart.then(|| {
1456            let cache = self.restart_cache.as_ref().expect("restart cache exists");
1457            input
1458                .document
1459                .body
1460                .content
1461                .iter()
1462                .rev()
1463                .zip(cache.body.iter().rev())
1464                .take_while(|(current, previous)| previous.matches(current))
1465                .count()
1466        });
1467        let restart_checkpoint = first_changed.and_then(|first_changed| {
1468            self.restart_cache
1469                .as_ref()
1470                .expect("restart cache exists")
1471                .checkpoints
1472                .iter()
1473                .rev()
1474                .find(|checkpoint| checkpoint.next_block_index <= first_changed)
1475                .copied()
1476        });
1477        let tail_source = restart_checkpoint.and_then(|restart| {
1478            let cache = self.restart_cache.as_ref().expect("restart cache exists");
1479            let common_suffix = common_suffix.expect("reusable restart has an exact suffix");
1480            let new_tail = input.document.body.content.len() - common_suffix;
1481            let old_tail = cache.body.len() - common_suffix;
1482            let block_delta = new_tail as isize - old_tail as isize;
1483            cache
1484                .checkpoints
1485                .iter()
1486                .find(|checkpoint| {
1487                    checkpoint.next_block_index >= old_tail
1488                        && checkpoint
1489                            .next_block_index
1490                            .checked_add_signed(block_delta)
1491                            .is_some_and(|next| next > restart.next_block_index)
1492                })
1493                .copied()
1494                .map(|old| {
1495                    (
1496                        paginator::PaginationCheckpoint {
1497                            next_block_index: old
1498                                .next_block_index
1499                                .checked_add_signed(block_delta)
1500                                .expect("common suffix block index remains in range"),
1501                            page_count: old.page_count,
1502                            next_header_page_number: old.next_header_page_number,
1503                        },
1504                        old,
1505                    )
1506                })
1507        });
1508
1509        let (mut pages, mut outlines, mut checkpoints) = if restart_eligible {
1510            let mut recorded = paginator::paginate_shared_single_section_recorded(
1511                &sections[0],
1512                &self.font_manager,
1513                &media,
1514                &notes,
1515                restart_checkpoint,
1516                tail_source.map(|(stop, _)| stop),
1517            );
1518            #[cfg(test)]
1519            {
1520                self.page_layout_invocations = recorded.pages.len();
1521            }
1522            if recorded.stopped_at.is_none() {
1523                if let Some(checkpoint) = restart_checkpoint {
1524                    let references = body_note_references(input);
1525                    paginator::append_endnote_pages_for_references(
1526                        &mut recorded.pages,
1527                        &references,
1528                        &notes,
1529                        final_geometry,
1530                        checkpoint.page_count,
1531                    );
1532                } else {
1533                    paginator::append_endnote_pages(&mut recorded.pages, &notes, final_geometry);
1534                }
1535            }
1536            for page in &mut recorded.pages {
1537                mark_remaining_artifacts(&mut page.elements);
1538            }
1539            let mut pages = restart_checkpoint.map_or_else(Vec::new, |checkpoint| {
1540                self.restart_cache
1541                    .as_ref()
1542                    .expect("a restart checkpoint belongs to retained pages")
1543                    .raw_pages[..checkpoint.page_count]
1544                    .iter()
1545                    .map(Arc::clone)
1546                    .collect()
1547            });
1548            pages.extend(recorded.pages.into_iter().map(Arc::new));
1549            let mut outlines = restart_checkpoint.map_or_else(Vec::new, |checkpoint| {
1550                self.restart_cache
1551                    .as_ref()
1552                    .expect("a restart checkpoint belongs to retained outlines")
1553                    .outlines
1554                    .iter()
1555                    .filter(|outline| outline.page_index < checkpoint.page_count)
1556                    .cloned()
1557                    .collect()
1558            });
1559            outlines.extend(recorded.outlines);
1560            let mut checkpoints = restart_checkpoint.map_or_else(Vec::new, |checkpoint| {
1561                self.restart_cache
1562                    .as_ref()
1563                    .expect("a restart checkpoint belongs to retained state")
1564                    .checkpoints
1565                    .iter()
1566                    .copied()
1567                    .filter(|candidate| candidate.page_count <= checkpoint.page_count)
1568                    .collect()
1569            });
1570            checkpoints.extend(recorded.checkpoints);
1571            if let (Some(stopped), Some((_, old_tail))) = (recorded.stopped_at, tail_source) {
1572                debug_assert_eq!(stopped.page_count, old_tail.page_count);
1573                let cache = self.restart_cache.as_ref().expect("restart cache exists");
1574                pages.extend(
1575                    cache.raw_pages[old_tail.page_count..]
1576                        .iter()
1577                        .map(Arc::clone),
1578                );
1579                outlines.extend(
1580                    cache
1581                        .outlines
1582                        .iter()
1583                        .filter(|outline| outline.page_index >= old_tail.page_count)
1584                        .cloned(),
1585                );
1586                let block_delta =
1587                    stopped.next_block_index as isize - old_tail.next_block_index as isize;
1588                checkpoints.extend(
1589                    cache
1590                        .checkpoints
1591                        .iter()
1592                        .filter(|candidate| candidate.page_count > old_tail.page_count)
1593                        .map(|candidate| paginator::PaginationCheckpoint {
1594                            next_block_index: candidate
1595                                .next_block_index
1596                                .checked_add_signed(block_delta)
1597                                .expect("common suffix block index remains in range"),
1598                            page_count: candidate.page_count,
1599                            next_header_page_number: candidate.next_header_page_number,
1600                        }),
1601                );
1602            }
1603            checkpoints.sort_unstable_by_key(|checkpoint| checkpoint.next_block_index);
1604            checkpoints.dedup();
1605            (pages, outlines, checkpoints)
1606        } else {
1607            let (mut pages, outlines) =
1608                paginator::paginate_shared_sections(&sections, &self.font_manager, &media, &notes);
1609            #[cfg(test)]
1610            {
1611                self.page_layout_invocations = pages.len();
1612            }
1613            // Endnotes read at the end of the document, so they follow the last
1614            // body page rather than sitting at the foot of their reference's page.
1615            paginator::append_endnote_pages(&mut pages, &notes, final_geometry);
1616            apply_page_background(&mut pages, input);
1617            for page in &mut pages {
1618                mark_remaining_artifacts(&mut page.elements);
1619            }
1620            (
1621                pages.into_iter().map(Arc::new).collect(),
1622                outlines,
1623                Vec::new(),
1624            )
1625        };
1626        if body_unchanged
1627            && let Some(cache) = self.restart_cache.as_ref()
1628            && cache.raw_pages.len() == pages.len()
1629        {
1630            for (page, retained) in pages.iter_mut().zip(&cache.raw_pages) {
1631                *page = Arc::clone(retained);
1632            }
1633        }
1634        let mut raw_pages = restart_record_eligible.then(|| pages.clone());
1635
1636        // Post-pagination pass: record bookmark targets and substitute fields.
1637        let total_pages = pages.len();
1638        let bookmark_pages = pages
1639            .iter()
1640            .flat_map(|page| {
1641                let mut targets = Vec::new();
1642                oxml_layout::walk(&page.elements, &mut |element, _| {
1643                    if let PositionedElement::Text(run) = element
1644                        && let Some(FieldKind::Target(target)) = run.field_kind
1645                    {
1646                        targets.push((target, page.page_number));
1647                    }
1648                });
1649                targets
1650            })
1651            .collect::<HashMap<_, _>>();
1652        let mut bookmark_identity = bookmark_pages
1653            .iter()
1654            .map(|(&target, &page_number)| (target, page_number))
1655            .collect::<Vec<_>>();
1656        bookmark_identity.sort_unstable();
1657        let mut substitution_inputs = Vec::with_capacity(pages.len());
1658        let mut reuse_result_pages = vec![false; pages.len()];
1659        for (page_index, page) in pages.iter_mut().enumerate() {
1660            if !page_has_substitution_state(page) {
1661                reuse_result_pages[page_index] = self.restart_cache.as_ref().is_some_and(|cache| {
1662                    cache.substitution_inputs.get(page_index) == Some(&None)
1663                        && cache
1664                            .raw_pages
1665                            .get(page_index)
1666                            .is_some_and(|retained| Arc::ptr_eq(page, retained))
1667                });
1668                substitution_inputs.push(None);
1669                continue;
1670            }
1671            let inputs = FieldSubstitutionInputs {
1672                page_index,
1673                page_number: page.page_number,
1674                total_pages,
1675                bookmark_pages: bookmark_identity.clone(),
1676                font_identity: font_trace.clone(),
1677                revision_view: input.revision_view,
1678            };
1679            let reusable = self.restart_cache.as_ref().is_some_and(|cache| {
1680                cache
1681                    .substitution_inputs
1682                    .get(page_index)
1683                    .and_then(Option::as_ref)
1684                    == Some(&inputs)
1685                    && cache
1686                        .raw_pages
1687                        .get(page_index)
1688                        .is_some_and(|retained| Arc::ptr_eq(page, retained))
1689            });
1690            if reusable {
1691                reuse_result_pages[page_index] = true;
1692                substitution_inputs.push(Some(inputs));
1693                continue;
1694            }
1695            let page = Arc::make_mut(page);
1696            let page_num = page.page_number;
1697            substitute_fields(
1698                &mut page.elements,
1699                page_num,
1700                total_pages,
1701                &bookmark_pages,
1702                &mut self.font_manager,
1703            );
1704            substitution_inputs.push(Some(inputs));
1705        }
1706
1707        #[cfg(test)]
1708        {
1709            self.last_rebuilt_page_range = Some(0..pages.len());
1710        }
1711        if restart_checkpoint.is_some()
1712            && let Some(cache) = self.restart_cache.as_ref()
1713            && cache.font_trace == font_trace
1714        {
1715            let mut rebuilt_start = pages.len();
1716            let mut rebuilt_end = 0;
1717            for (page_index, (page, retained)) in pages.iter().zip(&cache.raw_pages).enumerate() {
1718                if Arc::ptr_eq(page, retained) {
1719                    reuse_result_pages[page_index] = true;
1720                } else {
1721                    rebuilt_start = rebuilt_start.min(page_index);
1722                    rebuilt_end = page_index + 1;
1723                }
1724            }
1725            if pages.len() != cache.pages.len() {
1726                rebuilt_start = rebuilt_start.min(pages.len().min(cache.pages.len()));
1727                rebuilt_end = pages.len();
1728            }
1729            let rebuilt_range = if rebuilt_start < rebuilt_end {
1730                rebuilt_start..rebuilt_end
1731            } else {
1732                0..0
1733            };
1734            #[cfg(test)]
1735            {
1736                self.last_rebuilt_page_range = Some(rebuilt_range);
1737            }
1738            #[cfg(not(test))]
1739            {
1740                let _ = rebuilt_range;
1741            }
1742        }
1743        // Metrics-only empty carriers must still resolve through the result,
1744        // but they do not get to move a glyph-bearing font earlier in the
1745        // deterministic result order.
1746        let mut carrier_fonts = Vec::new();
1747        fn collect_carrier_fonts(elements: &[PositionedElement], fonts: &mut Vec<FontId>) {
1748            for element in elements {
1749                match element {
1750                    PositionedElement::Text(run)
1751                        if run.text.is_empty() && run.glyph_ids.is_empty() =>
1752                    {
1753                        if !fonts.contains(&run.font_id) {
1754                            fonts.push(run.font_id);
1755                        }
1756                    }
1757                    PositionedElement::Group(group) => {
1758                        collect_carrier_fonts(&group.children, fonts)
1759                    }
1760                    PositionedElement::MarkedContent { children, .. } => {
1761                        collect_carrier_fonts(children, fonts)
1762                    }
1763                    _ => {}
1764                }
1765            }
1766        }
1767        for page in &pages {
1768            collect_carrier_fonts(&page.elements, &mut carrier_fonts);
1769        }
1770        self.font_manager.replay_layout_font_trace(&carrier_fonts);
1771
1772        // Remap persistent manager ids to result-local ids and omit faces that
1773        // are no longer present in the current layout.
1774        let fonts = if self.font_manager.every_loaded_font_is_current() {
1775            self.font_manager.all_font_data()
1776        } else {
1777            let current_fonts = self.font_manager.current_layout_fonts().to_vec();
1778            canonicalize_layout_fonts(&mut pages, &self.font_manager, &current_fonts)?
1779        };
1780        if let Some(cache) = self.restart_cache.as_ref() {
1781            for (page_index, reuse) in reuse_result_pages.into_iter().enumerate() {
1782                if reuse && let Some(retained) = cache.pages.get(page_index) {
1783                    pages[page_index] = Arc::clone(retained);
1784                }
1785            }
1786        }
1787        let mut retained_pages = restart_record_eligible.then(|| pages.clone());
1788
1789        // Convert core properties to document metadata
1790        let metadata = input.core_properties.as_ref().map(|cp| DocumentMetadata {
1791            title: cp.title.clone(),
1792            author: cp.creator.clone(),
1793            subject: cp.subject.clone(),
1794            keywords: cp.keywords.clone(),
1795            creator: Some("rdocx".to_string()),
1796        });
1797
1798        if restart_record_eligible
1799            && pages.len().max(checkpoints.len()) <= RESTART_CACHE_MAX_ENTRIES
1800            && let Some(raw_pages) = raw_pages.as_mut()
1801            && let Some(retained_pages) = retained_pages.as_mut()
1802        {
1803            let old_body = self
1804                .restart_cache
1805                .as_ref()
1806                .map(|cache| cache.body.as_slice());
1807            let prefix = first_changed.unwrap_or(0);
1808            let suffix = common_suffix.unwrap_or(0);
1809            let new_len = input.document.body.content.len();
1810            let old_len = old_body.map_or(0, <[RestartBodyEntry]>::len);
1811            let mut body = input
1812                .document
1813                .body
1814                .content
1815                .iter()
1816                .enumerate()
1817                .filter_map(|(index, content)| {
1818                    if index < prefix {
1819                        return old_body.and_then(|body| body.get(index)).cloned();
1820                    }
1821                    if index >= new_len.saturating_sub(suffix) {
1822                        let old_index = old_len.saturating_sub(new_len - index);
1823                        return old_body.and_then(|body| body.get(old_index)).cloned();
1824                    }
1825                    RestartBodyEntry::for_content(content, input.revision_view)
1826                })
1827                .collect::<Vec<_>>();
1828            let body_complete = body.len() == new_len;
1829            body.shrink_to_fit();
1830            raw_pages.shrink_to_fit();
1831            retained_pages.shrink_to_fit();
1832            substitution_inputs.shrink_to_fit();
1833            outlines.shrink_to_fit();
1834            checkpoints.shrink_to_fit();
1835            font_trace.shrink_to_fit();
1836            let mut candidate = RestartCache {
1837                body,
1838                with_provenance: sources.is_some(),
1839                raw_pages: std::mem::take(raw_pages),
1840                pages: std::mem::take(retained_pages),
1841                substitution_inputs,
1842                outlines: outlines.clone(),
1843                checkpoints,
1844                font_trace,
1845                bytes: 0,
1846            };
1847            candidate.outlines.shrink_to_fit();
1848            for inputs in candidate.substitution_inputs.iter_mut().flatten() {
1849                inputs.bookmark_pages.shrink_to_fit();
1850                inputs.font_identity.shrink_to_fit();
1851            }
1852            let bytes = if body_complete {
1853                restart_cache_bytes(&candidate)
1854            } else {
1855                usize::MAX
1856            };
1857            #[cfg(test)]
1858            {
1859                self.last_restart_candidate_bytes = bytes;
1860            }
1861            let entries = restart_cache_entries(&candidate);
1862            if self.restart_candidate_fits_aggregate(entries, bytes) {
1863                candidate.bytes = bytes;
1864                self.restart_cache = Some(candidate);
1865            } else {
1866                self.restart_cache = None;
1867            }
1868        } else {
1869            self.restart_cache = None;
1870        }
1871        let mut result = LayoutResult::new(pages, fonts, metadata, outlines);
1872        result.diagnostics = diagnostics;
1873        result.structure = Some(structure);
1874        Ok(result)
1875    }
1876
1877    #[allow(clippy::too_many_arguments)]
1878    fn layout_body_paragraph(
1879        &mut self,
1880        paragraph: &CT_P,
1881        content_width: f64,
1882        styles: &CT_Styles,
1883        input: &LayoutInput,
1884        media: &MediaRegistry,
1885        numbering: &mut NumberingState,
1886        diagnostics: &mut Vec<Diagnostic>,
1887        source_node: Option<SourceNodeId>,
1888    ) -> Result<SharedLayoutBlock> {
1889        if !paragraph_is_cache_safe(paragraph, styles) {
1890            // Traversal-sensitive content can change generated state consumed
1891            // by later blocks. The conservative boundary is the first such
1892            // block, after which no retained block is read in this layout.
1893            self.paragraph_cache_reads_enabled = false;
1894            let (block, reflow_direction) = layout_paragraph_with_source_and_direction(
1895                paragraph,
1896                content_width,
1897                styles,
1898                input,
1899                media,
1900                &mut self.font_manager,
1901                numbering,
1902                diagnostics,
1903                source_node,
1904            )?;
1905            return Ok(SharedLayoutBlock::Owned {
1906                block: Box::new(LayoutBlock::Paragraph(block)),
1907                reflow_direction,
1908            });
1909        }
1910
1911        let fingerprint = paragraph_fingerprint(paragraph);
1912        if self.paragraph_cache_reads_enabled
1913            && let Some(entry) = self.paragraph_cache.iter().find(|entry| {
1914                entry.fingerprint == fingerprint
1915                    && entry.key.paragraph == *paragraph
1916                    && entry.key.content_width_bits == content_width.to_bits()
1917                    && entry.key.revision_view == input.revision_view
1918            })
1919        {
1920            diagnostics.extend(entry.diagnostics.iter().cloned());
1921            self.font_manager
1922                .replay_layout_font_trace(&entry.font_trace);
1923            self.paragraph_cache_hits += 1;
1924            return Ok(SharedLayoutBlock::Paragraph {
1925                block: Arc::clone(&entry.block),
1926                semantics: ParagraphSemantics {
1927                    source_node,
1928                    structure_id: None,
1929                    reflow_direction: entry.reflow_direction,
1930                },
1931            });
1932        }
1933
1934        let diagnostics_start = diagnostics.len();
1935        self.font_manager.begin_paragraph_font_trace();
1936        let block_result = layout_paragraph_with_source_and_direction(
1937            paragraph,
1938            content_width,
1939            styles,
1940            input,
1941            media,
1942            &mut self.font_manager,
1943            numbering,
1944            diagnostics,
1945            Some(CACHE_SOURCE_NODE),
1946        );
1947        let font_trace = self.font_manager.finish_paragraph_font_trace();
1948        let (mut block, reflow_direction) = block_result?;
1949        self.paragraph_cache_builds += 1;
1950
1951        let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
1952        if let Some(font_trace) = font_trace {
1953            let bytes = paragraph_cache_entry_bytes(
1954                paragraph,
1955                &block,
1956                &cached_diagnostics,
1957                font_trace.len(),
1958            );
1959            let block = Arc::new(block);
1960            self.stage_paragraph_cache_entry(ParagraphCacheEntry {
1961                fingerprint,
1962                key: ParagraphCacheKey {
1963                    paragraph: paragraph.clone(),
1964                    content_width_bits: content_width.to_bits(),
1965                    revision_view: input.revision_view,
1966                },
1967                block: Arc::clone(&block),
1968                diagnostics: cached_diagnostics,
1969                font_trace,
1970                reflow_direction,
1971                bytes,
1972            });
1973            return Ok(SharedLayoutBlock::Paragraph {
1974                block,
1975                semantics: ParagraphSemantics {
1976                    source_node,
1977                    structure_id: None,
1978                    reflow_direction,
1979                },
1980            });
1981        }
1982
1983        rebind_paragraph_source(&mut block, source_node)?;
1984        Ok(SharedLayoutBlock::Owned {
1985            block: Box::new(LayoutBlock::Paragraph(block)),
1986            reflow_direction,
1987        })
1988    }
1989
1990    #[allow(clippy::too_many_arguments)]
1991    fn layout_body_table(
1992        &mut self,
1993        table: &CT_Tbl,
1994        content_width: f64,
1995        styles: &CT_Styles,
1996        input: &LayoutInput,
1997        media: &MediaRegistry,
1998        numbering: &mut NumberingState,
1999        diagnostics: &mut Vec<Diagnostic>,
2000        sources: Option<&SourceRegistry>,
2001        story: &WordStory,
2002        path: &[usize],
2003    ) -> Result<SharedLayoutBlock> {
2004        if !table_is_cache_safe(table, styles) {
2005            self.paragraph_cache_reads_enabled = false;
2006            return table::layout_table_with_provenance(
2007                table,
2008                content_width,
2009                styles,
2010                input,
2011                media,
2012                &mut self.font_manager,
2013                numbering,
2014                diagnostics,
2015                sources,
2016                story,
2017                path,
2018            )
2019            .map(|(block, semantics)| SharedLayoutBlock::Table {
2020                block: Arc::new(block),
2021                semantics,
2022            });
2023        }
2024
2025        let fingerprint = table_fingerprint(table);
2026        if self.paragraph_cache_reads_enabled
2027            && let Some(entry) = self.table_cache.iter().find(|entry| {
2028                entry.fingerprint == fingerprint
2029                    && entry.key.table == *table
2030                    && entry.key.content_width_bits == content_width.to_bits()
2031                    && entry.key.revision_view == input.revision_view
2032                    && entry.key.with_provenance == sources.is_some()
2033            })
2034        {
2035            diagnostics.extend(entry.diagnostics.iter().cloned());
2036            self.font_manager
2037                .replay_layout_font_trace(&entry.font_trace);
2038            self.table_cache_hits += 1;
2039            return Ok(SharedLayoutBlock::Table {
2040                block: Arc::clone(&entry.block),
2041                semantics: table_semantics(
2042                    table,
2043                    entry.block.as_ref(),
2044                    &entry.semantics,
2045                    sources,
2046                    story,
2047                    path,
2048                ),
2049            });
2050        }
2051
2052        let diagnostics_start = diagnostics.len();
2053        self.font_manager.begin_paragraph_font_trace();
2054        let block_result = table::layout_table_with_provenance(
2055            table,
2056            content_width,
2057            styles,
2058            input,
2059            media,
2060            &mut self.font_manager,
2061            numbering,
2062            diagnostics,
2063            sources,
2064            story,
2065            path,
2066        );
2067        let font_trace = self.font_manager.finish_paragraph_font_trace();
2068        let (mut block, semantics) = block_result?;
2069        self.table_cache_builds += 1;
2070
2071        let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
2072        if let Some(font_trace) = font_trace {
2073            canonicalize_table_sources(&mut block)?;
2074            let key = TableCacheKey {
2075                table: table.clone(),
2076                content_width_bits: content_width.to_bits(),
2077                revision_view: input.revision_view,
2078                with_provenance: sources.is_some(),
2079            };
2080            let bytes = table_cache_entry_bytes(
2081                &key,
2082                &block,
2083                &semantics,
2084                &cached_diagnostics,
2085                font_trace.len(),
2086            );
2087            let block = Arc::new(block);
2088            self.stage_table_cache_entry(TableCacheEntry {
2089                fingerprint,
2090                key,
2091                block: Arc::clone(&block),
2092                semantics: semantics.clone(),
2093                diagnostics: cached_diagnostics,
2094                font_trace,
2095                bytes,
2096            });
2097            return Ok(SharedLayoutBlock::Table { block, semantics });
2098        }
2099        Ok(SharedLayoutBlock::Table {
2100            block: Arc::new(block),
2101            semantics,
2102        })
2103    }
2104
2105    #[cfg(test)]
2106    fn paragraph_cache_counts(&self) -> (usize, usize) {
2107        (self.paragraph_cache_hits, self.paragraph_cache_builds)
2108    }
2109
2110    #[cfg(test)]
2111    fn table_cache_counts(&self) -> (usize, usize) {
2112        (self.table_cache_hits, self.table_cache_builds)
2113    }
2114
2115    #[cfg(test)]
2116    fn owned_context_build_count(&self) -> usize {
2117        self.owned_context_builds
2118    }
2119
2120    #[cfg(test)]
2121    fn hot_path_work_counts(&self) -> (usize, usize) {
2122        (self.body_debug_work, self.retained_page_deep_copies)
2123    }
2124
2125    #[cfg(test)]
2126    fn page_layout_invocation_count(&self) -> usize {
2127        self.page_layout_invocations
2128    }
2129
2130    fn publish_paragraph_cache_entry(&mut self, entry: ParagraphCacheEntry) {
2131        if entry.bytes > PARAGRAPH_CACHE_MAX_BYTES {
2132            return;
2133        }
2134        while self.paragraph_cache.len() >= PARAGRAPH_CACHE_MAX_ENTRIES
2135            || self.paragraph_cache_bytes.saturating_add(entry.bytes) > PARAGRAPH_CACHE_MAX_BYTES
2136        {
2137            let Some(evicted) = self.paragraph_cache.pop_front() else {
2138                break;
2139            };
2140            self.paragraph_cache_bytes = self.paragraph_cache_bytes.saturating_sub(evicted.bytes);
2141        }
2142        let Some(bytes) = self.paragraph_cache_bytes.checked_add(entry.bytes) else {
2143            return;
2144        };
2145        if bytes > PARAGRAPH_CACHE_MAX_BYTES {
2146            return;
2147        }
2148        self.paragraph_cache_bytes = bytes;
2149        self.paragraph_cache.push_back(entry);
2150    }
2151
2152    fn restart_candidate_fits_aggregate(
2153        &self,
2154        candidate_entries: usize,
2155        candidate_bytes: usize,
2156    ) -> bool {
2157        let pending_paragraph_entries = self
2158            .pending_paragraph_cache
2159            .as_ref()
2160            .map_or(0, VecDeque::len);
2161        let pending_table_entries = self.pending_table_cache.as_ref().map_or(0, VecDeque::len);
2162        let pending_header_footer_entries = self
2163            .pending_header_footer_cache
2164            .as_ref()
2165            .map_or(0, VecDeque::len);
2166        let entries = self
2167            .paragraph_cache
2168            .len()
2169            .checked_add(self.table_cache.len())
2170            .and_then(|entries| entries.checked_add(self.header_footer_cache.len()))
2171            .and_then(|entries| entries.checked_add(pending_paragraph_entries))
2172            .and_then(|entries| entries.checked_add(pending_table_entries))
2173            .and_then(|entries| entries.checked_add(pending_header_footer_entries))
2174            .and_then(|entries| entries.checked_add(candidate_entries));
2175        let bytes = self
2176            .paragraph_cache_bytes
2177            .checked_add(self.table_cache_bytes)
2178            .and_then(|bytes| bytes.checked_add(self.header_footer_cache_bytes))
2179            .and_then(|bytes| bytes.checked_add(self.pending_paragraph_cache_bytes))
2180            .and_then(|bytes| bytes.checked_add(self.pending_table_cache_bytes))
2181            .and_then(|bytes| bytes.checked_add(self.pending_header_footer_cache_bytes))
2182            .and_then(|bytes| bytes.checked_add(candidate_bytes));
2183        entries.is_some_and(|entries| entries <= CACHE_MAX_ENTRIES)
2184            && bytes.is_some_and(|bytes| bytes <= CACHE_MAX_BYTES)
2185    }
2186
2187    fn stage_paragraph_cache_entry(&mut self, entry: ParagraphCacheEntry) {
2188        if entry.bytes > PARAGRAPH_CACHE_MAX_BYTES {
2189            return;
2190        }
2191        let Some(pending) = self.pending_paragraph_cache.as_mut() else {
2192            return;
2193        };
2194        while pending.len() >= PARAGRAPH_CACHE_MAX_ENTRIES
2195            || self
2196                .pending_paragraph_cache_bytes
2197                .saturating_add(entry.bytes)
2198                > PARAGRAPH_CACHE_MAX_BYTES
2199        {
2200            let Some(evicted) = pending.pop_front() else {
2201                break;
2202            };
2203            self.pending_paragraph_cache_bytes = self
2204                .pending_paragraph_cache_bytes
2205                .saturating_sub(evicted.bytes);
2206        }
2207        self.pending_paragraph_cache_bytes += entry.bytes;
2208        pending.push_back(entry);
2209        #[cfg(test)]
2210        {
2211            self.pending_paragraph_cache_peak_entries =
2212                self.pending_paragraph_cache_peak_entries.max(pending.len());
2213            self.pending_paragraph_cache_peak_bytes = self
2214                .pending_paragraph_cache_peak_bytes
2215                .max(self.pending_paragraph_cache_bytes);
2216        }
2217    }
2218
2219    fn publish_table_cache_entry(&mut self, entry: TableCacheEntry) {
2220        if entry.bytes > TABLE_CACHE_MAX_BYTES {
2221            return;
2222        }
2223        while self.table_cache.len() >= TABLE_CACHE_MAX_ENTRIES
2224            || self.table_cache_bytes.saturating_add(entry.bytes) > TABLE_CACHE_MAX_BYTES
2225        {
2226            let Some(evicted) = self.table_cache.pop_front() else {
2227                break;
2228            };
2229            self.table_cache_bytes = self.table_cache_bytes.saturating_sub(evicted.bytes);
2230        }
2231        self.table_cache_bytes += entry.bytes;
2232        self.table_cache.push_back(entry);
2233        let restart_entries = self.restart_cache.as_ref().map_or(0, restart_cache_entries);
2234        let restart_bytes = self.restart_cache.as_ref().map_or(0, |cache| cache.bytes);
2235        debug_assert!(
2236            self.paragraph_cache.len() + self.table_cache.len() + restart_entries
2237                <= CACHE_MAX_ENTRIES
2238        );
2239        debug_assert!(
2240            self.paragraph_cache_bytes + self.table_cache_bytes + restart_bytes <= CACHE_MAX_BYTES
2241        );
2242    }
2243
2244    fn stage_table_cache_entry(&mut self, entry: TableCacheEntry) {
2245        if entry.bytes > TABLE_CACHE_MAX_BYTES {
2246            return;
2247        }
2248        let Some(pending) = self.pending_table_cache.as_mut() else {
2249            return;
2250        };
2251        while pending.len() >= TABLE_CACHE_MAX_ENTRIES
2252            || self.pending_table_cache_bytes.saturating_add(entry.bytes) > TABLE_CACHE_MAX_BYTES
2253        {
2254            let Some(evicted) = pending.pop_front() else {
2255                break;
2256            };
2257            self.pending_table_cache_bytes =
2258                self.pending_table_cache_bytes.saturating_sub(evicted.bytes);
2259        }
2260        self.pending_table_cache_bytes += entry.bytes;
2261        pending.push_back(entry);
2262        #[cfg(test)]
2263        {
2264            self.pending_table_cache_peak_entries =
2265                self.pending_table_cache_peak_entries.max(pending.len());
2266            self.pending_table_cache_peak_bytes = self
2267                .pending_table_cache_peak_bytes
2268                .max(self.pending_table_cache_bytes);
2269        }
2270    }
2271
2272    #[cfg(test)]
2273    fn header_footer_cache_counts(&self) -> (usize, usize) {
2274        (
2275            self.header_footer_cache_hits,
2276            self.header_footer_cache_builds,
2277        )
2278    }
2279
2280    fn publish_header_footer_cache_entry(&mut self, entry: HeaderFooterCacheEntry) {
2281        if entry.bytes > HEADER_FOOTER_CACHE_MAX_BYTES {
2282            return;
2283        }
2284        while self.header_footer_cache.len() >= HEADER_FOOTER_CACHE_MAX_ENTRIES
2285            || self.header_footer_cache_bytes.saturating_add(entry.bytes)
2286                > HEADER_FOOTER_CACHE_MAX_BYTES
2287        {
2288            let Some(evicted) = self.header_footer_cache.pop_front() else {
2289                break;
2290            };
2291            self.header_footer_cache_bytes =
2292                self.header_footer_cache_bytes.saturating_sub(evicted.bytes);
2293        }
2294        self.header_footer_cache_bytes += entry.bytes;
2295        self.header_footer_cache.push_back(entry);
2296        let restart_entries = self.restart_cache.as_ref().map_or(0, restart_cache_entries);
2297        let restart_bytes = self.restart_cache.as_ref().map_or(0, |cache| cache.bytes);
2298        debug_assert!(
2299            self.paragraph_cache.len()
2300                + self.table_cache.len()
2301                + self.header_footer_cache.len()
2302                + restart_entries
2303                <= CACHE_MAX_ENTRIES
2304        );
2305        debug_assert!(
2306            self.paragraph_cache_bytes
2307                + self.table_cache_bytes
2308                + self.header_footer_cache_bytes
2309                + restart_bytes
2310                <= CACHE_MAX_BYTES
2311        );
2312    }
2313
2314    fn stage_header_footer_cache_entry(&mut self, entry: HeaderFooterCacheEntry) {
2315        if entry.bytes > HEADER_FOOTER_CACHE_MAX_BYTES {
2316            return;
2317        }
2318        let Some(pending) = self.pending_header_footer_cache.as_mut() else {
2319            return;
2320        };
2321        while pending.len() >= HEADER_FOOTER_CACHE_MAX_ENTRIES
2322            || self
2323                .pending_header_footer_cache_bytes
2324                .saturating_add(entry.bytes)
2325                > HEADER_FOOTER_CACHE_MAX_BYTES
2326        {
2327            let Some(evicted) = pending.pop_front() else {
2328                break;
2329            };
2330            self.pending_header_footer_cache_bytes = self
2331                .pending_header_footer_cache_bytes
2332                .saturating_sub(evicted.bytes);
2333        }
2334        self.pending_header_footer_cache_bytes += entry.bytes;
2335        pending.push_back(entry);
2336        #[cfg(test)]
2337        {
2338            self.pending_header_footer_cache_peak_entries = self
2339                .pending_header_footer_cache_peak_entries
2340                .max(pending.len());
2341            self.pending_header_footer_cache_peak_bytes = self
2342                .pending_header_footer_cache_peak_bytes
2343                .max(self.pending_header_footer_cache_bytes);
2344        }
2345    }
2346}
2347
2348fn paragraph_source_is_cache_safe(
2349    paragraph: &CT_P,
2350    styles: &CT_Styles,
2351    allow_fields: bool,
2352    allow_bookmarks: bool,
2353) -> bool {
2354    let extra_xml_is_represented = paragraph.extra_xml.is_empty()
2355        || (allow_bookmarks && paragraph_bookmark_raw_is_exact(paragraph));
2356    if !paragraph.hyperlinks.is_empty()
2357        || !paragraph.comment_ranges.is_empty()
2358        || (!allow_bookmarks && !paragraph.bookmark_markers.is_empty())
2359        || !extra_xml_is_represented
2360        || !paragraph.content_controls.is_empty()
2361        || !paragraph.revisions.is_empty()
2362    {
2363        return false;
2364    }
2365
2366    let style_id = paragraph
2367        .properties
2368        .as_ref()
2369        .and_then(|properties| properties.style_id.as_deref());
2370    let resolved = style_resolver::resolve_paragraph_properties(style_id, styles);
2371    if resolved.num_id.is_some()
2372        || paragraph.properties.as_ref().is_some_and(|properties| {
2373            properties.num_id.is_some()
2374                || properties.sect_pr.is_some()
2375                || properties.numbering_revision.is_some()
2376                || !properties.numbering_revision_xml.is_empty()
2377                || properties.change.is_some()
2378                || !properties.revision_xml.is_empty()
2379                || properties.rpr.as_ref().is_some_and(|rpr| {
2380                    !rpr.revision_markers.is_empty()
2381                        || rpr.change.is_some()
2382                        || !rpr.revision_xml.is_empty()
2383                        || !rpr.revision_xml_positions.is_empty()
2384                })
2385        })
2386    {
2387        return false;
2388    }
2389
2390    paragraph.runs.iter().all(|run| {
2391        run.alt_drawings.is_empty()
2392            && run.extra_xml.is_empty()
2393            && run.extra_xml_positions.is_empty()
2394            && run.properties.as_ref().is_none_or(|rpr| {
2395                rpr.revision_markers.is_empty()
2396                    && rpr.change.is_none()
2397                    && rpr.revision_xml.is_empty()
2398                    && rpr.revision_xml_positions.is_empty()
2399            })
2400            && run.content.iter().all(|content| match content {
2401                RunContent::Text(_)
2402                | RunContent::Tab
2403                | RunContent::Break(_)
2404                | RunContent::FootnoteRef { .. }
2405                | RunContent::EndnoteRef { .. } => true,
2406                RunContent::Field(_) => allow_fields,
2407                _ => false,
2408            })
2409    })
2410}
2411
2412fn paragraph_is_restart_record_source_safe(paragraph: &CT_P, styles: &CT_Styles) -> bool {
2413    paragraph_source_is_cache_safe(paragraph, styles, true, true)
2414        && paragraph.runs.iter().all(|run| {
2415            run.properties
2416                .as_ref()
2417                .is_none_or(|properties| properties.language.is_none())
2418        })
2419}
2420
2421fn paragraph_is_cache_safe(paragraph: &CT_P, styles: &CT_Styles) -> bool {
2422    paragraph_source_is_cache_safe(paragraph, styles, false, false)
2423}
2424
2425fn paragraph_bookmark_raw_is_exact(paragraph: &CT_P) -> bool {
2426    paragraph.extra_xml.len() == paragraph.bookmark_markers.len()
2427        && paragraph
2428            .extra_xml
2429            .iter()
2430            .enumerate()
2431            .zip(&paragraph.bookmark_markers)
2432            .all(|((raw_index, (run_index, raw)), marker)| {
2433                let raw_before = paragraph.extra_xml[..raw_index]
2434                    .iter()
2435                    .filter(|(at, _)| at == run_index)
2436                    .count();
2437                *run_index == marker.run_index()
2438                    && raw_before == marker.raw_before()
2439                    && raw_xml_is_exact_word_bookmark(raw, marker)
2440            })
2441}
2442
2443fn raw_xml_is_exact_word_bookmark(raw: &[u8], marker: &BookmarkMarker) -> bool {
2444    let Some(start) = raw.iter().position(|byte| !byte.is_ascii_whitespace()) else {
2445        return false;
2446    };
2447    let end = raw
2448        .iter()
2449        .rposition(|byte| !byte.is_ascii_whitespace())
2450        .expect("non-empty raw XML has a final byte");
2451    let raw = &raw[start..=end];
2452    if !raw.ends_with(b"/>") {
2453        return false;
2454    }
2455    let Some((root_name, raw_attributes)) = raw_root_start_tag(raw) else {
2456        return false;
2457    };
2458    let Some(attributes) = parse_raw_attributes(raw_attributes) else {
2459        return false;
2460    };
2461    let expected_local_name = if marker.is_start() {
2462        b"bookmarkStart".as_slice()
2463    } else {
2464        b"bookmarkEnd".as_slice()
2465    };
2466    if xml_local_name(root_name) != expected_local_name
2467        || !raw_name_has_namespace(root_name, &attributes, rdocx_oxml::namespace::W_NS, false)
2468    {
2469        return false;
2470    }
2471    let word_attribute_count = |local: &[u8]| {
2472        attributes
2473            .iter()
2474            .filter(|(name, _)| {
2475                xml_local_name(name) == local
2476                    && raw_name_has_namespace(name, &attributes, rdocx_oxml::namespace::W_NS, true)
2477            })
2478            .count()
2479    };
2480    if word_attribute_count(b"id") != 1
2481        || word_attribute_count(b"name") != usize::from(marker.is_start())
2482    {
2483        return false;
2484    }
2485
2486    let mut document_xml = Vec::with_capacity(raw.len() + 160);
2487    document_xml.extend_from_slice(
2488        br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>"#,
2489    );
2490    document_xml.extend_from_slice(raw);
2491    document_xml.extend_from_slice(b"</w:p></w:body></w:document>");
2492    let Ok(document) = CT_Document::from_xml(&document_xml) else {
2493        return false;
2494    };
2495    let [BodyContent::Paragraph(parsed)] = document.body.content.as_slice() else {
2496        return false;
2497    };
2498    let [(run_index, parsed_raw)] = parsed.extra_xml.as_slice() else {
2499        return false;
2500    };
2501    let [parsed_marker] = parsed.bookmark_markers.as_slice() else {
2502        return false;
2503    };
2504    parsed.properties.is_none()
2505        && parsed.runs.is_empty()
2506        && parsed.hyperlinks.is_empty()
2507        && parsed.comment_ranges.is_empty()
2508        && parsed.content_controls.is_empty()
2509        && parsed.revisions.is_empty()
2510        && *run_index == 0
2511        && parsed_raw == raw
2512        && parsed_marker.raw_before() == 0
2513        && parsed_marker.is_start() == marker.is_start()
2514        && parsed_marker.id().is_some()
2515        && parsed_marker.id() == marker.id()
2516        && parsed_marker.name() == marker.name()
2517        && if parsed_marker.is_start() {
2518            parsed_marker.name().is_some()
2519        } else {
2520            parsed_marker.name().is_none()
2521        }
2522}
2523
2524fn paragraph_has_field(paragraph: &CT_P) -> bool {
2525    paragraph
2526        .runs
2527        .iter()
2528        .flat_map(|run| &run.content)
2529        .any(|content| matches!(content, RunContent::Field(_)))
2530}
2531
2532fn paragraph_has_note_reference(paragraph: &CT_P) -> bool {
2533    paragraph.runs.iter().any(|run| {
2534        run.content.iter().any(|content| {
2535            matches!(
2536                content,
2537                RunContent::FootnoteRef { .. } | RunContent::EndnoteRef { .. }
2538            )
2539        })
2540    })
2541}
2542
2543fn header_footer_part_is_cache_safe(
2544    part: &rdocx_oxml::header_footer::CT_HdrFtr,
2545    styles: &CT_Styles,
2546) -> bool {
2547    if !part.extra_xml.is_empty() {
2548        return false;
2549    }
2550    let raw_watermark_count = part
2551        .paragraphs
2552        .iter()
2553        .flat_map(|paragraph| &paragraph.runs)
2554        .flat_map(|run| &run.extra_xml)
2555        .filter(|raw| raw_xml_root_is_word_pict(raw, &part.extra_namespaces))
2556        .count();
2557    if raw_watermark_count != part.watermarks().len() {
2558        return false;
2559    }
2560    part.paragraphs.iter().all(|paragraph| {
2561        if !paragraph.extra_xml.is_empty() {
2562            return false;
2563        }
2564        let mut projected = paragraph.clone();
2565        for run in &mut projected.runs {
2566            if run.extra_xml.len() != run.extra_xml_positions.len()
2567                || !run
2568                    .extra_xml
2569                    .iter()
2570                    .all(|raw| raw_xml_root_is_word_pict(raw, &part.extra_namespaces))
2571            {
2572                return false;
2573            }
2574            run.extra_xml.clear();
2575            run.extra_xml_positions.clear();
2576        }
2577        !paragraph_has_note_reference(&projected) && paragraph_is_cache_safe(&projected, styles)
2578    })
2579}
2580
2581fn raw_xml_root_is_word_pict(raw: &[u8], namespaces: &[(String, String)]) -> bool {
2582    let raw = raw
2583        .iter()
2584        .position(|byte| !byte.is_ascii_whitespace())
2585        .map_or(raw, |start| &raw[start..]);
2586    let Some((name, attributes)) = raw.strip_prefix(b"<").and_then(|raw| {
2587        let name_end = raw
2588            .iter()
2589            .position(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/'))?;
2590        Some((&raw[..name_end], &raw[name_end..]))
2591    }) else {
2592        return false;
2593    };
2594    let mut components = name.rsplitn(2, |byte| *byte == b':');
2595    if components.next() != Some(b"pict".as_slice()) {
2596        return false;
2597    }
2598    let prefix = components.next();
2599    let declaration = prefix.map_or_else(
2600        || "xmlns".to_owned(),
2601        |prefix| format!("xmlns:{}", String::from_utf8_lossy(prefix)),
2602    );
2603    if let Some(namespace) = raw_xml_start_attribute(attributes, declaration.as_bytes()) {
2604        return namespace == rdocx_oxml::namespace::W_NS.as_bytes();
2605    }
2606    let Some(prefix) = prefix.and_then(|prefix| std::str::from_utf8(prefix).ok()) else {
2607        return false;
2608    };
2609    if prefix == "w" {
2610        return !namespaces.iter().any(|(name, namespace)| {
2611            name != "xmlns:w" && namespace == rdocx_oxml::namespace::W_NS
2612        });
2613    }
2614    namespaces.iter().any(|(name, namespace)| {
2615        name.strip_prefix("xmlns:") == Some(prefix) && namespace == rdocx_oxml::namespace::W_NS
2616    })
2617}
2618
2619fn raw_xml_start_attribute<'a>(mut input: &'a [u8], expected: &[u8]) -> Option<&'a [u8]> {
2620    loop {
2621        input = input
2622            .iter()
2623            .position(|byte| !byte.is_ascii_whitespace())
2624            .map_or(input, |start| &input[start..]);
2625        if input.first().is_none_or(|byte| matches!(byte, b'>' | b'/')) {
2626            return None;
2627        }
2628        let name_end = input
2629            .iter()
2630            .position(|byte| byte.is_ascii_whitespace() || matches!(byte, b'=' | b'>' | b'/'))?;
2631        let name = &input[..name_end];
2632        input = &input[name_end..];
2633        input = input
2634            .iter()
2635            .position(|byte| !byte.is_ascii_whitespace())
2636            .map_or(input, |start| &input[start..]);
2637        if input.first() != Some(&b'=') {
2638            return None;
2639        }
2640        input = &input[1..];
2641        input = input
2642            .iter()
2643            .position(|byte| !byte.is_ascii_whitespace())
2644            .map_or(input, |start| &input[start..]);
2645        let quote = *input.first()?;
2646        if !matches!(quote, b'\'' | b'"') {
2647            return None;
2648        }
2649        input = &input[1..];
2650        let value_end = input.iter().position(|byte| *byte == quote)?;
2651        let value = &input[..value_end];
2652        input = &input[value_end + 1..];
2653        if name == expected {
2654            return Some(value);
2655        }
2656    }
2657}
2658
2659fn header_footer_section_is_cache_safe(section: &CT_SectPr) -> bool {
2660    section.change.is_none() && section.extra_xml.is_empty()
2661}
2662
2663fn table_is_cache_safe(table: &CT_Tbl, styles: &CT_Styles) -> bool {
2664    table.extra_xml.is_empty()
2665        && table.content_controls.is_empty()
2666        && table.properties.as_ref().is_none_or(|properties| {
2667            properties.change.is_none() && properties.revision_xml.is_empty()
2668        })
2669        && table.rows.iter().all(|row| {
2670            row.extra_xml.is_empty()
2671                && row.content_controls.is_empty()
2672                && row.properties.as_ref().is_none_or(|properties| {
2673                    properties.revision_markers.is_empty() && properties.revision_xml.is_empty()
2674                })
2675                && row.cells.iter().all(|cell| {
2676                    cell.extra_xml.is_empty()
2677                        && cell
2678                            .properties
2679                            .as_ref()
2680                            .is_none_or(|properties| properties.extra_xml.is_empty())
2681                        && cell.content.iter().all(|content| match content {
2682                            CellContent::Paragraph(paragraph) => {
2683                                !paragraph_has_note_reference(paragraph)
2684                                    && paragraph_is_cache_safe(paragraph, styles)
2685                            }
2686                            CellContent::Table(table) => table_is_cache_safe(table, styles),
2687                            CellContent::ContentControl(_) => false,
2688                        })
2689                })
2690        })
2691}
2692
2693fn table_semantics(
2694    table: &CT_Tbl,
2695    block: &table::TableBlock,
2696    retained: &TableSemantics,
2697    sources: Option<&SourceRegistry>,
2698    story: &WordStory,
2699    table_path: &[usize],
2700) -> TableSemantics {
2701    let rows = table
2702        .rows
2703        .iter()
2704        .zip(&block.rows)
2705        .zip(&retained.rows)
2706        .enumerate()
2707        .map(|(row_index, ((row, block_row), retained_row))| {
2708            let cells = row
2709                .cells
2710                .iter()
2711                .zip(&block_row.cells)
2712                .zip(&retained_row.cells)
2713                .enumerate()
2714                .map(|(cell_index, ((cell, block_cell), retained_cell))| {
2715                    let blocks = cell
2716                        .content
2717                        .iter()
2718                        .zip(&block_cell.blocks)
2719                        .zip(&retained_cell.blocks)
2720                        .enumerate()
2721                        .map(|(content_index, ((content, block_item), retained_item))| {
2722                            let mut source_path = table_path.to_vec();
2723                            source_path.extend([row_index, cell_index, content_index]);
2724                            match (content, block_item, retained_item) {
2725                                (
2726                                    CellContent::Paragraph(_),
2727                                    table::CellBlock::Paragraph(_),
2728                                    CellBlockSemantics::Paragraph(retained),
2729                                ) => CellBlockSemantics::Paragraph(ParagraphSemantics {
2730                                    source_node: sources
2731                                        .and_then(|sources| sources.id(story, &source_path)),
2732                                    structure_id: None,
2733                                    reflow_direction: retained.reflow_direction,
2734                                }),
2735                                (
2736                                    CellContent::Table(table),
2737                                    table::CellBlock::Table(block),
2738                                    CellBlockSemantics::Table(retained),
2739                                ) => CellBlockSemantics::Table(table_semantics(
2740                                    table,
2741                                    block,
2742                                    retained,
2743                                    sources,
2744                                    story,
2745                                    &source_path,
2746                                )),
2747                                _ => unreachable!("cache-safe table topology stays aligned"),
2748                            }
2749                        })
2750                        .collect();
2751                    block::CellSemantics { blocks }
2752                })
2753                .collect();
2754            block::RowSemantics { cells }
2755        })
2756        .collect();
2757    TableSemantics { rows }
2758}
2759
2760fn canonicalize_table_sources(block: &mut table::TableBlock) -> Result<()> {
2761    for row in &mut block.rows {
2762        for cell in &mut row.cells {
2763            for block in &mut cell.blocks {
2764                match block {
2765                    table::CellBlock::Paragraph(paragraph) => {
2766                        rebind_paragraph_source(paragraph, Some(CACHE_SOURCE_NODE))?;
2767                    }
2768                    table::CellBlock::Table(table) => canonicalize_table_sources(table)?,
2769                }
2770            }
2771        }
2772    }
2773    Ok(())
2774}
2775
2776fn table_cache_entry_bytes(
2777    key: &TableCacheKey,
2778    block: &table::TableBlock,
2779    semantics: &TableSemantics,
2780    diagnostics: &[Diagnostic],
2781    font_trace_len: usize,
2782) -> usize {
2783    let diagnostic_bytes = diagnostics
2784        .len()
2785        .saturating_mul(std::mem::size_of::<Diagnostic>())
2786        .saturating_add(
2787            diagnostics
2788                .iter()
2789                .map(|diagnostic| diagnostic.message.capacity())
2790                .fold(0usize, usize::saturating_add),
2791        );
2792    std::mem::size_of::<TableCacheEntry>()
2793        .saturating_add(table_key_retained_bytes(&key.table))
2794        .saturating_add(table_block_retained_bytes(block))
2795        .saturating_add(table_semantics_retained_bytes(semantics))
2796        .saturating_add(2 * std::mem::size_of::<usize>())
2797        .saturating_add(font_trace_len.saturating_mul(std::mem::size_of::<FontId>()))
2798        .saturating_add(diagnostic_bytes)
2799}
2800
2801fn table_semantics_retained_bytes(semantics: &TableSemantics) -> usize {
2802    let mut bytes = semantics
2803        .rows
2804        .capacity()
2805        .saturating_mul(std::mem::size_of::<block::RowSemantics>());
2806    for row in &semantics.rows {
2807        bytes = bytes.saturating_add(
2808            row.cells
2809                .capacity()
2810                .saturating_mul(std::mem::size_of::<block::CellSemantics>()),
2811        );
2812        for cell in &row.cells {
2813            bytes = bytes.saturating_add(
2814                cell.blocks
2815                    .capacity()
2816                    .saturating_mul(std::mem::size_of::<CellBlockSemantics>()),
2817            );
2818            for block in &cell.blocks {
2819                if let CellBlockSemantics::Table(table) = block {
2820                    bytes = bytes.saturating_add(table_semantics_retained_bytes(table));
2821                }
2822            }
2823        }
2824    }
2825    bytes
2826}
2827
2828fn paragraph_key_retained_bytes(paragraph: &CT_P) -> usize {
2829    fn option_string_bytes(value: &Option<String>) -> usize {
2830        value.as_ref().map_or(0, String::capacity)
2831    }
2832    fn raw_vectors_bytes(values: &[Vec<u8>]) -> usize {
2833        values
2834            .len()
2835            .saturating_mul(std::mem::size_of::<Vec<u8>>())
2836            .saturating_add(
2837                values
2838                    .iter()
2839                    .map(Vec::capacity)
2840                    .fold(0usize, usize::saturating_add),
2841            )
2842    }
2843    fn run_properties_bytes(properties: &CT_RPr) -> usize {
2844        [
2845            &properties.style_id,
2846            &properties.font_ascii,
2847            &properties.font_hansi,
2848            &properties.font_east_asia,
2849            &properties.font_cs,
2850            &properties.font_ascii_theme,
2851            &properties.font_hansi_theme,
2852            &properties.color,
2853            &properties.color_theme,
2854            &properties.vert_align,
2855        ]
2856        .into_iter()
2857        .map(option_string_bytes)
2858        .fold(0usize, usize::saturating_add)
2859        .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
2860        .saturating_add(
2861            properties
2862                .revision_markers
2863                .capacity()
2864                .saturating_mul(std::mem::size_of::<CT_Revision>()),
2865        )
2866        .saturating_add(raw_vectors_bytes(&properties.revision_xml))
2867        .saturating_add(
2868            properties
2869                .revision_xml_positions
2870                .capacity()
2871                .saturating_mul(std::mem::size_of::<(u8, usize)>()),
2872        )
2873    }
2874    fn shading_bytes(shading: &CT_Shd) -> usize {
2875        shading
2876            .val
2877            .capacity()
2878            .saturating_add(option_string_bytes(&shading.color))
2879            .saturating_add(option_string_bytes(&shading.fill))
2880    }
2881    fn paragraph_border_bytes(borders: &CT_PBdr) -> usize {
2882        [
2883            &borders.top,
2884            &borders.bottom,
2885            &borders.left,
2886            &borders.right,
2887            &borders.between,
2888            &borders.bar,
2889        ]
2890        .into_iter()
2891        .filter_map(Option::as_ref)
2892        .map(|edge| option_string_bytes(&edge.color))
2893        .fold(0usize, usize::saturating_add)
2894    }
2895    fn paragraph_properties_bytes(properties: &CT_PPr) -> usize {
2896        option_string_bytes(&properties.style_id)
2897            .saturating_add(option_string_bytes(&properties.line_rule))
2898            .saturating_add(
2899                properties
2900                    .borders
2901                    .as_ref()
2902                    .map_or(0, paragraph_border_bytes),
2903            )
2904            .saturating_add(properties.tabs.as_ref().map_or(0, |tabs| {
2905                tabs.tabs
2906                    .capacity()
2907                    .saturating_mul(std::mem::size_of::<CT_TabStop>())
2908            }))
2909            .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
2910            .saturating_add(properties.rpr.as_ref().map_or(0, run_properties_bytes))
2911            .saturating_add(raw_vectors_bytes(&properties.numbering_revision_xml))
2912            .saturating_add(raw_vectors_bytes(&properties.revision_xml))
2913    }
2914
2915    let run_bytes = paragraph
2916        .runs
2917        .capacity()
2918        .saturating_mul(std::mem::size_of::<CT_R>())
2919        .saturating_add(
2920            paragraph
2921                .runs
2922                .iter()
2923                .map(|run| {
2924                    run.content
2925                        .capacity()
2926                        .saturating_mul(std::mem::size_of::<RunContent>())
2927                        .saturating_add(
2928                            run.content
2929                                .iter()
2930                                .map(|content| match content {
2931                                    RunContent::Text(text) | RunContent::DeletedText(text) => {
2932                                        text.text.capacity()
2933                                    }
2934                                    RunContent::Tab
2935                                    | RunContent::Break(_)
2936                                    | RunContent::Drawing(_)
2937                                    | RunContent::Field(_)
2938                                    | RunContent::FootnoteRef { .. }
2939                                    | RunContent::EndnoteRef { .. }
2940                                    | RunContent::CommentReference { .. } => 0,
2941                                })
2942                                .fold(0usize, usize::saturating_add),
2943                        )
2944                        .saturating_add(run.properties.as_ref().map_or(0, run_properties_bytes))
2945                        .saturating_add(raw_vectors_bytes(&run.extra_xml))
2946                        .saturating_add(
2947                            run.extra_xml_positions
2948                                .capacity()
2949                                .saturating_mul(std::mem::size_of::<usize>()),
2950                        )
2951                })
2952                .fold(0usize, usize::saturating_add),
2953        );
2954    let paragraph_vectors = paragraph
2955        .hyperlinks
2956        .capacity()
2957        .saturating_mul(std::mem::size_of::<rdocx_oxml::text::HyperlinkSpan>())
2958        .saturating_add(
2959            paragraph
2960                .comment_ranges
2961                .capacity()
2962                .saturating_mul(std::mem::size_of::<rdocx_oxml::text::CommentRangeMarker>()),
2963        )
2964        .saturating_add(
2965            paragraph
2966                .extra_xml
2967                .capacity()
2968                .saturating_mul(std::mem::size_of::<(usize, Vec<u8>)>()),
2969        )
2970        .saturating_add(
2971            paragraph
2972                .extra_xml
2973                .iter()
2974                .map(|(_, raw)| raw.capacity())
2975                .fold(0usize, usize::saturating_add),
2976        );
2977    run_bytes.saturating_add(paragraph_vectors).saturating_add(
2978        paragraph
2979            .properties
2980            .as_ref()
2981            .map_or(0, paragraph_properties_bytes),
2982    )
2983}
2984
2985fn table_key_retained_bytes(table: &CT_Tbl) -> usize {
2986    fn option_string_bytes(value: &Option<String>) -> usize {
2987        value.as_ref().map_or(0, String::capacity)
2988    }
2989    fn raw_entries_bytes(values: &[(usize, Vec<u8>)], capacity: usize) -> usize {
2990        capacity
2991            .saturating_mul(std::mem::size_of::<(usize, Vec<u8>)>())
2992            .saturating_add(
2993                values
2994                    .iter()
2995                    .map(|(_, raw)| raw.capacity())
2996                    .fold(0usize, usize::saturating_add),
2997            )
2998    }
2999    fn raw_vectors_bytes(values: &[Vec<u8>]) -> usize {
3000        values
3001            .len()
3002            .saturating_mul(std::mem::size_of::<Vec<u8>>())
3003            .saturating_add(
3004                values
3005                    .iter()
3006                    .map(Vec::capacity)
3007                    .fold(0usize, usize::saturating_add),
3008            )
3009    }
3010    fn shading_bytes(shading: &CT_Shd) -> usize {
3011        shading
3012            .val
3013            .capacity()
3014            .saturating_add(option_string_bytes(&shading.color))
3015            .saturating_add(option_string_bytes(&shading.fill))
3016    }
3017    fn table_border_bytes(borders: &rdocx_oxml::table::CT_TblBorders) -> usize {
3018        [
3019            &borders.top,
3020            &borders.bottom,
3021            &borders.left,
3022            &borders.right,
3023            &borders.inside_h,
3024            &borders.inside_v,
3025        ]
3026        .into_iter()
3027        .filter_map(Option::as_ref)
3028        .map(|edge| option_string_bytes(&edge.color))
3029        .fold(0usize, usize::saturating_add)
3030    }
3031    fn width_bytes(width: &rdocx_oxml::table::CT_TblWidth) -> usize {
3032        width.width_type.capacity()
3033    }
3034    fn table_properties_bytes(properties: &rdocx_oxml::table::CT_TblPr) -> usize {
3035        option_string_bytes(&properties.style_id)
3036            .saturating_add(option_string_bytes(&properties.layout))
3037            .saturating_add(properties.width.as_ref().map_or(0, width_bytes))
3038            .saturating_add(properties.indent.as_ref().map_or(0, width_bytes))
3039            .saturating_add(properties.borders.as_ref().map_or(0, table_border_bytes))
3040            .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3041            .saturating_add(
3042                properties
3043                    .look
3044                    .as_ref()
3045                    .map_or(0, |look| option_string_bytes(&look.val)),
3046            )
3047            .saturating_add(raw_vectors_bytes(&properties.revision_xml))
3048    }
3049    fn row_properties_bytes(properties: &rdocx_oxml::table::CT_TrPr) -> usize {
3050        option_string_bytes(&properties.height_rule)
3051            .saturating_add(option_string_bytes(&properties.cnf_style))
3052            .saturating_add(
3053                properties
3054                    .revision_markers
3055                    .capacity()
3056                    .saturating_mul(std::mem::size_of::<CT_Revision>()),
3057            )
3058            .saturating_add(raw_vectors_bytes(&properties.revision_xml))
3059    }
3060    fn cell_properties_bytes(properties: &rdocx_oxml::table::CT_TcPr) -> usize {
3061        properties
3062            .width
3063            .as_ref()
3064            .map_or(0, width_bytes)
3065            .saturating_add(properties.borders.as_ref().map_or(0, table_border_bytes))
3066            .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3067            .saturating_add(option_string_bytes(&properties.text_direction))
3068            .saturating_add(option_string_bytes(&properties.cnf_style))
3069            .saturating_add(raw_entries_bytes(
3070                &properties.extra_xml,
3071                properties.extra_xml.capacity(),
3072            ))
3073    }
3074
3075    let grid_bytes = table.grid.as_ref().map_or(0, |grid| {
3076        grid.columns
3077            .capacity()
3078            .saturating_mul(std::mem::size_of::<rdocx_oxml::table::CT_TblGridCol>())
3079    });
3080    table
3081        .rows
3082        .capacity()
3083        .saturating_mul(std::mem::size_of::<CT_Row>())
3084        .saturating_add(grid_bytes)
3085        .saturating_add(
3086            table
3087                .rows
3088                .iter()
3089                .map(|row| {
3090                    row.properties
3091                        .as_ref()
3092                        .map_or(0, row_properties_bytes)
3093                        .saturating_add(raw_entries_bytes(&row.extra_xml, row.extra_xml.capacity()))
3094                        .saturating_add(
3095                            row.content_controls
3096                                .capacity()
3097                                .saturating_mul(std::mem::size_of::<(usize, usize, CT_Sdt)>()),
3098                        )
3099                        .saturating_add(
3100                            row.cells
3101                                .capacity()
3102                                .saturating_mul(std::mem::size_of::<CT_Tc>())
3103                                .saturating_add(
3104                                    row.cells
3105                                        .iter()
3106                                        .map(|cell| {
3107                                            cell.properties
3108                                        .as_ref()
3109                                        .map_or(0, cell_properties_bytes)
3110                                        .saturating_add(raw_entries_bytes(
3111                                            &cell.extra_xml,
3112                                            cell.extra_xml.capacity(),
3113                                        ))
3114                                        .saturating_add(cell.content
3115                                        .capacity()
3116                                        .saturating_mul(std::mem::size_of::<CellContent>())
3117                                        .saturating_add(
3118                                            cell.content
3119                                                .iter()
3120                                                .map(|content| match content {
3121                                                    CellContent::Paragraph(paragraph) => {
3122                                                        paragraph_key_retained_bytes(paragraph)
3123                                                    }
3124                                                    CellContent::Table(table) => {
3125                                                        table_key_retained_bytes(table)
3126                                                    }
3127                                                    CellContent::ContentControl(_) => usize::MAX,
3128                                                })
3129                                                .fold(0usize, usize::saturating_add),
3130                                        ))
3131                                        })
3132                                        .fold(0usize, usize::saturating_add),
3133                                ),
3134                        )
3135                })
3136                .fold(0usize, usize::saturating_add),
3137        )
3138        .saturating_add(table.properties.as_ref().map_or(0, table_properties_bytes))
3139        .saturating_add(raw_entries_bytes(
3140            &table.extra_xml,
3141            table.extra_xml.capacity(),
3142        ))
3143        .saturating_add(
3144            table
3145                .content_controls
3146                .capacity()
3147                .saturating_mul(std::mem::size_of::<(usize, usize, CT_Sdt)>()),
3148        )
3149}
3150
3151fn table_block_retained_bytes(block: &table::TableBlock) -> usize {
3152    fn border_bytes(borders: &rdocx_oxml::table::CT_TblBorders) -> usize {
3153        [
3154            &borders.top,
3155            &borders.bottom,
3156            &borders.left,
3157            &borders.right,
3158            &borders.inside_h,
3159            &borders.inside_v,
3160        ]
3161        .into_iter()
3162        .map(|edge| {
3163            edge.as_ref()
3164                .and_then(|edge| edge.color.as_ref())
3165                .map_or(0, String::capacity)
3166        })
3167        .fold(0usize, usize::saturating_add)
3168    }
3169
3170    let rows = block
3171        .rows
3172        .iter()
3173        .map(|row| {
3174            row.cells
3175                .capacity()
3176                .saturating_mul(std::mem::size_of::<table::TableCell>())
3177                .saturating_add(
3178                    row.cells
3179                        .iter()
3180                        .map(|cell| {
3181                            cell.blocks
3182                                .capacity()
3183                                .saturating_mul(std::mem::size_of::<table::CellBlock>())
3184                                .saturating_add(
3185                                    cell.blocks
3186                                        .iter()
3187                                        .map(|block| match block {
3188                                            table::CellBlock::Paragraph(paragraph) => {
3189                                                paragraph_cache_entry_bytes(
3190                                                    &CT_P::new(),
3191                                                    paragraph,
3192                                                    &[],
3193                                                    0,
3194                                                )
3195                                            }
3196                                            table::CellBlock::Table(table) => {
3197                                                table_block_retained_bytes(table)
3198                                            }
3199                                        })
3200                                        .fold(0usize, usize::saturating_add),
3201                                )
3202                                .saturating_add(cell.borders.as_ref().map_or(0, border_bytes))
3203                        })
3204                        .fold(0usize, usize::saturating_add),
3205                )
3206        })
3207        .fold(0usize, usize::saturating_add);
3208    std::mem::size_of::<table::TableBlock>()
3209        .saturating_add(
3210            block
3211                .col_widths
3212                .capacity()
3213                .saturating_mul(std::mem::size_of::<f64>()),
3214        )
3215        .saturating_add(
3216            block
3217                .rows
3218                .capacity()
3219                .saturating_mul(std::mem::size_of::<table::TableRow>()),
3220        )
3221        .saturating_add(
3222            block
3223                .header_row_indices
3224                .capacity()
3225                .saturating_mul(std::mem::size_of::<usize>()),
3226        )
3227        .saturating_add(rows)
3228        .saturating_add(block.borders.as_ref().map_or(0, border_bytes))
3229}
3230
3231fn canonicalize_layout_fonts(
3232    pages: &mut [Arc<PageFrame>],
3233    font_manager: &FontManager,
3234    current_fonts: &[FontId],
3235) -> Result<Vec<oxml_layout::FontData>> {
3236    fn collect(
3237        elements: &[PositionedElement],
3238        remap: &mut HashMap<FontId, FontId>,
3239        order: &mut Vec<FontId>,
3240    ) {
3241        for element in elements {
3242            match element {
3243                PositionedElement::Text(run) => {
3244                    if let std::collections::hash_map::Entry::Vacant(entry) =
3245                        remap.entry(run.font_id)
3246                    {
3247                        let local = FontId(order.len() as u32);
3248                        entry.insert(local);
3249                        order.push(run.font_id);
3250                    }
3251                }
3252                PositionedElement::MultilingualText(run) => {
3253                    if let std::collections::hash_map::Entry::Vacant(entry) =
3254                        remap.entry(run.font_id)
3255                    {
3256                        let local = FontId(order.len() as u32);
3257                        entry.insert(local);
3258                        order.push(run.font_id);
3259                    }
3260                }
3261                PositionedElement::Group(group) => collect(&group.children, remap, order),
3262                PositionedElement::MarkedContent { children, .. } => {
3263                    collect(children, remap, order)
3264                }
3265                _ => {}
3266            }
3267        }
3268    }
3269
3270    fn rewrite(elements: &mut [PositionedElement], remap: &HashMap<FontId, FontId>) {
3271        for element in elements {
3272            match element {
3273                PositionedElement::Text(run) => {
3274                    run.font_id = remap[&run.font_id];
3275                }
3276                PositionedElement::MultilingualText(run) => {
3277                    run.font_id = remap[&run.font_id];
3278                }
3279                PositionedElement::Group(group) => rewrite(&mut group.children, remap),
3280                PositionedElement::MarkedContent { children, .. } => rewrite(children, remap),
3281                _ => {}
3282            }
3283        }
3284    }
3285
3286    let mut remap = HashMap::new();
3287    let mut order = Vec::with_capacity(current_fonts.len());
3288    for &font_id in current_fonts {
3289        if let std::collections::hash_map::Entry::Vacant(entry) = remap.entry(font_id) {
3290            let local = FontId(order.len() as u32);
3291            entry.insert(local);
3292            order.push(font_id);
3293        }
3294    }
3295    for page in pages.iter() {
3296        collect(&page.elements, &mut remap, &mut order);
3297    }
3298    let mut fonts = Vec::with_capacity(order.len());
3299    for persistent_id in order {
3300        let mut font = font_manager.font_data(persistent_id)?;
3301        font.id = remap[&persistent_id];
3302        fonts.push(font);
3303    }
3304    for page in pages {
3305        rewrite(&mut Arc::make_mut(page).elements, &remap);
3306    }
3307    Ok(fonts)
3308}
3309
3310fn restart_block_is_safe<B: LayoutBlockLike>(block: &B) -> bool {
3311    if let Some(paragraph) = block.paragraph() {
3312        restart_record_block_is_safe(block)
3313            && paragraph.lines.iter().all(|line| {
3314                line.items.iter().all(|item| match item {
3315                    LineItem::Text(text) | LineItem::Marker(text) => text.field_kind.is_none(),
3316                    LineItem::MultilingualText(text) => text.base().field_kind.is_none(),
3317                    LineItem::Tab {
3318                        leader: Some(text), ..
3319                    } => text.field_kind.is_none(),
3320                    _ => true,
3321                })
3322            })
3323    } else {
3324        restart_record_block_is_safe(block)
3325    }
3326}
3327
3328fn restart_record_block_is_safe<B: LayoutBlockLike>(block: &B) -> bool {
3329    if let Some(paragraph) = block.paragraph() {
3330        paragraph.anchored.is_empty()
3331            && paragraph.lines.iter().all(|line| {
3332                line.items.iter().all(|item| match item {
3333                    LineItem::Text(_) | LineItem::Marker(_) => true,
3334                    LineItem::MultilingualText(_) => false,
3335                    LineItem::Tab {
3336                        leader: Some(_), ..
3337                    } => true,
3338                    LineItem::Tab { leader: None, .. } => true,
3339                    LineItem::Image { .. } | LineItem::Group { .. } => false,
3340                    _ => false,
3341                })
3342            })
3343    } else {
3344        block.table().is_some()
3345    }
3346}
3347
3348fn page_has_substitution_state(page: &PageFrame) -> bool {
3349    let mut found = false;
3350    oxml_layout::walk(&page.elements, &mut |element, _| {
3351        found |= match element {
3352            PositionedElement::Text(run) => run.field_kind.is_some(),
3353            PositionedElement::MultilingualText(run) => run.field_kind.is_some(),
3354            _ => false,
3355        };
3356    });
3357    found
3358}
3359
3360fn restart_cache_entries(cache: &RestartCache) -> usize {
3361    cache.raw_pages.len().max(cache.checkpoints.len())
3362}
3363
3364fn restart_cache_bytes(cache: &RestartCache) -> usize {
3365    let vector_bytes = cache
3366        .body
3367        .capacity()
3368        .saturating_mul(std::mem::size_of::<RestartBodyEntry>())
3369        .saturating_add(
3370            cache
3371                .raw_pages
3372                .capacity()
3373                .saturating_add(cache.pages.capacity())
3374                .saturating_mul(std::mem::size_of::<Arc<PageFrame>>()),
3375        )
3376        .saturating_add(
3377            cache
3378                .substitution_inputs
3379                .capacity()
3380                .saturating_mul(std::mem::size_of::<Option<FieldSubstitutionInputs>>()),
3381        )
3382        .saturating_add(
3383            cache
3384                .outlines
3385                .capacity()
3386                .saturating_mul(std::mem::size_of::<oxml_layout::OutlineEntry>()),
3387        )
3388        .saturating_add(
3389            cache
3390                .checkpoints
3391                .capacity()
3392                .saturating_mul(std::mem::size_of::<paginator::PaginationCheckpoint>()),
3393        )
3394        .saturating_add(
3395            cache
3396                .font_trace
3397                .capacity()
3398                .saturating_mul(std::mem::size_of::<FontId>()),
3399        );
3400    let body_bytes = cache
3401        .body
3402        .iter()
3403        .map(RestartBodyEntry::bytes)
3404        .fold(0usize, usize::saturating_add);
3405    debug_assert_eq!(cache.raw_pages.len(), cache.pages.len());
3406    let page_bytes = cache
3407        .raw_pages
3408        .iter()
3409        .zip(&cache.pages)
3410        .map(|(pristine, substituted)| {
3411            let substituted_bytes = if Arc::ptr_eq(pristine, substituted) {
3412                0
3413            } else {
3414                page_frame_retained_bytes(substituted)
3415            };
3416            page_frame_retained_bytes(pristine)
3417                .saturating_add(2 * std::mem::size_of::<usize>())
3418                .saturating_add(substituted_bytes)
3419                .saturating_add(if Arc::ptr_eq(pristine, substituted) {
3420                    0
3421                } else {
3422                    2 * std::mem::size_of::<usize>()
3423                })
3424        })
3425        .fold(0usize, usize::saturating_add);
3426    let outline_bytes = cache
3427        .outlines
3428        .iter()
3429        .map(|outline| outline.title.capacity())
3430        .fold(0usize, usize::saturating_add);
3431    let substitution_bytes = cache
3432        .substitution_inputs
3433        .iter()
3434        .filter_map(Option::as_ref)
3435        .map(|inputs| {
3436            inputs
3437                .bookmark_pages
3438                .capacity()
3439                .saturating_mul(std::mem::size_of::<(usize, usize)>())
3440                .saturating_add(
3441                    inputs
3442                        .font_identity
3443                        .capacity()
3444                        .saturating_mul(std::mem::size_of::<FontId>()),
3445                )
3446        })
3447        .fold(0usize, usize::saturating_add);
3448    std::mem::size_of::<RestartCache>()
3449        .saturating_add(vector_bytes)
3450        .saturating_add(body_bytes)
3451        .saturating_add(page_bytes)
3452        .saturating_add(substitution_bytes)
3453        .saturating_add(outline_bytes)
3454}
3455
3456fn page_frame_retained_bytes(page: &PageFrame) -> usize {
3457    fn glyph_bytes(run: &GlyphRun) -> usize {
3458        run.text
3459            .capacity()
3460            .saturating_add(
3461                run.glyph_ids
3462                    .capacity()
3463                    .saturating_mul(std::mem::size_of::<u16>()),
3464            )
3465            .saturating_add(
3466                run.advances
3467                    .capacity()
3468                    .saturating_mul(std::mem::size_of::<f64>()),
3469            )
3470    }
3471
3472    fn multilingual_glyph_bytes(run: &oxml_layout::MultilingualGlyphRun) -> usize {
3473        run.logical_text
3474            .capacity()
3475            .saturating_add(run.language.as_ref().map_or(0, String::capacity))
3476            .saturating_add(
3477                run.glyph_ids
3478                    .capacity()
3479                    .saturating_mul(std::mem::size_of::<u16>()),
3480            )
3481            .saturating_add(
3482                [
3483                    run.x_advances.capacity(),
3484                    run.y_advances.capacity(),
3485                    run.x_offsets.capacity(),
3486                    run.y_offsets.capacity(),
3487                ]
3488                .into_iter()
3489                .sum::<usize>()
3490                .saturating_mul(std::mem::size_of::<f64>()),
3491            )
3492            .saturating_add(
3493                run.clusters
3494                    .capacity()
3495                    .saturating_mul(std::mem::size_of::<oxml_layout::GlyphCluster>()),
3496            )
3497    }
3498
3499    fn element_bytes(element: &PositionedElement) -> usize {
3500        match element {
3501            PositionedElement::Text(run) => glyph_bytes(run),
3502            PositionedElement::MultilingualText(run) => multilingual_glyph_bytes(run),
3503            PositionedElement::Image {
3504                data, content_type, ..
3505            } => data.capacity().saturating_add(content_type.capacity()),
3506            PositionedElement::LinkAnnotation { url, .. } => url.capacity(),
3507            PositionedElement::Group(group) => group
3508                .children
3509                .capacity()
3510                .saturating_mul(std::mem::size_of::<PositionedElement>())
3511                .saturating_add(
3512                    group
3513                        .children
3514                        .iter()
3515                        .map(element_bytes)
3516                        .fold(0usize, usize::saturating_add),
3517                )
3518                .saturating_add(format!("{:?}", group.effects).len()),
3519            PositionedElement::MarkedContent { children, .. } => children
3520                .capacity()
3521                .saturating_mul(std::mem::size_of::<PositionedElement>())
3522                .saturating_add(
3523                    children
3524                        .iter()
3525                        .map(element_bytes)
3526                        .fold(0usize, usize::saturating_add),
3527                ),
3528            PositionedElement::Path(path) => format!("{path:?}").len(),
3529            _ => 0,
3530        }
3531    }
3532
3533    std::mem::size_of::<PageFrame>()
3534        .saturating_add(
3535            page.elements
3536                .capacity()
3537                .saturating_mul(std::mem::size_of::<PositionedElement>()),
3538        )
3539        .saturating_add(
3540            page.elements
3541                .iter()
3542                .map(element_bytes)
3543                .fold(0usize, usize::saturating_add),
3544        )
3545        .saturating_add(format!("{:?}", page.background).len())
3546}
3547
3548fn rebind_text_source(text: &mut TextSegment, source_node: Option<SourceNodeId>) {
3549    match (text.source.as_mut(), source_node) {
3550        (Some(source), Some(node)) => source.node = node,
3551        (Some(_), None) => text.source = None,
3552        (None, _) => {}
3553    }
3554}
3555
3556fn rebind_multilingual_source(
3557    text: &mut oxml_layout::MultilingualTextSegment,
3558    source_node: Option<SourceNodeId>,
3559) -> Result<()> {
3560    let mut base = text.base().clone();
3561    rebind_text_source(&mut base, source_node);
3562    *text = oxml_layout::MultilingualTextSegment::new(
3563        base,
3564        text.logical_index(),
3565        text.language().map(str::to_owned),
3566        text.script(),
3567        text.direction(),
3568        text.bidi_level(),
3569        text.x_advances().to_vec(),
3570        text.y_advances().to_vec(),
3571        text.x_offsets().to_vec(),
3572        text.y_offsets().to_vec(),
3573        text.clusters().to_vec(),
3574        text.break_after(),
3575    )?;
3576    Ok(())
3577}
3578
3579fn rebind_paragraph_source(
3580    block: &mut ParagraphBlock,
3581    source_node: Option<SourceNodeId>,
3582) -> Result<()> {
3583    for line in &mut block.lines {
3584        for item in &mut line.items {
3585            match item {
3586                LineItem::Text(text) | LineItem::Marker(text) => {
3587                    rebind_text_source(text, source_node)
3588                }
3589                LineItem::MultilingualText(text) => rebind_multilingual_source(text, source_node)?,
3590                LineItem::Tab {
3591                    leader: Some(leader),
3592                    ..
3593                } => rebind_text_source(leader, source_node),
3594                _ => {}
3595            }
3596        }
3597    }
3598    if let Some(reflow) = block.reflow.as_mut() {
3599        for item in &mut reflow.items {
3600            match item {
3601                InlineItem::Text(text) | InlineItem::Marker(text) => {
3602                    rebind_text_source(text, source_node)
3603                }
3604                InlineItem::MultilingualText(text) => {
3605                    rebind_multilingual_source(text, source_node)?
3606                }
3607                InlineItem::HyphenatedText { segment, .. } => {
3608                    rebind_text_source(segment, source_node)
3609                }
3610                _ => {}
3611            }
3612        }
3613    }
3614    Ok(())
3615}
3616
3617fn rebind_header_footer_sources(
3618    story_kind: HeaderFooterStoryKind,
3619    relationship_id: &str,
3620    part: &rdocx_oxml::header_footer::CT_HdrFtr,
3621    blocks: &mut [ParagraphBlock],
3622    sources: Option<&SourceRegistry>,
3623) -> Result<()> {
3624    let story = match story_kind {
3625        HeaderFooterStoryKind::Header => WordStory::Header {
3626            relationship_id: relationship_id.to_owned(),
3627        },
3628        HeaderFooterStoryKind::Footer => WordStory::Footer {
3629            relationship_id: relationship_id.to_owned(),
3630        },
3631    };
3632    for (paragraph_index, block) in blocks.iter_mut().enumerate() {
3633        debug_assert!(paragraph_index < part.paragraphs.len());
3634        let source = sources.and_then(|sources| sources.id(&story, &[paragraph_index]));
3635        rebind_paragraph_source(block, source)?;
3636    }
3637    Ok(())
3638}
3639
3640fn header_footer_cache_entry_bytes(
3641    key: &HeaderFooterCacheKey,
3642    content: &HeaderFooterVariantContent,
3643    diagnostics: &Vec<Diagnostic>,
3644    font_trace: &Vec<FontId>,
3645) -> usize {
3646    let block_bytes = key
3647        .part
3648        .paragraphs
3649        .iter()
3650        .zip(&content.blocks)
3651        .map(|(paragraph, block)| paragraph_cache_entry_bytes(paragraph, block, &[], 0))
3652        .fold(0usize, usize::saturating_add);
3653    let direction_bytes = content
3654        .directions
3655        .capacity()
3656        .saturating_mul(std::mem::size_of::<TextDirection>());
3657    let diagnostic_bytes = diagnostics
3658        .capacity()
3659        .saturating_mul(std::mem::size_of::<Diagnostic>())
3660        .saturating_add(
3661            diagnostics
3662                .iter()
3663                .map(|diagnostic| diagnostic.message.capacity())
3664                .fold(0usize, usize::saturating_add),
3665        );
3666    let watermark_bytes = content.watermark.as_ref().map_or(0, |watermark| {
3667        page_frame_retained_bytes(&PageFrame::new(
3668            1,
3669            0.0,
3670            0.0,
3671            vec![PositionedElement::Group(watermark.clone())],
3672        ))
3673    });
3674    let section_capacity = key
3675        .section
3676        .header_refs
3677        .capacity()
3678        .saturating_mul(std::mem::size_of::<rdocx_oxml::header_footer::HdrFtrRef>())
3679        .saturating_add(
3680            key.section
3681                .footer_refs
3682                .capacity()
3683                .saturating_mul(std::mem::size_of::<rdocx_oxml::header_footer::HdrFtrRef>()),
3684        )
3685        .saturating_add(key.section.columns.as_ref().map_or(0, |columns| {
3686            columns
3687                .columns
3688                .capacity()
3689                .saturating_mul(std::mem::size_of::<rdocx_oxml::document::CT_Column>())
3690        }))
3691        .saturating_add(
3692            key.section
3693                .extra_xml
3694                .capacity()
3695                .saturating_mul(std::mem::size_of::<Vec<u8>>()),
3696        )
3697        .saturating_add(
3698            key.section
3699                .header_refs
3700                .iter()
3701                .chain(&key.section.footer_refs)
3702                .map(|reference| reference.rel_id.capacity())
3703                .fold(0usize, usize::saturating_add),
3704        )
3705        .saturating_add(
3706            key.section
3707                .extra_xml
3708                .iter()
3709                .map(Vec::capacity)
3710                .fold(0usize, usize::saturating_add),
3711        );
3712    let paragraph_raw_capacity = key
3713        .part
3714        .paragraphs
3715        .iter()
3716        .map(|paragraph| {
3717            paragraph
3718                .extra_xml
3719                .capacity()
3720                .saturating_mul(std::mem::size_of::<(usize, Vec<u8>)>())
3721                .saturating_add(
3722                    paragraph
3723                        .extra_xml
3724                        .iter()
3725                        .map(|(_, raw)| raw.capacity())
3726                        .fold(0usize, usize::saturating_add),
3727                )
3728                .saturating_add(
3729                    paragraph
3730                        .runs
3731                        .iter()
3732                        .map(|run| {
3733                            run.extra_xml
3734                                .capacity()
3735                                .saturating_mul(std::mem::size_of::<Vec<u8>>())
3736                                .saturating_add(
3737                                    run.extra_xml
3738                                        .iter()
3739                                        .map(Vec::capacity)
3740                                        .fold(0usize, usize::saturating_add),
3741                                )
3742                                .saturating_add(
3743                                    run.extra_xml_positions
3744                                        .capacity()
3745                                        .saturating_mul(std::mem::size_of::<usize>()),
3746                                )
3747                        })
3748                        .fold(0usize, usize::saturating_add),
3749                )
3750        })
3751        .fold(0usize, usize::saturating_add);
3752    let watermark_capacity = key
3753        .part
3754        .watermarks()
3755        .iter()
3756        .map(|watermark| {
3757            std::mem::size_of::<VmlWatermark>().saturating_add(match watermark {
3758                VmlWatermark::Text {
3759                    text,
3760                    color,
3761                    font_family,
3762                    ..
3763                } => text
3764                    .capacity()
3765                    .saturating_add(color.capacity())
3766                    .saturating_add(font_family.as_ref().map_or(0, String::capacity)),
3767                VmlWatermark::Image {
3768                    relationship_id, ..
3769                } => relationship_id.capacity(),
3770            })
3771        })
3772        .fold(0usize, usize::saturating_add);
3773    let part_capacity = key
3774        .part
3775        .paragraphs
3776        .capacity()
3777        .saturating_mul(std::mem::size_of::<CT_P>())
3778        .saturating_add(
3779            content
3780                .blocks
3781                .capacity()
3782                .saturating_mul(std::mem::size_of::<ParagraphBlock>()),
3783        )
3784        .saturating_add(
3785            key.part
3786                .extra_namespaces
3787                .capacity()
3788                .saturating_mul(std::mem::size_of::<(String, String)>()),
3789        )
3790        .saturating_add(
3791            key.part
3792                .extra_xml
3793                .capacity()
3794                .saturating_mul(std::mem::size_of::<Vec<u8>>()),
3795        )
3796        .saturating_add(
3797            key.part
3798                .extra_namespaces
3799                .iter()
3800                .map(|(prefix, namespace)| prefix.capacity().saturating_add(namespace.capacity()))
3801                .fold(0usize, usize::saturating_add),
3802        )
3803        .saturating_add(
3804            key.part
3805                .extra_xml
3806                .iter()
3807                .map(Vec::capacity)
3808                .fold(0usize, usize::saturating_add),
3809        );
3810    std::mem::size_of::<HeaderFooterCacheEntry>()
3811        .saturating_add(key.relationship_id.capacity())
3812        .saturating_add(key.resolved_part_bytes.capacity())
3813        .saturating_add(format!("{:?}", key.section).len())
3814        .saturating_add(format!("{:?}", key.part).len())
3815        .saturating_add(section_capacity)
3816        .saturating_add(part_capacity)
3817        .saturating_add(paragraph_raw_capacity)
3818        .saturating_add(watermark_capacity)
3819        .saturating_add(block_bytes)
3820        .saturating_add(direction_bytes)
3821        .saturating_add(watermark_bytes)
3822        .saturating_add(diagnostic_bytes)
3823        .saturating_add(
3824            font_trace
3825                .capacity()
3826                .saturating_mul(std::mem::size_of::<FontId>()),
3827        )
3828}
3829
3830fn paragraph_cache_entry_bytes(
3831    paragraph: &CT_P,
3832    block: &ParagraphBlock,
3833    diagnostics: &[Diagnostic],
3834    font_trace_len: usize,
3835) -> usize {
3836    fn option_string_bytes(value: &Option<String>) -> usize {
3837        value.as_ref().map_or(0, String::capacity)
3838    }
3839    fn shading_bytes(shading: &CT_Shd) -> usize {
3840        shading
3841            .val
3842            .capacity()
3843            .saturating_add(option_string_bytes(&shading.color))
3844            .saturating_add(option_string_bytes(&shading.fill))
3845    }
3846    fn run_properties_bytes(properties: &CT_RPr) -> usize {
3847        [
3848            &properties.style_id,
3849            &properties.font_ascii,
3850            &properties.font_hansi,
3851            &properties.font_east_asia,
3852            &properties.font_cs,
3853            &properties.font_ascii_theme,
3854            &properties.font_hansi_theme,
3855            &properties.color,
3856            &properties.color_theme,
3857            &properties.vert_align,
3858        ]
3859        .into_iter()
3860        .map(option_string_bytes)
3861        .fold(0usize, usize::saturating_add)
3862        .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3863    }
3864    fn border_bytes(borders: &CT_PBdr) -> usize {
3865        [
3866            &borders.top,
3867            &borders.bottom,
3868            &borders.left,
3869            &borders.right,
3870            &borders.between,
3871            &borders.bar,
3872        ]
3873        .into_iter()
3874        .map(|edge| {
3875            edge.as_ref()
3876                .and_then(|edge| edge.color.as_ref())
3877                .map_or(0, String::capacity)
3878        })
3879        .fold(0usize, usize::saturating_add)
3880    }
3881    fn paragraph_properties_bytes(properties: &CT_PPr) -> usize {
3882        option_string_bytes(&properties.style_id)
3883            .saturating_add(option_string_bytes(&properties.line_rule))
3884            .saturating_add(properties.borders.as_ref().map_or(0, border_bytes))
3885            .saturating_add(properties.tabs.as_ref().map_or(0, |tabs| {
3886                tabs.tabs
3887                    .capacity()
3888                    .saturating_mul(std::mem::size_of::<CT_TabStop>())
3889            }))
3890            .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3891            .saturating_add(properties.rpr.as_ref().map_or(0, run_properties_bytes))
3892    }
3893    fn paragraph_key_bytes(paragraph: &CT_P) -> usize {
3894        paragraph
3895            .runs
3896            .capacity()
3897            .saturating_mul(std::mem::size_of::<CT_R>())
3898            .saturating_add(
3899                paragraph
3900                    .runs
3901                    .iter()
3902                    .map(|run| {
3903                        run.content
3904                            .capacity()
3905                            .saturating_mul(std::mem::size_of::<RunContent>())
3906                            .saturating_add(
3907                                run.content
3908                                    .iter()
3909                                    .map(|content| match content {
3910                                        RunContent::Text(text) => text.text.capacity(),
3911                                        _ => 0,
3912                                    })
3913                                    .fold(0usize, usize::saturating_add),
3914                            )
3915                            .saturating_add(run.properties.as_ref().map_or(0, run_properties_bytes))
3916                    })
3917                    .fold(0usize, usize::saturating_add),
3918            )
3919            .saturating_add(
3920                paragraph
3921                    .properties
3922                    .as_ref()
3923                    .map_or(0, paragraph_properties_bytes),
3924            )
3925    }
3926    fn text_bytes(text: &TextSegment) -> usize {
3927        text.text.capacity()
3928            + text.glyph_ids.capacity() * std::mem::size_of::<u16>()
3929            + text.advances.capacity() * std::mem::size_of::<f64>()
3930            + text.hyperlink_url.as_ref().map_or(0, String::capacity)
3931    }
3932    fn inline_bytes(item: &InlineItem) -> usize {
3933        match item {
3934            InlineItem::Text(text) | InlineItem::Marker(text) => text_bytes(text),
3935            InlineItem::MultilingualText(_) => usize::MAX,
3936            InlineItem::Group { .. } => usize::MAX,
3937            _ => 0,
3938        }
3939    }
3940    fn line_item_bytes(item: &LineItem) -> usize {
3941        match item {
3942            LineItem::Text(text) | LineItem::Marker(text) => text_bytes(text),
3943            LineItem::MultilingualText(_) => usize::MAX,
3944            LineItem::Tab { leader, .. } => leader.as_ref().map_or(0, text_bytes),
3945            LineItem::Group { .. } => usize::MAX,
3946            _ => 0,
3947        }
3948    }
3949
3950    let paragraph_bytes = paragraph_key_bytes(paragraph);
3951    let line_bytes = block
3952        .lines
3953        .capacity()
3954        .saturating_mul(std::mem::size_of::<oxml_layout::LayoutLine>())
3955        .saturating_add(
3956            block
3957                .lines
3958                .iter()
3959                .map(|line| {
3960                    line.items
3961                        .capacity()
3962                        .saturating_mul(std::mem::size_of::<LineItem>())
3963                        .saturating_add(
3964                            line.items
3965                                .iter()
3966                                .map(line_item_bytes)
3967                                .fold(0usize, usize::saturating_add),
3968                        )
3969                })
3970                .fold(0usize, usize::saturating_add),
3971        );
3972    let reflow_bytes = block.reflow.as_ref().map_or(0, |reflow| {
3973        std::mem::size_of_val(reflow.as_ref())
3974            .saturating_add(
3975                reflow
3976                    .items
3977                    .capacity()
3978                    .saturating_mul(std::mem::size_of::<InlineItem>()),
3979            )
3980            .saturating_add(
3981                reflow
3982                    .items
3983                    .iter()
3984                    .map(inline_bytes)
3985                    .fold(0usize, usize::saturating_add),
3986            )
3987            .saturating_add(
3988                reflow
3989                    .params
3990                    .tab_stops
3991                    .capacity()
3992                    .saturating_mul(std::mem::size_of::<oxml_layout::TabStop>()),
3993            )
3994            .saturating_add(
3995                reflow
3996                    .params
3997                    .line_prefix_widths
3998                    .capacity()
3999                    .saturating_mul(std::mem::size_of::<f64>()),
4000            )
4001            .saturating_add(
4002                reflow
4003                    .params
4004                    .line_suffix_widths
4005                    .capacity()
4006                    .saturating_mul(std::mem::size_of::<f64>()),
4007            )
4008    });
4009    let diagnostic_bytes = diagnostics
4010        .len()
4011        .saturating_mul(std::mem::size_of::<Diagnostic>())
4012        .saturating_add(
4013            diagnostics
4014                .iter()
4015                .map(|diagnostic| diagnostic.message.capacity())
4016                .fold(0usize, usize::saturating_add),
4017        );
4018    std::mem::size_of::<ParagraphCacheEntry>()
4019        .saturating_add(arc_allocation_bytes::<ParagraphBlock>())
4020        .saturating_add(paragraph_bytes)
4021        .saturating_add(line_bytes)
4022        .saturating_add(reflow_bytes)
4023        .saturating_add(if block.anchored.is_empty() {
4024            0
4025        } else {
4026            usize::MAX
4027        })
4028        .saturating_add(block.heading_text.as_ref().map_or(0, String::capacity))
4029        .saturating_add(block.borders.as_ref().map_or(0, border_bytes))
4030        .saturating_add(font_trace_len * std::mem::size_of::<FontId>())
4031        .saturating_add(diagnostic_bytes)
4032}
4033
4034struct StableFingerprint(u64);
4035
4036impl StableFingerprint {
4037    fn new() -> Self {
4038        Self(0xcbf2_9ce4_8422_2325)
4039    }
4040
4041    fn write_bytes(&mut self, value: &[u8]) {
4042        for byte in value {
4043            self.0 ^= u64::from(*byte);
4044            self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
4045        }
4046    }
4047
4048    fn write_usize(&mut self, value: usize) {
4049        self.write_bytes(&value.to_le_bytes());
4050    }
4051
4052    fn write_tag(&mut self, value: u8) {
4053        self.write_bytes(&[value]);
4054    }
4055}
4056
4057fn paragraph_fingerprint(paragraph: &CT_P) -> u64 {
4058    let mut fingerprint = StableFingerprint::new();
4059    fingerprint.write_usize(paragraph.runs.len());
4060    fingerprint.write_tag(u8::from(paragraph.properties.is_some()));
4061    for run in &paragraph.runs {
4062        fingerprint.write_tag(u8::from(run.properties.is_some()));
4063        fingerprint.write_usize(run.content.len());
4064        for content in &run.content {
4065            match content {
4066                RunContent::Text(text) => {
4067                    fingerprint.write_tag(0);
4068                    fingerprint.write_bytes(text.text.as_bytes());
4069                    fingerprint.write_tag(u8::from(text.preserve_space));
4070                }
4071                RunContent::DeletedText(text) => {
4072                    fingerprint.write_tag(1);
4073                    fingerprint.write_bytes(text.text.as_bytes());
4074                    fingerprint.write_tag(u8::from(text.preserve_space));
4075                }
4076                RunContent::Tab => fingerprint.write_tag(2),
4077                RunContent::Break(kind) => {
4078                    fingerprint.write_tag(3);
4079                    fingerprint.write_tag(match kind {
4080                        BreakType::Line => 0,
4081                        BreakType::Page => 1,
4082                        BreakType::Column => 2,
4083                    });
4084                }
4085                RunContent::Drawing(_)
4086                | RunContent::Field(_)
4087                | RunContent::FootnoteRef { .. }
4088                | RunContent::EndnoteRef { .. }
4089                | RunContent::CommentReference { .. } => fingerprint.write_tag(4),
4090            }
4091        }
4092    }
4093    fingerprint.0
4094}
4095
4096fn table_fingerprint(table: &CT_Tbl) -> u64 {
4097    fn write_table(table: &CT_Tbl, fingerprint: &mut StableFingerprint) {
4098        fingerprint.write_tag(u8::from(table.properties.is_some()));
4099        fingerprint.write_usize(table.grid.as_ref().map_or(0, |grid| grid.columns.len()));
4100        fingerprint.write_usize(table.rows.len());
4101        for row in &table.rows {
4102            fingerprint.write_tag(u8::from(row.properties.is_some()));
4103            fingerprint.write_usize(row.cells.len());
4104            for cell in &row.cells {
4105                fingerprint.write_tag(u8::from(cell.properties.is_some()));
4106                fingerprint.write_usize(cell.content.len());
4107                for content in &cell.content {
4108                    match content {
4109                        CellContent::Paragraph(paragraph) => {
4110                            fingerprint.write_tag(0);
4111                            fingerprint
4112                                .write_bytes(&paragraph_fingerprint(paragraph).to_le_bytes());
4113                        }
4114                        CellContent::Table(table) => {
4115                            fingerprint.write_tag(1);
4116                            write_table(table, fingerprint);
4117                        }
4118                        CellContent::ContentControl(_) => fingerprint.write_tag(2),
4119                    }
4120                }
4121            }
4122        }
4123    }
4124
4125    let mut fingerprint = StableFingerprint::new();
4126    write_table(table, &mut fingerprint);
4127    fingerprint.0
4128}
4129
4130/// Apply page background color from `w:background` element to all pages.
4131fn apply_page_background(pages: &mut [PageFrame], input: &LayoutInput) {
4132    let bg_xml = match &input.document.background_xml {
4133        Some(xml) => xml,
4134        None => return,
4135    };
4136
4137    // Parse w:color attribute from background XML
4138    let xml_str = std::str::from_utf8(bg_xml).unwrap_or("");
4139    let color = extract_background_color(xml_str);
4140    let color = match color {
4141        Some(c) => c,
4142        None => return,
4143    };
4144
4145    // Insert a full-page FilledRect at position 0 on every page (renders underneath everything)
4146    for page in pages.iter_mut() {
4147        page.elements.insert(
4148            0,
4149            PositionedElement::FilledRect {
4150                rect: Rect {
4151                    x: 0.0,
4152                    y: 0.0,
4153                    width: page.width,
4154                    height: page.height,
4155                },
4156                color,
4157            },
4158        );
4159    }
4160}
4161
4162/// Extract the background color hex from w:background XML.
4163fn extract_background_color(xml: &str) -> Option<Color> {
4164    // Look for w:color="RRGGBB" or color="RRGGBB"
4165    for attr in ["w:color=\"", "color=\""] {
4166        if let Some(start) = xml.find(attr) {
4167            let val_start = start + attr.len();
4168            if let Some(end) = xml[val_start..].find('"') {
4169                let hex = &xml[val_start..val_start + end];
4170                if hex.len() == 6 && hex != "auto" {
4171                    return Some(Color::from_hex(hex));
4172                }
4173            }
4174        }
4175    }
4176    None
4177}
4178
4179/// Replace field placeholder GlyphRuns with actual values.
4180fn substitute_fields(
4181    elements: &mut Vec<PositionedElement>,
4182    page_number: usize,
4183    total_pages: usize,
4184    bookmark_pages: &HashMap<usize, usize>,
4185    fm: &mut FontManager,
4186) {
4187    for element in elements.iter_mut() {
4188        match element {
4189            PositionedElement::Text(run) => {
4190                let Some(fk) = run.field_kind else {
4191                    continue;
4192                };
4193                let value = match fk {
4194                    FieldKind::Page => page_number.to_string(),
4195                    FieldKind::NumPages => total_pages.to_string(),
4196                    FieldKind::TargetPage(target) => bookmark_pages
4197                        .get(&target)
4198                        .map(usize::to_string)
4199                        .unwrap_or_else(|| run.text.clone()),
4200                    FieldKind::Target(_) => continue,
4201                };
4202                if let Ok(shaped) = fm.shape_text(run.font_id, &value, run.font_size) {
4203                    run.text = value;
4204                    run.glyph_ids = shaped.glyph_ids;
4205                    run.advances = shaped.advances;
4206                }
4207            }
4208            PositionedElement::Group(group) => substitute_fields(
4209                &mut group.children,
4210                page_number,
4211                total_pages,
4212                bookmark_pages,
4213                fm,
4214            ),
4215            PositionedElement::MarkedContent { children, .. } => {
4216                substitute_fields(children, page_number, total_pages, bookmark_pages, fm)
4217            }
4218            _ => {}
4219        }
4220    }
4221    elements.retain(|element| match element {
4222        PositionedElement::Text(run) => !matches!(run.field_kind, Some(FieldKind::Target(_))),
4223        PositionedElement::MarkedContent { children, .. } => !children.is_empty(),
4224        _ => true,
4225    });
4226}
4227
4228fn mark_remaining_artifacts(elements: &mut Vec<PositionedElement>) {
4229    let unmarked = std::mem::take(elements);
4230    *elements = unmarked
4231        .into_iter()
4232        .map(|element| match element {
4233            PositionedElement::MarkedContent { .. } | PositionedElement::LinkAnnotation { .. } => {
4234                element
4235            }
4236            _ => PositionedElement::MarkedContent {
4237                structure: None,
4238                children: vec![element],
4239            },
4240        })
4241        .collect();
4242}
4243
4244struct StructureBuilder {
4245    nodes: Vec<StructureNode>,
4246}
4247
4248impl StructureBuilder {
4249    fn add(&mut self, role: StructureRole, parent: Option<StructureId>) -> StructureId {
4250        let id = StructureId::new(self.nodes.len() as u32 + 1)
4251            .expect("a structure node index is always non-zero");
4252        self.nodes.push(StructureNode {
4253            id,
4254            role,
4255            children: Vec::new(),
4256            alternate_text: None,
4257        });
4258        if let Some(parent) = parent
4259            && let Some(node) = self.nodes.get_mut(parent.get() as usize - 1)
4260        {
4261            node.children.push(id);
4262        }
4263        id
4264    }
4265
4266    fn set_alternate_text(&mut self, id: StructureId, text: String) {
4267        if let Some(node) = self.nodes.get_mut(id.get() as usize - 1) {
4268            node.alternate_text = Some(text);
4269        }
4270    }
4271}
4272
4273#[derive(Clone, Copy)]
4274struct ListFrame {
4275    num_id: u32,
4276    level: u8,
4277    list: StructureId,
4278    last_item: Option<StructureId>,
4279}
4280
4281fn assign_shared_document_structure(
4282    sections: &mut [paginator::SharedSection],
4283) -> DocumentStructure {
4284    let mut builder = StructureBuilder { nodes: Vec::new() };
4285    let root = builder.add(StructureRole::Document, None);
4286
4287    for section in sections {
4288        let mut lists: Vec<ListFrame> = Vec::new();
4289        for block in &mut section.blocks {
4290            match block {
4291                SharedLayoutBlock::Paragraph { block, semantics } => {
4292                    lists.clear();
4293                    let paragraph_id = builder.add(StructureRole::Paragraph, Some(root));
4294                    semantics.structure_id = Some(paragraph_id);
4295                    debug_assert!(block.list.is_none());
4296                    debug_assert!(block.anchored.is_empty());
4297                }
4298                SharedLayoutBlock::Table { block, semantics } => {
4299                    lists.clear();
4300                    assign_shared_table_structure(&mut builder, block, semantics, root);
4301                }
4302                SharedLayoutBlock::Owned { block, .. } => match block.as_mut() {
4303                    LayoutBlock::Paragraph(paragraph) => {
4304                        assign_owned_paragraph_structure(&mut builder, paragraph, root, &mut lists);
4305                    }
4306                    LayoutBlock::Table(table) => {
4307                        lists.clear();
4308                        assign_owned_table_structure(&mut builder, table, root);
4309                    }
4310                },
4311            }
4312        }
4313    }
4314
4315    DocumentStructure {
4316        root,
4317        nodes: builder.nodes,
4318    }
4319}
4320
4321fn assign_owned_paragraph_structure(
4322    builder: &mut StructureBuilder,
4323    paragraph: &mut ParagraphBlock,
4324    root: StructureId,
4325    lists: &mut Vec<ListFrame>,
4326) {
4327    if let Some((num_id, level)) = paragraph.list {
4328        if lists.last().is_some_and(|frame| frame.num_id != num_id) {
4329            lists.clear();
4330        }
4331        while lists.last().is_some_and(|frame| frame.level > level) {
4332            lists.pop();
4333        }
4334        if lists.is_empty() || lists.last().is_some_and(|frame| frame.level < level) {
4335            let parent = lists
4336                .last()
4337                .and_then(|frame| frame.last_item)
4338                .unwrap_or(root);
4339            let list = builder.add(StructureRole::List, Some(parent));
4340            lists.push(ListFrame {
4341                num_id,
4342                level,
4343                list,
4344                last_item: None,
4345            });
4346        }
4347        let frame = lists
4348            .last_mut()
4349            .expect("the requested list level has been allocated");
4350        let item = builder.add(StructureRole::ListItem, Some(frame.list));
4351        frame.last_item = Some(item);
4352        let paragraph_id = builder.add(StructureRole::Paragraph, Some(item));
4353        paragraph.structure_id = Some(paragraph_id);
4354        assign_paragraph_figures(builder, paragraph, paragraph_id, item);
4355    } else {
4356        lists.clear();
4357        let role = paragraph
4358            .heading_level
4359            .map(|level| StructureRole::Heading(level.min(6) as u8))
4360            .unwrap_or(StructureRole::Paragraph);
4361        let paragraph_id = builder.add(role, Some(root));
4362        paragraph.structure_id = Some(paragraph_id);
4363        assign_paragraph_figures(builder, paragraph, paragraph_id, root);
4364    }
4365}
4366
4367fn assign_owned_table_structure(
4368    builder: &mut StructureBuilder,
4369    table: &mut table::TableBlock,
4370    parent: StructureId,
4371) {
4372    let table_id = builder.add(StructureRole::Table, Some(parent));
4373    table.structure_id = Some(table_id);
4374    for row in &mut table.rows {
4375        let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4376        row.structure_id = Some(row_id);
4377        for cell in &mut row.cells {
4378            let role = if row.is_header {
4379                StructureRole::TableHeaderCell
4380            } else {
4381                StructureRole::TableCell
4382            };
4383            let cell_id = builder.add(role, Some(row_id));
4384            cell.structure_id = Some(cell_id);
4385            for block in &mut cell.blocks {
4386                assign_cell_block_structure(builder, block, cell_id);
4387            }
4388        }
4389    }
4390}
4391
4392fn assign_shared_table_structure(
4393    builder: &mut StructureBuilder,
4394    table: &table::TableBlock,
4395    semantics: &mut TableSemantics,
4396    parent: StructureId,
4397) {
4398    let table_id = builder.add(StructureRole::Table, Some(parent));
4399    for (row, row_semantics) in table.rows.iter().zip(&mut semantics.rows) {
4400        let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4401        for (cell, cell_semantics) in row.cells.iter().zip(&mut row_semantics.cells) {
4402            let role = if row.is_header {
4403                StructureRole::TableHeaderCell
4404            } else {
4405                StructureRole::TableCell
4406            };
4407            let cell_id = builder.add(role, Some(row_id));
4408            for (block, block_semantics) in cell.blocks.iter().zip(&mut cell_semantics.blocks) {
4409                match (block, block_semantics) {
4410                    (
4411                        table::CellBlock::Paragraph(paragraph),
4412                        CellBlockSemantics::Paragraph(semantics),
4413                    ) => {
4414                        let role = paragraph
4415                            .heading_level
4416                            .map(|level| StructureRole::Heading(level.min(6) as u8))
4417                            .unwrap_or(StructureRole::Paragraph);
4418                        semantics.structure_id = Some(builder.add(role, Some(cell_id)));
4419                    }
4420                    (table::CellBlock::Table(table), CellBlockSemantics::Table(semantics)) => {
4421                        assign_shared_table_structure(builder, table, semantics, cell_id)
4422                    }
4423                    _ => unreachable!("shared table semantics stay aligned"),
4424                }
4425            }
4426        }
4427    }
4428}
4429
4430#[cfg(test)]
4431fn assign_document_structure(sections: &mut [paginator::Section]) -> DocumentStructure {
4432    let mut builder = StructureBuilder { nodes: Vec::new() };
4433    let root = builder.add(StructureRole::Document, None);
4434
4435    for section in sections {
4436        let mut lists: Vec<ListFrame> = Vec::new();
4437        for block in &mut section.blocks {
4438            match block {
4439                LayoutBlock::Paragraph(paragraph) => {
4440                    if let Some((num_id, level)) = paragraph.list {
4441                        if lists.last().is_some_and(|frame| frame.num_id != num_id) {
4442                            lists.clear();
4443                        }
4444                        while lists.last().is_some_and(|frame| frame.level > level) {
4445                            lists.pop();
4446                        }
4447                        if lists.is_empty() || lists.last().is_some_and(|frame| frame.level < level)
4448                        {
4449                            let parent = lists
4450                                .last()
4451                                .and_then(|frame| frame.last_item)
4452                                .unwrap_or(root);
4453                            let list = builder.add(StructureRole::List, Some(parent));
4454                            lists.push(ListFrame {
4455                                num_id,
4456                                level,
4457                                list,
4458                                last_item: None,
4459                            });
4460                        }
4461                        let frame = lists
4462                            .last_mut()
4463                            .expect("the requested list level has been allocated");
4464                        let item = builder.add(StructureRole::ListItem, Some(frame.list));
4465                        frame.last_item = Some(item);
4466                        let paragraph_id = builder.add(StructureRole::Paragraph, Some(item));
4467                        paragraph.structure_id = Some(paragraph_id);
4468                        assign_paragraph_figures(&mut builder, paragraph, paragraph_id, item);
4469                    } else {
4470                        lists.clear();
4471                        let role = paragraph
4472                            .heading_level
4473                            .map(|level| StructureRole::Heading(level.min(6) as u8))
4474                            .unwrap_or(StructureRole::Paragraph);
4475                        let paragraph_id = builder.add(role, Some(root));
4476                        paragraph.structure_id = Some(paragraph_id);
4477                        assign_paragraph_figures(&mut builder, paragraph, paragraph_id, root);
4478                    }
4479                }
4480                LayoutBlock::Table(table) => {
4481                    lists.clear();
4482                    let table_id = builder.add(StructureRole::Table, Some(root));
4483                    table.structure_id = Some(table_id);
4484                    for row in &mut table.rows {
4485                        let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4486                        row.structure_id = Some(row_id);
4487                        for cell in &mut row.cells {
4488                            let role = if row.is_header {
4489                                StructureRole::TableHeaderCell
4490                            } else {
4491                                StructureRole::TableCell
4492                            };
4493                            let cell_id = builder.add(role, Some(row_id));
4494                            cell.structure_id = Some(cell_id);
4495                            for block in &mut cell.blocks {
4496                                assign_cell_block_structure(&mut builder, block, cell_id);
4497                            }
4498                        }
4499                    }
4500                }
4501            }
4502        }
4503    }
4504
4505    DocumentStructure {
4506        root,
4507        nodes: builder.nodes,
4508    }
4509}
4510
4511fn assign_cell_block_structure(
4512    builder: &mut StructureBuilder,
4513    block: &mut table::CellBlock,
4514    parent: StructureId,
4515) {
4516    match block {
4517        table::CellBlock::Paragraph(paragraph) => {
4518            let role = paragraph
4519                .heading_level
4520                .map(|level| StructureRole::Heading(level.min(6) as u8))
4521                .unwrap_or(StructureRole::Paragraph);
4522            let paragraph_id = builder.add(role, Some(parent));
4523            paragraph.structure_id = Some(paragraph_id);
4524            assign_paragraph_figures(builder, paragraph, paragraph_id, parent);
4525        }
4526        table::CellBlock::Table(table) => {
4527            let table_id = builder.add(StructureRole::Table, Some(parent));
4528            table.structure_id = Some(table_id);
4529            for row in &mut table.rows {
4530                let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4531                row.structure_id = Some(row_id);
4532                for cell in &mut row.cells {
4533                    let role = if row.is_header {
4534                        StructureRole::TableHeaderCell
4535                    } else {
4536                        StructureRole::TableCell
4537                    };
4538                    let cell_id = builder.add(role, Some(row_id));
4539                    cell.structure_id = Some(cell_id);
4540                    for child in &mut cell.blocks {
4541                        assign_cell_block_structure(builder, child, cell_id);
4542                    }
4543                }
4544            }
4545        }
4546    }
4547}
4548
4549fn assign_paragraph_figures(
4550    builder: &mut StructureBuilder,
4551    paragraph: &mut ParagraphBlock,
4552    inline_parent: StructureId,
4553    anchored_parent: StructureId,
4554) {
4555    for line in &mut paragraph.lines {
4556        for item in &mut line.items {
4557            let (alternate_text, structure_id) = match item {
4558                LineItem::Figure {
4559                    alternate_text,
4560                    structure_id,
4561                    ..
4562                } => (alternate_text, structure_id),
4563                _ => continue,
4564            };
4565            let figure = builder.add(StructureRole::Figure, Some(inline_parent));
4566            builder.set_alternate_text(figure, alternate_text.clone());
4567            *structure_id = Some(figure);
4568        }
4569    }
4570    for drawing in &mut paragraph.anchored {
4571        if let Some(text) = drawing
4572            .alternate_text
4573            .as_deref()
4574            .map(str::trim)
4575            .filter(|text| !text.is_empty())
4576        {
4577            let figure = builder.add(StructureRole::Figure, Some(anchored_parent));
4578            builder.set_alternate_text(figure, text.to_owned());
4579            drawing.structure_id = Some(figure);
4580        }
4581    }
4582}
4583
4584/// Detect if a paragraph has a heading style, returning the level (1-9).
4585fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
4586    let style_id = para.properties.as_ref()?.style_id.as_deref()?;
4587    // Check if style ID matches "Heading1" .. "Heading9"
4588    if let Some(rest) = style_id.strip_prefix("Heading") {
4589        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
4590    }
4591    // Also check style name in the styles definitions
4592    if let Some(style_def) = styles.get_by_id(style_id)
4593        && let Some(ref name) = style_def.name
4594        && let Some(rest) = name.strip_prefix("heading ")
4595    {
4596        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
4597    }
4598    None
4599}
4600
4601/// Lay out a single paragraph into a ParagraphBlock.
4602pub fn layout_paragraph(
4603    para: &CT_P,
4604    available_width: f64,
4605    styles: &CT_Styles,
4606    input: &LayoutInput,
4607    media: &MediaRegistry,
4608    fm: &mut FontManager,
4609    num_state: &mut NumberingState,
4610    diagnostics: &mut Vec<Diagnostic>,
4611) -> Result<ParagraphBlock> {
4612    layout_paragraph_with_source(
4613        para,
4614        available_width,
4615        styles,
4616        input,
4617        media,
4618        fm,
4619        num_state,
4620        diagnostics,
4621        None,
4622    )
4623}
4624
4625pub(crate) fn layout_paragraph_with_source(
4626    para: &CT_P,
4627    available_width: f64,
4628    styles: &CT_Styles,
4629    input: &LayoutInput,
4630    media: &MediaRegistry,
4631    fm: &mut FontManager,
4632    num_state: &mut NumberingState,
4633    diagnostics: &mut Vec<Diagnostic>,
4634    source_node: Option<SourceNodeId>,
4635) -> Result<ParagraphBlock> {
4636    layout_paragraph_with_source_and_table(
4637        para,
4638        available_width,
4639        styles,
4640        input,
4641        media,
4642        fm,
4643        num_state,
4644        diagnostics,
4645        source_node,
4646        None,
4647        None,
4648    )
4649}
4650
4651#[allow(clippy::too_many_arguments)]
4652pub(crate) fn layout_paragraph_with_source_and_direction(
4653    para: &CT_P,
4654    available_width: f64,
4655    styles: &CT_Styles,
4656    input: &LayoutInput,
4657    media: &MediaRegistry,
4658    fm: &mut FontManager,
4659    num_state: &mut NumberingState,
4660    diagnostics: &mut Vec<Diagnostic>,
4661    source_node: Option<SourceNodeId>,
4662) -> Result<(ParagraphBlock, TextDirection)> {
4663    let mut direction = TextDirection::Auto;
4664    let block = layout_paragraph_with_source_and_table(
4665        para,
4666        available_width,
4667        styles,
4668        input,
4669        media,
4670        fm,
4671        num_state,
4672        diagnostics,
4673        source_node,
4674        None,
4675        Some(&mut direction),
4676    )?;
4677    Ok((block, direction))
4678}
4679
4680#[allow(clippy::too_many_arguments)]
4681pub(crate) fn layout_paragraph_with_source_in_table(
4682    para: &CT_P,
4683    available_width: f64,
4684    styles: &CT_Styles,
4685    input: &LayoutInput,
4686    media: &MediaRegistry,
4687    fm: &mut FontManager,
4688    num_state: &mut NumberingState,
4689    diagnostics: &mut Vec<Diagnostic>,
4690    source_node: Option<SourceNodeId>,
4691    table_properties: Option<&rdocx_oxml::properties::CT_PPr>,
4692) -> Result<(ParagraphBlock, TextDirection)> {
4693    let mut direction = TextDirection::Auto;
4694    let block = layout_paragraph_with_source_and_table(
4695        para,
4696        available_width,
4697        styles,
4698        input,
4699        media,
4700        fm,
4701        num_state,
4702        diagnostics,
4703        source_node,
4704        table_properties,
4705        Some(&mut direction),
4706    )?;
4707    Ok((block, direction))
4708}
4709
4710#[allow(clippy::too_many_arguments)]
4711fn layout_paragraph_with_source_and_table(
4712    para: &CT_P,
4713    available_width: f64,
4714    styles: &CT_Styles,
4715    input: &LayoutInput,
4716    media: &MediaRegistry,
4717    fm: &mut FontManager,
4718    num_state: &mut NumberingState,
4719    diagnostics: &mut Vec<Diagnostic>,
4720    source_node: Option<SourceNodeId>,
4721    table_properties: Option<&rdocx_oxml::properties::CT_PPr>,
4722    reflow_direction_out: Option<&mut TextDirection>,
4723) -> Result<ParagraphBlock> {
4724    // Resolve paragraph properties
4725    let para_style_id = para.properties.as_ref().and_then(|p| p.style_id.as_deref());
4726
4727    let resolved_ppr = style_resolver::resolve_paragraph_properties_in_table(
4728        para_style_id,
4729        styles,
4730        table_properties,
4731    );
4732
4733    let mut effective_ppr = resolved_ppr;
4734
4735    // A numbering level carries paragraph properties of its own, mainly the
4736    // indentation for that level. They sit between the style and direct
4737    // formatting, so merge them before the direct properties rather than
4738    // after. Without this every level of a list draws at the same indent.
4739    let direct_ppr = para.properties.as_ref();
4740    let list_num_id = direct_ppr.and_then(|p| p.num_id).or(effective_ppr.num_id);
4741    let list_ilvl = direct_ppr
4742        .and_then(|p| p.num_ilvl)
4743        .or(effective_ppr.num_ilvl)
4744        .unwrap_or(0);
4745    if let (Some(num_id), Some(numbering)) = (list_num_id, input.numbering.as_ref())
4746        && let Some(lvl_ppr) =
4747            style_resolver::level_paragraph_properties(num_id, list_ilvl, numbering)
4748    {
4749        merge_direct_ppr(&mut effective_ppr, lvl_ppr);
4750    }
4751
4752    // Merge direct paragraph properties
4753    if let Some(direct_ppr) = direct_ppr {
4754        merge_direct_ppr(&mut effective_ppr, direct_ppr);
4755    }
4756
4757    // Convert paragraph properties to layout values
4758    let space_before = effective_ppr.space_before.map(|t| t.to_pt()).unwrap_or(0.0);
4759    let space_after = effective_ppr.space_after.map(|t| t.to_pt()).unwrap_or(0.0);
4760    let base_direction = match effective_ppr.bidi {
4761        Some(true) => TextDirection::RightToLeft,
4762        Some(false) => TextDirection::LeftToRight,
4763        None => TextDirection::Auto,
4764    };
4765    let keep_next = effective_ppr.keep_next.unwrap_or(false);
4766    let keep_lines = effective_ppr.keep_lines.unwrap_or(false);
4767    let page_break_before = effective_ppr.page_break_before.unwrap_or(false);
4768    let widow_control = effective_ppr.widow_control.unwrap_or(true);
4769    let automatic_hyphenation =
4770        input.automatic_hyphenation && effective_ppr.suppress_auto_hyphens != Some(true);
4771
4772    // Parse shading color
4773    let shading = effective_ppr
4774        .shading
4775        .as_ref()
4776        .and_then(|shd| shd.fill.as_ref())
4777        .filter(|f| f != &"auto")
4778        .map(|f| Color::from_hex(f));
4779
4780    // Convert runs to inline items
4781    let mut inline_items = Vec::new();
4782    let mut multilingual_styles = HashMap::<usize, WordMultilingualStyle>::new();
4783
4784    // Handle numbering marker
4785    if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) {
4786        let ilvl = effective_ppr.num_ilvl.unwrap_or(0);
4787        if let Some(marker) = style_resolver::generate_marker(num_id, ilvl, numbering, num_state) {
4788            // Shape the marker text
4789            let marker_rpr = marker.marker_rpr;
4790            let marker_font_size = marker_rpr.sz.map(|hp| hp.to_pt()).unwrap_or_else(|| {
4791                style_resolver::resolve_run_properties(para_style_id, None, styles)
4792                    .sz
4793                    .map(|hp| hp.to_pt())
4794                    .unwrap_or(11.0)
4795            });
4796            let marker_bold = marker_rpr.bold.unwrap_or(false);
4797            let marker_italic = marker_rpr.italic.unwrap_or(false);
4798            let marker_font_family = marker_rpr.font_ascii.as_deref();
4799
4800            // Bullet glyphs are not in every font either, so the marker gets
4801            // the same coverage check as body text.
4802            if let Ok(font_id) = fm.resolve_font_for_text(
4803                marker_font_family,
4804                marker_bold,
4805                marker_italic,
4806                &marker.marker_text,
4807            ) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size)
4808            {
4809                let metrics = fm.metrics(font_id, marker_font_size)?;
4810                let color = marker_rpr
4811                    .color
4812                    .as_ref()
4813                    .map(|c| Color::from_hex(c))
4814                    .unwrap_or(Color::BLACK);
4815
4816                inline_items.push(InlineItem::Marker(TextSegment {
4817                    text: marker.marker_text,
4818                    direction: TextDirection::Auto,
4819                    source: None,
4820                    font_id,
4821                    font_size: marker_font_size,
4822                    glyph_ids: shaped.glyph_ids,
4823                    advances: shaped.advances,
4824                    width: shaped.width,
4825                    ascent: metrics.ascent,
4826                    descent: metrics.descent,
4827                    line_gap: 0.0,
4828                    color,
4829                    bold: marker_bold,
4830                    italic: marker_italic,
4831                    underline: None,
4832                    strike: false,
4833                    dstrike: false,
4834                    highlight: None,
4835                    baseline_offset: 0.0,
4836                    hyperlink_url: None,
4837                    field_kind: None,
4838                    note: None,
4839                }));
4840
4841                match marker.suffix {
4842                    ST_LvlSuffix::Tab => inline_items.push(InlineItem::Tab),
4843                    ST_LvlSuffix::Space => {
4844                        let shaped = fm.shape_text(font_id, " ", marker_font_size)?;
4845                        inline_items.push(InlineItem::Text(TextSegment {
4846                            text: " ".to_owned(),
4847                            direction: TextDirection::Auto,
4848                            source: None,
4849                            font_id,
4850                            font_size: marker_font_size,
4851                            glyph_ids: shaped.glyph_ids,
4852                            advances: shaped.advances,
4853                            width: shaped.width,
4854                            ascent: metrics.ascent,
4855                            descent: metrics.descent,
4856                            line_gap: 0.0,
4857                            color,
4858                            bold: marker_bold,
4859                            italic: marker_italic,
4860                            underline: None,
4861                            strike: false,
4862                            dstrike: false,
4863                            highlight: None,
4864                            baseline_offset: 0.0,
4865                            hyperlink_url: None,
4866                            field_kind: None,
4867                            note: None,
4868                        }));
4869                    }
4870                    ST_LvlSuffix::Nothing => {}
4871                }
4872            }
4873        }
4874    }
4875
4876    // Build hyperlink URL map: run index → URL
4877    let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
4878        std::collections::HashMap::new();
4879    for hl in &para.hyperlinks {
4880        if let Some(ref rel_id) = hl.rel_id
4881            && let Some(url) = input.hyperlink_urls.get(rel_id)
4882        {
4883            for run_idx in hl.run_start..hl.run_end {
4884                run_hyperlink_url.insert(run_idx, url.clone());
4885            }
4886        }
4887    }
4888
4889    // Process ordinary and revision-wrapped runs in their preserved order.
4890    let mut marker_boundary = None;
4891    let mut marker_raw_before = None;
4892    let mut projection_char_offset = 0usize;
4893    for projected in project_paragraph_runs(para, input.revision_view) {
4894        let run = projected.run;
4895        let projected_run_start = projection_char_offset;
4896        projection_char_offset += run.text().chars().count();
4897        if marker_boundary != Some(projected.boundary) {
4898            marker_boundary = Some(projected.boundary);
4899            marker_raw_before = None;
4900        }
4901        push_targeted_bookmark_markers(
4902            &mut inline_items,
4903            para,
4904            projected.boundary,
4905            marker_raw_before,
4906            projected.raw_order,
4907            input,
4908            fm,
4909        )?;
4910        marker_raw_before = Some(projected.raw_order);
4911        let current_hyperlink_url = projected
4912            .ordinary_run_index
4913            .and_then(|run_index| run_hyperlink_url.get(&run_index).cloned())
4914            .or_else(|| {
4915                projected
4916                    .hyperlink_index
4917                    .and_then(|index| para.hyperlinks.get(index))
4918                    .and_then(|hyperlink| hyperlink.rel_id.as_deref())
4919                    .and_then(|rel_id| input.hyperlink_urls.get(rel_id).cloned())
4920            });
4921
4922        let run_style_id = run.properties.as_ref().and_then(|p| p.style_id.as_deref());
4923
4924        let resolved_rpr =
4925            style_resolver::resolve_run_properties(para_style_id, run_style_id, styles);
4926
4927        // Merge direct run properties
4928        let mut effective_rpr = resolved_rpr;
4929        if let Some(ref direct_rpr) = run.properties {
4930            effective_rpr.merge_from(direct_rpr);
4931        }
4932
4933        // Skip hidden text
4934        if effective_rpr.vanish == Some(true) {
4935            continue;
4936        }
4937
4938        let mut font_size = effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
4939        let bold = effective_rpr.bold.unwrap_or(false);
4940        let italic = effective_rpr.italic.unwrap_or(false);
4941
4942        // Resolve font family: theme font takes priority when no explicit font is set
4943        let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
4944
4945        // Resolve color: theme color takes priority over literal color value
4946        let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
4947
4948        // Decoration properties
4949        let underline = if projected.force_underline {
4950            Some(Underline::Single)
4951        } else {
4952            convert::underline(effective_rpr.underline)
4953        };
4954        let strike = projected.force_strike || effective_rpr.strike.unwrap_or(false);
4955        let dstrike = effective_rpr.dstrike.unwrap_or(false);
4956        let highlight = effective_rpr.highlight.and_then(highlight_to_color);
4957
4958        // Superscript/subscript handling
4959        let mut baseline_offset = 0.0;
4960        if let Some(ref va) = effective_rpr.vert_align {
4961            match va.as_str() {
4962                "superscript" => {
4963                    // Reduce font size to ~58% and raise baseline
4964                    let original_size = font_size;
4965                    font_size *= 0.58;
4966                    baseline_offset = original_size * 0.33; // raise by 1/3 of original size
4967                }
4968                "subscript" => {
4969                    // Reduce font size to ~58% and lower baseline
4970                    let original_size = font_size;
4971                    font_size *= 0.58;
4972                    baseline_offset = -(original_size * 0.14); // lower
4973                }
4974                _ => {}
4975            }
4976        }
4977
4978        // Position offset (in half-points, positive=raise)
4979        if let Some(pos) = effective_rpr.position {
4980            baseline_offset += pos as f64 / 2.0; // half-points to points
4981        }
4982
4983        // Resolved against the run's own text, so a family without glyphs for
4984        // this script is replaced by one that has them.
4985        let font_id =
4986            fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?;
4987        let metrics = fm.metrics(font_id, font_size)?;
4988
4989        let content_char_starts = projected_content_char_starts(run);
4990        for (content_index, content) in run.content.iter().enumerate() {
4991            let content_char_start = projected_run_start + content_char_starts[content_index];
4992            match content {
4993                RunContent::Text(ct_text) | RunContent::DeletedText(ct_text) => {
4994                    let text = if effective_rpr.caps == Some(true) {
4995                        ct_text.text.to_uppercase()
4996                    } else {
4997                        ct_text.text.clone()
4998                    };
4999
5000                    if text.is_empty() {
5001                        continue;
5002                    }
5003
5004                    let mut shaped = fm.shape_text(font_id, &text, font_size)?;
5005                    let source = if text == ct_text.text {
5006                        source_node.and_then(|node| {
5007                            let char_start = u32::try_from(content_char_start).ok()?;
5008                            let char_end =
5009                                u32::try_from(content_char_start + ct_text.text.chars().count())
5010                                    .ok()?;
5011                            Some(SourceSpan {
5012                                node,
5013                                char_start,
5014                                char_end,
5015                            })
5016                        })
5017                    } else {
5018                        None
5019                    };
5020
5021                    // Apply character spacing from run properties (in twips)
5022                    if let Some(spacing) = effective_rpr.spacing {
5023                        let extra = spacing.to_pt();
5024                        for advance in &mut shaped.advances {
5025                            *advance += extra;
5026                        }
5027                        shaped.width += extra * shaped.advances.len() as f64;
5028                    }
5029
5030                    let segment = TextSegment {
5031                        text,
5032                        direction: TextDirection::Auto,
5033                        source,
5034                        font_id,
5035                        font_size,
5036                        glyph_ids: shaped.glyph_ids,
5037                        advances: shaped.advances,
5038                        width: shaped.width,
5039                        ascent: metrics.ascent,
5040                        descent: metrics.descent,
5041                        line_gap: 0.0,
5042                        color,
5043                        bold,
5044                        italic,
5045                        underline,
5046                        strike,
5047                        dstrike,
5048                        highlight,
5049                        baseline_offset,
5050                        hyperlink_url: current_hyperlink_url.clone(),
5051                        field_kind: None,
5052                        note: None,
5053                    };
5054                    let item_index = inline_items.len();
5055                    multilingual_styles.insert(
5056                        item_index,
5057                        WordMultilingualStyle {
5058                            language: effective_rpr.language.clone(),
5059                            language_east_asia: effective_rpr.language_east_asia.clone(),
5060                            language_bidi: effective_rpr.language_bidi.clone(),
5061                            direction: word_text_direction(effective_rpr.rtl),
5062                            spacing: effective_rpr.spacing.map_or(0.0, |value| value.to_pt()),
5063                        },
5064                    );
5065                    if automatic_hyphenation && let Some(language) = effective_rpr.language.as_ref()
5066                    {
5067                        inline_items.push(InlineItem::HyphenatedText {
5068                            segment,
5069                            language: language.clone(),
5070                        });
5071                    } else {
5072                        inline_items.push(InlineItem::Text(segment));
5073                    }
5074                }
5075                RunContent::Tab => {
5076                    inline_items.push(InlineItem::Tab);
5077                }
5078                RunContent::Break(bt) => match bt {
5079                    BreakType::Line => inline_items.push(InlineItem::LineBreak),
5080                    BreakType::Page => inline_items.push(InlineItem::PageBreak),
5081                    BreakType::Column => inline_items.push(InlineItem::ColumnBreak),
5082                },
5083                RunContent::Drawing(drawing) => {
5084                    if let Some(ref inline) = drawing.inline {
5085                        let width = inline.extent_cx.to_pt();
5086                        let height = inline.extent_cy.to_pt();
5087                        let item = if let Some(relationship_id) = inline.chart_rel_id.as_deref() {
5088                            InlineItem::Group {
5089                                width,
5090                                height,
5091                                group: render_word_chart(
5092                                    relationship_id,
5093                                    width,
5094                                    height,
5095                                    input,
5096                                    fm,
5097                                    diagnostics,
5098                                )?,
5099                            }
5100                        } else {
5101                            InlineItem::Image {
5102                                width,
5103                                height,
5104                                media_id: media.id_for_relationship(&inline.embed_id),
5105                            }
5106                        };
5107                        if let Some(alternate_text) = inline
5108                            .description
5109                            .as_deref()
5110                            .map(str::trim)
5111                            .filter(|text| !text.is_empty())
5112                        {
5113                            inline_items.push(InlineItem::Figure {
5114                                item: Box::new(item),
5115                                alternate_text: alternate_text.to_owned(),
5116                                structure_id: None,
5117                            });
5118                        } else {
5119                            inline_items.push(item);
5120                        }
5121                    }
5122                }
5123                RunContent::Field(field) => {
5124                    let (computed_value, field_kind) = match field.instruction.name.as_str() {
5125                        "PAGE" => (Some("99".to_owned()), Some(FieldKind::Page)),
5126                        "NUMPAGES" => (Some("99".to_owned()), Some(FieldKind::NumPages)),
5127                        "REF" => {
5128                            let Some(bookmark) = field_text_argument(field, 0) else {
5129                                continue;
5130                            };
5131                            if let Some(text) = bookmark_text(input, bookmark) {
5132                                (Some(text), None)
5133                            } else {
5134                                diagnostics.push(Diagnostic {
5135                                    message: format!(
5136                                        "REF target {bookmark} was not found, stored display retained"
5137                                    ),
5138                                });
5139                                (None, None)
5140                            }
5141                        }
5142                        "PAGEREF" => {
5143                            let Some(bookmark) = field_text_argument(field, 0) else {
5144                                continue;
5145                            };
5146                            if bookmark_text(input, bookmark).is_none() {
5147                                diagnostics.push(Diagnostic {
5148                                    message: format!(
5149                                        "PAGEREF target {bookmark} was not found, stored display retained"
5150                                    ),
5151                                });
5152                                (None, None)
5153                            } else if let Some(target) = page_ref_id(input, bookmark) {
5154                                (Some("99".to_owned()), Some(FieldKind::TargetPage(target)))
5155                            } else {
5156                                (None, None)
5157                            }
5158                        }
5159                        _ => (None, None),
5160                    };
5161                    let stored_segments = field.cached_display_segments();
5162                    let segments = if let Some(value) = computed_value.as_deref() {
5163                        let stored_properties = stored_segments
5164                            .first()
5165                            .and_then(|(_, properties)| *properties);
5166                        vec![(value, stored_properties)]
5167                    } else {
5168                        stored_segments
5169                    };
5170                    for (value, stored_properties) in segments {
5171                        let segment_style_id =
5172                            stored_properties.and_then(|properties| properties.style_id.as_deref());
5173                        let mut segment_rpr = if stored_properties.is_some() {
5174                            style_resolver::resolve_run_properties(
5175                                para_style_id,
5176                                segment_style_id,
5177                                styles,
5178                            )
5179                        } else {
5180                            effective_rpr.clone()
5181                        };
5182                        if let Some(properties) = stored_properties {
5183                            segment_rpr.merge_from(properties);
5184                        }
5185                        if segment_rpr.vanish == Some(true) {
5186                            continue;
5187                        }
5188                        let mut segment_font_size =
5189                            segment_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
5190                        let segment_bold = segment_rpr.bold.unwrap_or(false);
5191                        let segment_italic = segment_rpr.italic.unwrap_or(false);
5192                        let segment_font_family =
5193                            resolve_font_family(&segment_rpr, input.theme.as_ref());
5194                        let segment_color = resolve_run_color(&segment_rpr, input.theme.as_ref());
5195                        let segment_underline = if projected.force_underline {
5196                            Some(Underline::Single)
5197                        } else {
5198                            convert::underline(segment_rpr.underline)
5199                        };
5200                        let segment_strike =
5201                            projected.force_strike || segment_rpr.strike.unwrap_or(false);
5202                        let segment_dstrike = segment_rpr.dstrike.unwrap_or(false);
5203                        let segment_highlight = segment_rpr.highlight.and_then(highlight_to_color);
5204                        let mut segment_baseline_offset = 0.0;
5205                        if let Some(vertical) = segment_rpr.vert_align.as_deref() {
5206                            match vertical {
5207                                "superscript" => {
5208                                    let original_size = segment_font_size;
5209                                    segment_font_size *= 0.58;
5210                                    segment_baseline_offset = original_size * 0.33;
5211                                }
5212                                "subscript" => {
5213                                    let original_size = segment_font_size;
5214                                    segment_font_size *= 0.58;
5215                                    segment_baseline_offset = -(original_size * 0.14);
5216                                }
5217                                _ => {}
5218                            }
5219                        }
5220                        if let Some(position) = segment_rpr.position {
5221                            segment_baseline_offset += position as f64 / 2.0;
5222                        }
5223                        let segment_font_id = fm.resolve_font_for_text(
5224                            segment_font_family.as_deref(),
5225                            segment_bold,
5226                            segment_italic,
5227                            value,
5228                        )?;
5229                        let segment_metrics = fm.metrics(segment_font_id, segment_font_size)?;
5230
5231                        let mut start = 0usize;
5232                        for (index, character) in value
5233                            .char_indices()
5234                            .chain(std::iter::once((value.len(), '\0')))
5235                        {
5236                            let control = match character {
5237                                '\t' => Some(InlineItem::Tab),
5238                                '\n' => Some(InlineItem::LineBreak),
5239                                '\u{000c}' => Some(InlineItem::PageBreak),
5240                                '\u{000b}' => Some(InlineItem::ColumnBreak),
5241                                '\0' if index == value.len() => None,
5242                                _ => continue,
5243                            };
5244                            if start < index {
5245                                let mut text = value[start..index].to_owned();
5246                                if segment_rpr.caps == Some(true) {
5247                                    text = text.to_uppercase();
5248                                }
5249                                let mut shaped =
5250                                    fm.shape_text(segment_font_id, &text, segment_font_size)?;
5251                                if let Some(spacing) = segment_rpr.spacing {
5252                                    let extra = spacing.to_pt();
5253                                    for advance in &mut shaped.advances {
5254                                        *advance += extra;
5255                                    }
5256                                    shaped.width += extra * shaped.advances.len() as f64;
5257                                }
5258                                let item_index = inline_items.len();
5259                                multilingual_styles.insert(
5260                                    item_index,
5261                                    WordMultilingualStyle {
5262                                        language: segment_rpr.language.clone(),
5263                                        language_east_asia: segment_rpr.language_east_asia.clone(),
5264                                        language_bidi: segment_rpr.language_bidi.clone(),
5265                                        direction: word_text_direction(segment_rpr.rtl),
5266                                        spacing: segment_rpr
5267                                            .spacing
5268                                            .map_or(0.0, |value| value.to_pt()),
5269                                    },
5270                                );
5271                                inline_items.push(InlineItem::Text(TextSegment {
5272                                    text,
5273                                    direction: word_text_direction(segment_rpr.rtl),
5274                                    source: None,
5275                                    font_id: segment_font_id,
5276                                    font_size: segment_font_size,
5277                                    glyph_ids: shaped.glyph_ids,
5278                                    advances: shaped.advances,
5279                                    width: shaped.width,
5280                                    ascent: segment_metrics.ascent,
5281                                    descent: segment_metrics.descent,
5282                                    line_gap: 0.0,
5283                                    color: segment_color,
5284                                    bold: segment_bold,
5285                                    italic: segment_italic,
5286                                    underline: segment_underline,
5287                                    strike: segment_strike,
5288                                    dstrike: segment_dstrike,
5289                                    highlight: segment_highlight,
5290                                    baseline_offset: segment_baseline_offset,
5291                                    hyperlink_url: current_hyperlink_url.clone(),
5292                                    field_kind,
5293                                    note: None,
5294                                }));
5295                            }
5296                            if let Some(control) = control {
5297                                inline_items.push(control);
5298                                start = index + character.len_utf8();
5299                            }
5300                        }
5301                    }
5302                }
5303                RunContent::FootnoteRef { id } | RunContent::EndnoteRef { id } => {
5304                    // The two streams number independently, so the marker has
5305                    // to carry which one it came from.
5306                    let stream = match content {
5307                        RunContent::EndnoteRef { .. } => NoteStream::Endnote,
5308                        _ => NoteStream::Footnote,
5309                    };
5310                    // Render as superscript number
5311                    let marker = id.to_string();
5312                    let sup_size = font_size * 0.58;
5313                    let sup_offset = font_size * 0.33; // raise baseline
5314                    let shaped = fm.shape_text(font_id, &marker, sup_size)?;
5315                    let sup_metrics = fm.metrics(font_id, sup_size)?;
5316                    let revision_marker = input.revision_view == RevisionView::Tracked
5317                        && projected.ordinary_run_index.is_none();
5318                    inline_items.push(InlineItem::Text(TextSegment {
5319                        text: marker,
5320                        direction: TextDirection::Auto,
5321                        source: None,
5322                        font_id,
5323                        font_size: sup_size,
5324                        glyph_ids: shaped.glyph_ids,
5325                        advances: shaped.advances,
5326                        width: shaped.width,
5327                        ascent: sup_metrics.ascent,
5328                        descent: sup_metrics.descent,
5329                        line_gap: 0.0,
5330                        color,
5331                        bold,
5332                        italic,
5333                        underline: revision_marker.then_some(underline).flatten(),
5334                        strike: revision_marker && strike,
5335                        dstrike: revision_marker && dstrike,
5336                        highlight: revision_marker.then_some(highlight).flatten(),
5337                        baseline_offset: sup_offset,
5338                        hyperlink_url: None,
5339                        field_kind: None,
5340                        note: Some(NoteRef { stream, id: *id }),
5341                    }));
5342                }
5343                RunContent::CommentReference { .. } => {}
5344            }
5345        }
5346    }
5347
5348    let final_marker_lower = (marker_boundary == Some(para.runs.len()))
5349        .then_some(marker_raw_before)
5350        .flatten();
5351    push_targeted_bookmark_markers(
5352        &mut inline_items,
5353        para,
5354        para.runs.len(),
5355        final_marker_lower,
5356        RawOrder::AfterRaw,
5357        input,
5358        fm,
5359    )?;
5360
5361    let attributed_empty_paragraph = inline_items.is_empty();
5362    if attributed_empty_paragraph {
5363        let mut caret_rpr = style_resolver::resolve_run_properties(para_style_id, None, styles);
5364        if let Some(paragraph_mark_rpr) = direct_ppr.and_then(|ppr| ppr.rpr.as_ref()) {
5365            caret_rpr.merge_from(paragraph_mark_rpr);
5366        }
5367        let font_size = caret_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
5368        let bold = caret_rpr.bold.unwrap_or(false);
5369        let italic = caret_rpr.italic.unwrap_or(false);
5370        let font_family = resolve_font_family(&caret_rpr, input.theme.as_ref());
5371        let font_id = fm.resolve_font_for_metrics(font_family.as_deref(), bold, italic)?;
5372        let metrics = fm.metrics(font_id, font_size)?;
5373        inline_items.push(InlineItem::Text(TextSegment {
5374            text: String::new(),
5375            direction: TextDirection::Auto,
5376            source: source_node.map(|node| SourceSpan {
5377                node,
5378                char_start: 0,
5379                char_end: 0,
5380            }),
5381            font_id,
5382            font_size,
5383            glyph_ids: Vec::new(),
5384            advances: Vec::new(),
5385            width: 0.0,
5386            ascent: metrics.ascent,
5387            descent: metrics.descent,
5388            line_gap: 0.0,
5389            color: resolve_run_color(&caret_rpr, input.theme.as_ref()),
5390            bold,
5391            italic,
5392            underline: None,
5393            strike: false,
5394            dstrike: false,
5395            highlight: None,
5396            baseline_offset: 0.0,
5397            hyperlink_url: None,
5398            field_kind: None,
5399            note: None,
5400        }));
5401    }
5402
5403    let layout_direction = match base_direction {
5404        TextDirection::Auto => inferred_word_base_direction(&inline_items),
5405        direction => direction,
5406    };
5407    if let Some(reflow_direction_out) = reflow_direction_out {
5408        *reflow_direction_out = layout_direction;
5409    }
5410    let logical_left = match layout_direction {
5411        TextDirection::RightToLeft => effective_ppr.ind_end,
5412        TextDirection::Auto | TextDirection::LeftToRight => effective_ppr.ind_start,
5413    };
5414    let logical_right = match layout_direction {
5415        TextDirection::RightToLeft => effective_ppr.ind_start,
5416        TextDirection::Auto | TextDirection::LeftToRight => effective_ppr.ind_end,
5417    };
5418    let ind_left = effective_ppr
5419        .ind_left
5420        .or(logical_left)
5421        .map(|t| t.to_pt())
5422        .unwrap_or(0.0);
5423    let ind_right = effective_ppr
5424        .ind_right
5425        .or(logical_right)
5426        .map(|t| t.to_pt())
5427        .unwrap_or(0.0);
5428    let jc = convert::alignment_for_direction(effective_ppr.jc, layout_direction);
5429
5430    // Line breaking
5431    let mut line_params = convert::line_break_params(&effective_ppr, available_width);
5432    line_params.ind_left = ind_left;
5433    line_params.ind_right = ind_right;
5434    line_params.jc = jc;
5435
5436    let legacy_empty_line = if attributed_empty_paragraph
5437        && direct_ppr
5438            .and_then(|properties| properties.rpr.as_ref())
5439            .is_none()
5440    {
5441        let mut lines = break_into_lines(&[], &line_params, fm)?;
5442        convert::restore_word_line_heights(&mut lines, &effective_ppr);
5443        lines.pop()
5444    } else {
5445        None
5446    };
5447
5448    let uses_multilingual_layout = base_direction != TextDirection::Auto
5449        || multilingual_styles
5450            .values()
5451            .any(|style| style.direction != TextDirection::Auto)
5452        || inline_items.iter().any(|item| {
5453            multilingual_candidate(item)
5454                .is_some_and(|segment| needs_word_multilingual_layout(&segment.text))
5455        });
5456    let uses_exact_word_baseline = uses_multilingual_layout
5457        && effective_ppr.line_rule.as_deref() == Some("exact")
5458        && effective_ppr.line_spacing.is_some();
5459    if uses_multilingual_layout {
5460        inline_items = shape_word_multilingual_items(
5461            fm,
5462            inline_items,
5463            &multilingual_styles,
5464            layout_direction,
5465            !line_params.wrap,
5466            uses_exact_word_baseline,
5467        )?;
5468    }
5469    let mut lines = if uses_multilingual_layout {
5470        break_multilingual_into_lines(&inline_items, &line_params, fm, layout_direction)?
5471    } else {
5472        break_into_lines(&inline_items, &line_params, fm)?
5473    };
5474    convert::restore_word_line_heights(&mut lines, &effective_ppr);
5475    if let (Some(line), Some(legacy)) = (lines.first_mut(), legacy_empty_line) {
5476        line.ascent = legacy.ascent;
5477        line.descent = legacy.descent;
5478        line.line_gap = legacy.line_gap;
5479        line.height = legacy.height;
5480    }
5481
5482    let mut result = block::build_paragraph_block(
5483        lines,
5484        space_before,
5485        space_after,
5486        effective_ppr.borders,
5487        shading,
5488        ind_left,
5489        ind_right,
5490        jc,
5491        keep_next,
5492        keep_lines,
5493        page_break_before,
5494        widow_control,
5495    );
5496    result.has_visible_revision =
5497        input.revision_view == RevisionView::Tracked && paragraph_has_visible_revision(para);
5498    result.list = list_num_id.map(|num_id| (num_id, list_ilvl.min(8) as u8));
5499    result.anchored =
5500        collect_anchored_drawings(para, styles, input, media, fm, num_state, diagnostics)?;
5501    // `inline_items` is finished with here and would otherwise be dropped, so
5502    // handing it to the reflow costs nothing but the memory it already holds.
5503    // `Engine::layout` frees it again unless the document wraps.
5504    result.reflow = Some(Box::new(block::ParagraphReflow {
5505        items: inline_items,
5506        params: line_params,
5507    }));
5508    Ok(result)
5509}
5510
5511fn push_targeted_bookmark_markers(
5512    items: &mut Vec<InlineItem>,
5513    paragraph: &CT_P,
5514    run_index: usize,
5515    after_raw: Option<RawOrder>,
5516    through_raw: RawOrder,
5517    input: &LayoutInput,
5518    fm: &mut FontManager,
5519) -> Result<()> {
5520    let mut font_id = None;
5521    for marker in paragraph.bookmark_markers.iter().filter(|marker| {
5522        marker.is_start()
5523            && marker.run_index() == run_index
5524            && after_raw.is_none_or(|after| RawOrder::Raw(marker.raw_before()) > after)
5525            && RawOrder::Raw(marker.raw_before()) <= through_raw
5526            && marker.name().is_some_and(|name| {
5527                document_has_page_ref(input, name) && bookmark_text(input, name).is_some()
5528            })
5529    }) {
5530        if let Some(target) = marker.name().and_then(|name| page_ref_id(input, name)) {
5531            let resolved_font = match font_id {
5532                Some(font_id) => font_id,
5533                None => {
5534                    let resolved = fm.resolve_font_for_text(None, false, false, " ")?;
5535                    font_id = Some(resolved);
5536                    resolved
5537                }
5538            };
5539            push_bookmark_marker(items, target, resolved_font);
5540        }
5541    }
5542    Ok(())
5543}
5544
5545fn push_bookmark_marker(items: &mut Vec<InlineItem>, target: usize, font_id: oxml_layout::FontId) {
5546    items.push(InlineItem::Text(TextSegment {
5547        text: "\u{2060}".to_owned(),
5548        direction: TextDirection::Auto,
5549        source: None,
5550        font_id,
5551        font_size: 1.0,
5552        glyph_ids: vec![0],
5553        advances: vec![0.0],
5554        width: 0.0,
5555        ascent: 0.0,
5556        descent: 0.0,
5557        line_gap: 0.0,
5558        color: Color::BLACK,
5559        bold: false,
5560        italic: false,
5561        underline: None,
5562        strike: false,
5563        dstrike: false,
5564        highlight: None,
5565        baseline_offset: 0.0,
5566        hyperlink_url: None,
5567        field_kind: Some(FieldKind::Target(target)),
5568        note: None,
5569    }));
5570}
5571
5572fn page_ref_id(input: &LayoutInput, name: &str) -> Option<usize> {
5573    let mut names = Vec::<&str>::new();
5574    visit_document_paragraphs(input, &mut |paragraph| {
5575        for projected in project_paragraph_runs(paragraph, input.revision_view) {
5576            let run = projected.run;
5577            for content in &run.content {
5578                let RunContent::Field(field) = content else {
5579                    continue;
5580                };
5581                if field.instruction.name != "PAGEREF" {
5582                    continue;
5583                }
5584                let Some(bookmark) = field_text_argument(field, 0) else {
5585                    continue;
5586                };
5587                if !names.contains(&bookmark) {
5588                    names.push(bookmark);
5589                }
5590            }
5591        }
5592    });
5593    names.iter().position(|candidate| *candidate == name)
5594}
5595
5596fn field_text_argument(field: &Field, index: usize) -> Option<&str> {
5597    match field.instruction.arguments.get(index) {
5598        Some(FieldArgument::Text(value)) => Some(value),
5599        Some(FieldArgument::Nested(_)) | None => None,
5600    }
5601}
5602
5603fn document_has_page_ref(input: &LayoutInput, name: &str) -> bool {
5604    page_ref_id(input, name).is_some()
5605}
5606
5607fn visit_document_paragraphs<'a>(input: &'a LayoutInput, visit: &mut impl FnMut(&'a CT_P)) {
5608    for content in &input.document.body.content {
5609        match content {
5610            BodyContent::Paragraph(paragraph) => visit(paragraph),
5611            BodyContent::Table(table) => visit_table_paragraphs(table, visit),
5612            BodyContent::ContentControl(control) => visit_control_paragraphs(control, visit),
5613            BodyContent::RawXml(_) => {}
5614        }
5615    }
5616}
5617
5618fn visit_table_paragraphs<'a>(table: &'a CT_Tbl, visit: &mut impl FnMut(&'a CT_P)) {
5619    for (_, _, control) in &table.content_controls {
5620        visit_control_paragraphs(control, visit);
5621    }
5622    for row in &table.rows {
5623        visit_row_paragraphs(row, visit);
5624    }
5625}
5626
5627fn visit_row_paragraphs<'a>(row: &'a CT_Row, visit: &mut impl FnMut(&'a CT_P)) {
5628    for (_, _, control) in &row.content_controls {
5629        visit_control_paragraphs(control, visit);
5630    }
5631    for cell in &row.cells {
5632        visit_cell_paragraphs(cell, visit);
5633    }
5634}
5635
5636fn visit_cell_paragraphs<'a>(cell: &'a CT_Tc, visit: &mut impl FnMut(&'a CT_P)) {
5637    for content in &cell.content {
5638        match content {
5639            CellContent::Paragraph(paragraph) => visit(paragraph),
5640            CellContent::Table(table) => visit_table_paragraphs(table, visit),
5641            CellContent::ContentControl(control) => visit_control_paragraphs(control, visit),
5642        }
5643    }
5644}
5645
5646fn visit_control_paragraphs<'a>(control: &'a CT_Sdt, visit: &mut impl FnMut(&'a CT_P)) {
5647    for content in &control.content {
5648        match content {
5649            SdtContent::Paragraph(paragraph) => visit(paragraph),
5650            SdtContent::Table(table) => visit_table_paragraphs(table, visit),
5651            SdtContent::Row(row) => visit_row_paragraphs(row, visit),
5652            SdtContent::Cell(cell) => visit_cell_paragraphs(cell, visit),
5653            SdtContent::ContentControl(control) => visit_control_paragraphs(control, visit),
5654            SdtContent::Run(_) | SdtContent::RawXml(_) => {}
5655        }
5656    }
5657}
5658
5659fn bookmark_text(input: &LayoutInput, name: &str) -> Option<String> {
5660    type BodyRunPosition = (usize, usize, RawOrder);
5661    type BookmarkStart<'a> = (Option<&'a str>, BodyRunPosition);
5662
5663    let mut starts: HashMap<i32, Vec<BookmarkStart<'_>>> = HashMap::new();
5664    let mut ends: HashMap<i32, Vec<BodyRunPosition>> = HashMap::new();
5665    for (body_index, content) in input.document.body.content.iter().enumerate() {
5666        let BodyContent::Paragraph(paragraph) = content else {
5667            continue;
5668        };
5669        for marker in &paragraph.bookmark_markers {
5670            let Some(id) = marker.id() else {
5671                continue;
5672            };
5673            if marker.run_index() > paragraph.runs.len() {
5674                return None;
5675            }
5676            let position = (
5677                body_index,
5678                marker.run_index(),
5679                RawOrder::Raw(marker.raw_before()),
5680            );
5681            if marker.is_start() {
5682                starts
5683                    .entry(id)
5684                    .or_default()
5685                    .push((marker.name(), position));
5686            } else {
5687                ends.entry(id).or_default().push(position);
5688            }
5689        }
5690    }
5691    let candidates = starts
5692        .iter()
5693        .filter_map(|(id, starts)| {
5694            let ends = ends.get(id)?;
5695            (starts.len() == 1 && starts[0].0 == Some(name) && ends.len() == 1)
5696                .then_some((starts[0].1, ends[0]))
5697        })
5698        .collect::<Vec<_>>();
5699    if candidates.len() != 1 {
5700        return None;
5701    }
5702    let (start, end) = candidates[0];
5703    if start > end {
5704        return None;
5705    }
5706    let mut parts = Vec::new();
5707    for body_index in start.0..=end.0 {
5708        let BodyContent::Paragraph(paragraph) = &input.document.body.content[body_index] else {
5709            continue;
5710        };
5711        parts.push(
5712            project_paragraph_runs(paragraph, input.revision_view)
5713                .iter()
5714                .filter(|projected| {
5715                    let position = (body_index, projected.boundary, projected.raw_order);
5716                    position >= start && position < end
5717                })
5718                .map(|projected| projected.run.text())
5719                .collect::<String>(),
5720        );
5721    }
5722    Some(parts.join("\n"))
5723}
5724
5725/// Whether any drawing in the document body wraps text around itself.
5726///
5727/// A document without one can never reach the reflow path, so it does not pay
5728/// for it.
5729fn document_has_wrapping_drawing(input: &LayoutInput) -> bool {
5730    fn paragraph_wraps(para: &CT_P, view: RevisionView) -> bool {
5731        fn run_wraps(run: &CT_R) -> bool {
5732            run.content
5733                .iter()
5734                .filter_map(|rc| match rc {
5735                    RunContent::Drawing(d) => Some(d),
5736                    _ => None,
5737                })
5738                .chain(run.alt_drawings.iter())
5739                .any(|drawing| {
5740                    drawing
5741                        .anchor
5742                        .as_ref()
5743                        .is_some_and(|anchor| anchor.wrap != WrapType::None)
5744                })
5745        }
5746
5747        if para.revisions.is_empty() && para.content_controls.is_empty() {
5748            para.runs.iter().any(run_wraps)
5749        } else {
5750            project_paragraph_runs(para, view)
5751                .iter()
5752                .any(|projected| run_wraps(projected.run))
5753        }
5754    }
5755
5756    input
5757        .document
5758        .body
5759        .content
5760        .iter()
5761        .any(|content| match content {
5762            BodyContent::Paragraph(para) => paragraph_wraps(para, input.revision_view),
5763            BodyContent::Table(table) => table
5764                .rows
5765                .iter()
5766                .flat_map(|row| row.cells.iter())
5767                .flat_map(|cell| cell.content.iter())
5768                .any(|content| match content {
5769                    rdocx_oxml::table::CellContent::Paragraph(para) => {
5770                        paragraph_wraps(para, input.revision_view)
5771                    }
5772                    // A drawing inside a nested table is rare enough that the
5773                    // conservative answer is to look no deeper.
5774                    rdocx_oxml::table::CellContent::Table(_) => false,
5775                    rdocx_oxml::table::CellContent::ContentControl(_) => false,
5776                }),
5777            _ => false,
5778        })
5779}
5780
5781fn render_word_chart(
5782    relationship_id: &str,
5783    width: f64,
5784    height: f64,
5785    input: &LayoutInput,
5786    fm: &mut FontManager,
5787    diagnostics: &mut Vec<Diagnostic>,
5788) -> Result<GroupElement> {
5789    let bounds = Rect {
5790        x: 0.0,
5791        y: 0.0,
5792        width,
5793        height,
5794    };
5795    let rendered = match input.charts.get(relationship_id) {
5796        Some(Ok(chart)) => oxml_chart::render_chart(
5797            &chart.chart,
5798            bounds,
5799            &input.chart_theme,
5800            &input.chart_color_map,
5801            fm,
5802        )
5803        .map_err(|error| error.to_string()),
5804        Some(Err(message)) => Err(message.clone()),
5805        None => Err("relationship was not resolved from the document part".to_owned()),
5806    };
5807    match rendered {
5808        Ok(group) => Ok(group),
5809        Err(detail) => {
5810            diagnostics.push(Diagnostic {
5811                message: format!("Word chart relationship {relationship_id}: {detail}"),
5812            });
5813            oxml_chart::render_chart_placeholder(bounds, fm)
5814                .map_err(|error| oxml_layout::LayoutError::Layout(error.to_string()))
5815        }
5816    }
5817}
5818
5819/// Collect the floating drawings anchored to a paragraph.
5820///
5821/// The offsets stay paired with the frame they are measured from. Resolving
5822/// them here is not possible: a paragraph-relative offset needs the laid-out
5823/// position of the paragraph, which only the paginator knows.
5824///
5825/// A shape's text box is laid out here rather than later, because breaking it
5826/// into lines needs the font manager.
5827fn collect_anchored_drawings(
5828    para: &CT_P,
5829    styles: &CT_Styles,
5830    input: &LayoutInput,
5831    media: &MediaRegistry,
5832    fm: &mut FontManager,
5833    num_state: &mut NumberingState,
5834    diagnostics: &mut Vec<Diagnostic>,
5835) -> Result<Vec<block::AnchoredDrawing>> {
5836    let mut out = Vec::new();
5837
5838    // Drawings written plainly, and drawings recovered from an
5839    // mc:AlternateContent block, are both anchored the same way.
5840    for projected in project_paragraph_runs(para, input.revision_view) {
5841        let run = projected.run;
5842        let plain = run.content.iter().filter_map(|rc| match rc {
5843            RunContent::Drawing(d) => Some(d),
5844            _ => None,
5845        });
5846        for drawing in plain.chain(run.alt_drawings.iter()) {
5847            let Some(anchor) = drawing.anchor.as_ref() else {
5848                continue;
5849            };
5850
5851            // A picture also carries a pic:spPr, so a parsed shape alone does
5852            // not mean this is a shape. An embed id is what makes it a
5853            // picture, and that takes precedence.
5854            let shape = if anchor.embed_id.is_empty() && anchor.chart_rel_id.is_none() {
5855                anchor.shape.as_ref()
5856            } else {
5857                None
5858            };
5859
5860            let content = if let Some(relationship_id) = anchor.chart_rel_id.as_deref() {
5861                block::AnchoredContent::Group(render_word_chart(
5862                    relationship_id,
5863                    anchor.extent_cx.to_pt(),
5864                    anchor.extent_cy.to_pt(),
5865                    input,
5866                    fm,
5867                    diagnostics,
5868                )?)
5869            } else {
5870                match shape {
5871                    Some(shape) => {
5872                        // A shape's text box wraps at the shape width.
5873                        let mut text = Vec::new();
5874                        for p in &shape.text {
5875                            text.push(layout_paragraph(
5876                                p,
5877                                anchor.extent_cx.to_pt(),
5878                                styles,
5879                                input,
5880                                media,
5881                                fm,
5882                                num_state,
5883                                diagnostics,
5884                            )?);
5885                        }
5886                        block::AnchoredContent::Shape {
5887                            preset: block::ShapePreset::from_prst(shape.preset.as_deref()),
5888                            fill: shape.solid_fill.as_deref().map(Color::from_hex),
5889                            text,
5890                        }
5891                    }
5892                    None if anchor.embed_id.is_empty() => continue,
5893                    None => block::AnchoredContent::Image {
5894                        media_id: media.id_for_relationship(&anchor.embed_id),
5895                    },
5896                }
5897            };
5898
5899            out.push(block::AnchoredDrawing {
5900                behind_doc: anchor.behind_doc,
5901                rel_h: anchor.pos_h_relative_from,
5902                off_h: anchor.pos_h_offset.to_pt(),
5903                rel_v: anchor.pos_v_relative_from,
5904                off_v: anchor.pos_v_offset.to_pt(),
5905                width: anchor.extent_cx.to_pt(),
5906                height: anchor.extent_cy.to_pt(),
5907                alternate_text: anchor.description.clone(),
5908                structure_id: None,
5909                wrap: anchor.wrap,
5910                dist_top: anchor.dist_t.to_pt(),
5911                dist_bottom: anchor.dist_b.to_pt(),
5912                dist_left: anchor.dist_l.to_pt(),
5913                dist_right: anchor.dist_r.to_pt(),
5914                align_h: anchor.pos_h_align,
5915                align_v: anchor.pos_v_align,
5916                content,
5917            });
5918        }
5919    }
5920    Ok(out)
5921}
5922
5923/// Merge direct paragraph properties (only fields explicitly set in the XML).
5924fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
5925    // Don't merge style_id — that was already used for resolution
5926    if direct.jc.is_some() {
5927        effective.jc = direct.jc;
5928    }
5929    if direct.space_before.is_some() {
5930        effective.space_before = direct.space_before;
5931    }
5932    if direct.space_after.is_some() {
5933        effective.space_after = direct.space_after;
5934    }
5935    if direct.line_spacing.is_some() {
5936        effective.line_spacing = direct.line_spacing;
5937    }
5938    if direct.line_rule.is_some() {
5939        effective.line_rule = direct.line_rule.clone();
5940    }
5941    if direct.ind_left.is_some() {
5942        effective.ind_left = direct.ind_left;
5943    }
5944    if direct.ind_right.is_some() {
5945        effective.ind_right = direct.ind_right;
5946    }
5947    if direct.ind_start.is_some() {
5948        effective.ind_start = direct.ind_start;
5949    }
5950    if direct.ind_end.is_some() {
5951        effective.ind_end = direct.ind_end;
5952    }
5953    if direct.ind_first_line.is_some() {
5954        effective.ind_first_line = direct.ind_first_line;
5955    }
5956    if direct.ind_hanging.is_some() {
5957        effective.ind_hanging = direct.ind_hanging;
5958    }
5959    if direct.keep_next.is_some() {
5960        effective.keep_next = direct.keep_next;
5961    }
5962    if direct.keep_lines.is_some() {
5963        effective.keep_lines = direct.keep_lines;
5964    }
5965    if direct.page_break_before.is_some() {
5966        effective.page_break_before = direct.page_break_before;
5967    }
5968    if direct.widow_control.is_some() {
5969        effective.widow_control = direct.widow_control;
5970    }
5971    if direct.suppress_auto_hyphens.is_some() {
5972        effective.suppress_auto_hyphens = direct.suppress_auto_hyphens;
5973    }
5974    if direct.bidi.is_some() {
5975        effective.bidi = direct.bidi;
5976    }
5977    if direct.borders.is_some() {
5978        effective.borders = direct.borders.clone();
5979    }
5980    if direct.tabs.is_some() {
5981        effective.tabs = direct.tabs.clone();
5982    }
5983    if direct.shading.is_some() {
5984        effective.shading = direct.shading.clone();
5985    }
5986    if direct.num_id.is_some() {
5987        effective.num_id = direct.num_id;
5988    }
5989    if direct.num_ilvl.is_some() {
5990        effective.num_ilvl = direct.num_ilvl;
5991    }
5992}
5993
5994/// Convert section properties to page geometry.
5995fn sect_pr_to_geometry(sect_pr: &CT_SectPr) -> PageGeometry {
5996    PageGeometry {
5997        page_width: sect_pr.page_width.map(|t| t.to_pt()).unwrap_or(612.0),
5998        page_height: sect_pr.page_height.map(|t| t.to_pt()).unwrap_or(792.0),
5999        margin_top: sect_pr.margin_top.map(|t| t.to_pt()).unwrap_or(72.0),
6000        margin_right: sect_pr.margin_right.map(|t| t.to_pt()).unwrap_or(72.0),
6001        margin_bottom: sect_pr.margin_bottom.map(|t| t.to_pt()).unwrap_or(72.0),
6002        margin_left: sect_pr.margin_left.map(|t| t.to_pt()).unwrap_or(72.0),
6003        header_distance: sect_pr.header_distance.map(|t| t.to_pt()).unwrap_or(36.0),
6004        footer_distance: sect_pr.footer_distance.map(|t| t.to_pt()).unwrap_or(36.0),
6005    }
6006}
6007
6008fn section_page_number_start(sect_pr: &CT_SectPr) -> Option<usize> {
6009    for raw in &sect_pr.extra_xml {
6010        let Some((name, raw_attributes)) = raw_root_start_tag(raw) else {
6011            continue;
6012        };
6013        let Some(attributes) = parse_raw_attributes(raw_attributes) else {
6014            continue;
6015        };
6016        if xml_local_name(name) != b"pgNumType"
6017            || !raw_name_has_namespace(name, &attributes, rdocx_oxml::namespace::W_NS, false)
6018        {
6019            continue;
6020        }
6021        let (_, value) = attributes.iter().find(|(attribute_name, _)| {
6022            xml_local_name(attribute_name) == b"start"
6023                && raw_name_has_namespace(
6024                    attribute_name,
6025                    &attributes,
6026                    rdocx_oxml::namespace::W_NS,
6027                    true,
6028                )
6029        })?;
6030        return decode_xml_attribute(value)?.parse().ok();
6031    }
6032    None
6033}
6034
6035fn xml_local_name(name: &[u8]) -> &[u8] {
6036    name.rsplit(|byte| *byte == b':').next().unwrap_or(name)
6037}
6038
6039fn raw_root_start_tag(raw: &[u8]) -> Option<(&[u8], &[u8])> {
6040    let mut cursor = 0usize;
6041    while cursor < raw.len() && raw[cursor].is_ascii_whitespace() {
6042        cursor += 1;
6043    }
6044    if raw.get(cursor) != Some(&b'<') {
6045        return None;
6046    }
6047    cursor += 1;
6048    if matches!(raw.get(cursor), Some(b'!' | b'?' | b'/')) {
6049        return None;
6050    }
6051    let name_start = cursor;
6052    while cursor < raw.len()
6053        && !raw[cursor].is_ascii_whitespace()
6054        && !matches!(raw[cursor], b'>' | b'/')
6055    {
6056        cursor += 1;
6057    }
6058    if cursor == name_start {
6059        return None;
6060    }
6061    let name_end = cursor;
6062    let attributes_start = cursor;
6063    let mut quote = None;
6064    while cursor < raw.len() {
6065        match (quote, raw[cursor]) {
6066            (None, b'\'' | b'"') => quote = Some(raw[cursor]),
6067            (Some(expected), found) if expected == found => quote = None,
6068            (None, b'>') => {
6069                return Some((&raw[name_start..name_end], &raw[attributes_start..cursor]));
6070            }
6071            _ => {}
6072        }
6073        cursor += 1;
6074    }
6075    None
6076}
6077
6078fn parse_raw_attributes(attributes: &[u8]) -> Option<Vec<(&[u8], &[u8])>> {
6079    let mut parsed = Vec::new();
6080    let mut cursor = 0usize;
6081    while cursor < attributes.len() {
6082        while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
6083            cursor += 1;
6084        }
6085        if cursor == attributes.len() || attributes[cursor] == b'/' {
6086            break;
6087        }
6088        let name_start = cursor;
6089        while cursor < attributes.len()
6090            && !attributes[cursor].is_ascii_whitespace()
6091            && attributes[cursor] != b'='
6092        {
6093            cursor += 1;
6094        }
6095        let name_end = cursor;
6096        while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
6097            cursor += 1;
6098        }
6099        if attributes.get(cursor) != Some(&b'=') {
6100            cursor = cursor.saturating_add(1);
6101            continue;
6102        }
6103        cursor += 1;
6104        while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
6105            cursor += 1;
6106        }
6107        let quote = *attributes.get(cursor)?;
6108        if !matches!(quote, b'\'' | b'"') {
6109            return None;
6110        }
6111        cursor += 1;
6112        let value_start = cursor;
6113        while cursor < attributes.len() && attributes[cursor] != quote {
6114            cursor += 1;
6115        }
6116        let value_end = cursor;
6117        cursor += 1;
6118        parsed.push((
6119            &attributes[name_start..name_end],
6120            &attributes[value_start..value_end],
6121        ));
6122    }
6123    Some(parsed)
6124}
6125
6126fn raw_name_has_namespace(
6127    name: &[u8],
6128    attributes: &[(&[u8], &[u8])],
6129    expected: &str,
6130    is_attribute: bool,
6131) -> bool {
6132    let prefix = name
6133        .iter()
6134        .rposition(|byte| *byte == b':')
6135        .map(|separator| &name[..separator]);
6136    let namespace = match prefix {
6137        Some(prefix) => attributes.iter().find_map(|(attribute_name, value)| {
6138            attribute_name
6139                .strip_prefix(b"xmlns:")
6140                .is_some_and(|declared| declared == prefix)
6141                .then_some(*value)
6142        }),
6143        None if !is_attribute => attributes
6144            .iter()
6145            .find_map(|(attribute_name, value)| (*attribute_name == b"xmlns").then_some(*value)),
6146        None => None,
6147    };
6148    match namespace {
6149        Some(namespace) => decode_xml_attribute(namespace).is_some_and(|value| value == expected),
6150        None => prefix == Some(b"w".as_slice()) && expected == rdocx_oxml::namespace::W_NS,
6151    }
6152}
6153
6154fn decode_xml_attribute(value: &[u8]) -> Option<String> {
6155    let value = std::str::from_utf8(value).ok()?;
6156    let mut decoded = String::with_capacity(value.len());
6157    let mut cursor = 0usize;
6158    while let Some(relative_start) = value[cursor..].find('&') {
6159        let entity_start = cursor + relative_start;
6160        decoded.push_str(&value[cursor..entity_start]);
6161        let entity_end = entity_start + value[entity_start..].find(';')?;
6162        let entity = &value[entity_start + 1..entity_end];
6163        match entity {
6164            "amp" => decoded.push('&'),
6165            "apos" => decoded.push('\''),
6166            "gt" => decoded.push('>'),
6167            "lt" => decoded.push('<'),
6168            "quot" => decoded.push('"'),
6169            numeric if numeric.starts_with("#x") => {
6170                decoded.push(char::from_u32(
6171                    u32::from_str_radix(&numeric[2..], 16).ok()?,
6172                )?);
6173            }
6174            numeric if numeric.starts_with('#') => {
6175                decoded.push(char::from_u32(numeric[1..].parse().ok()?)?);
6176            }
6177            _ => return None,
6178        }
6179        cursor = entity_end + 1;
6180    }
6181    decoded.push_str(&value[cursor..]);
6182    Some(decoded)
6183}
6184
6185/// Lay out header and footer content (both Default and First-page).
6186fn layout_header_footer(
6187    engine: &mut Engine,
6188    sect_pr: &CT_SectPr,
6189    input: &LayoutInput,
6190    styles: &CT_Styles,
6191    media: &MediaRegistry,
6192    num_state: &mut NumberingState,
6193    diagnostics: &mut Vec<Diagnostic>,
6194    sources: Option<&SourceRegistry>,
6195) -> Result<Option<(HeaderFooterContent, HeaderFooterSemantics)>> {
6196    let mut has_content = false;
6197    let mut header_blocks = Vec::new();
6198    let mut footer_blocks = Vec::new();
6199    let mut first_header_blocks = Vec::new();
6200    let mut first_footer_blocks = Vec::new();
6201    let mut even_header_blocks = Vec::new();
6202    let mut even_footer_blocks = Vec::new();
6203    let mut header_directions = Vec::new();
6204    let mut footer_directions = Vec::new();
6205    let mut first_header_directions = Vec::new();
6206    let mut first_footer_directions = Vec::new();
6207    let mut even_header_directions = Vec::new();
6208    let mut even_footer_directions = Vec::new();
6209    let mut watermark = None;
6210    let mut first_watermark = None;
6211    let mut even_watermark = None;
6212    let even_headers_active = sect_pr
6213        .header_refs
6214        .iter()
6215        .any(|reference| reference.hdr_ftr_type == HdrFtrType::Even);
6216
6217    let geometry = sect_pr_to_geometry(sect_pr);
6218    let width = geometry.content_width();
6219
6220    for href in &sect_pr.header_refs {
6221        let (target_blocks, target_directions, target_watermark) = match href.hdr_ftr_type {
6222            HdrFtrType::Default => (&mut header_blocks, &mut header_directions, &mut watermark),
6223            HdrFtrType::First => (
6224                &mut first_header_blocks,
6225                &mut first_header_directions,
6226                &mut first_watermark,
6227            ),
6228            HdrFtrType::Even => (
6229                &mut even_header_blocks,
6230                &mut even_header_directions,
6231                &mut even_watermark,
6232            ),
6233        };
6234        if let Some(hdr) = input.headers.get(&href.rel_id) {
6235            let content = layout_header_footer_variant(
6236                engine,
6237                HeaderFooterStoryKind::Header,
6238                href.hdr_ftr_type,
6239                sect_pr,
6240                &href.rel_id,
6241                hdr,
6242                input,
6243                styles,
6244                media,
6245                num_state,
6246                diagnostics,
6247                sources,
6248                width,
6249                geometry,
6250            )?;
6251            target_blocks.extend(content.blocks);
6252            target_directions.extend(content.directions);
6253            if target_watermark.is_none() {
6254                *target_watermark = content.watermark;
6255            }
6256            has_content = true;
6257        }
6258    }
6259
6260    for fref in &sect_pr.footer_refs {
6261        let (target_blocks, target_directions) = match fref.hdr_ftr_type {
6262            HdrFtrType::Default => (&mut footer_blocks, &mut footer_directions),
6263            HdrFtrType::First => (&mut first_footer_blocks, &mut first_footer_directions),
6264            HdrFtrType::Even => (&mut even_footer_blocks, &mut even_footer_directions),
6265        };
6266        if let Some(ftr) = input.footers.get(&fref.rel_id) {
6267            let content = layout_header_footer_variant(
6268                engine,
6269                HeaderFooterStoryKind::Footer,
6270                fref.hdr_ftr_type,
6271                sect_pr,
6272                &fref.rel_id,
6273                ftr,
6274                input,
6275                styles,
6276                media,
6277                num_state,
6278                diagnostics,
6279                sources,
6280                width,
6281                geometry,
6282            )?;
6283            target_blocks.extend(content.blocks);
6284            target_directions.extend(content.directions);
6285            has_content = true;
6286        }
6287    }
6288
6289    if has_content {
6290        Ok(Some((
6291            HeaderFooterContent {
6292                header_blocks,
6293                footer_blocks,
6294                first_header_blocks,
6295                first_footer_blocks,
6296                even_header_blocks,
6297                even_footer_blocks,
6298                even_headers_active,
6299                watermark,
6300                first_watermark,
6301                even_watermark,
6302            },
6303            HeaderFooterSemantics {
6304                header_directions,
6305                footer_directions,
6306                first_header_directions,
6307                first_footer_directions,
6308                even_header_directions,
6309                even_footer_directions,
6310            },
6311        )))
6312    } else {
6313        Ok(None)
6314    }
6315}
6316
6317#[allow(clippy::too_many_arguments)]
6318fn layout_header_footer_variant(
6319    engine: &mut Engine,
6320    story_kind: HeaderFooterStoryKind,
6321    variant: HdrFtrType,
6322    sect_pr: &CT_SectPr,
6323    relationship_id: &str,
6324    part: &rdocx_oxml::header_footer::CT_HdrFtr,
6325    input: &LayoutInput,
6326    styles: &CT_Styles,
6327    media: &MediaRegistry,
6328    num_state: &mut NumberingState,
6329    diagnostics: &mut Vec<Diagnostic>,
6330    sources: Option<&SourceRegistry>,
6331    width: f64,
6332    geometry: PageGeometry,
6333) -> Result<HeaderFooterVariantContent> {
6334    let cache_safe = header_footer_section_is_cache_safe(sect_pr)
6335        && header_footer_part_is_cache_safe(part, styles);
6336    let resolved_part_bytes = match story_kind {
6337        HeaderFooterStoryKind::Header => part.to_xml_header(),
6338        HeaderFooterStoryKind::Footer => part.to_xml_footer(),
6339    };
6340    if !cache_safe || resolved_part_bytes.is_err() {
6341        return layout_header_footer_variant_uncached(
6342            story_kind,
6343            relationship_id,
6344            part,
6345            input,
6346            styles,
6347            media,
6348            &mut engine.font_manager,
6349            num_state,
6350            diagnostics,
6351            sources,
6352            false,
6353            width,
6354            geometry,
6355        );
6356    }
6357
6358    let key = HeaderFooterCacheKey {
6359        story: story_kind,
6360        variant,
6361        section: sect_pr.clone(),
6362        relationship_id: relationship_id.to_owned(),
6363        part: part.clone(),
6364        resolved_part_bytes: resolved_part_bytes.expect("checked resolved part bytes"),
6365        with_provenance: sources.is_some(),
6366    };
6367    let hit = engine
6368        .header_footer_cache_reads_enabled
6369        .then(|| {
6370            engine
6371                .header_footer_cache
6372                .iter()
6373                .find(|entry| entry.key == key)
6374                .map(|entry| {
6375                    (
6376                        entry.content.clone(),
6377                        entry.diagnostics.clone(),
6378                        entry.font_trace.clone(),
6379                    )
6380                })
6381        })
6382        .flatten();
6383    if let Some((mut content, cached_diagnostics, font_trace)) = hit {
6384        rebind_header_footer_sources(
6385            story_kind,
6386            relationship_id,
6387            part,
6388            &mut content.blocks,
6389            sources,
6390        )?;
6391        diagnostics.extend(cached_diagnostics);
6392        engine.font_manager.replay_layout_font_trace(&font_trace);
6393        engine.header_footer_cache_hits += 1;
6394        return Ok(content);
6395    }
6396
6397    let diagnostics_start = diagnostics.len();
6398    engine.font_manager.begin_paragraph_font_trace();
6399    let content_result = layout_header_footer_variant_uncached(
6400        story_kind,
6401        relationship_id,
6402        part,
6403        input,
6404        styles,
6405        media,
6406        &mut engine.font_manager,
6407        num_state,
6408        diagnostics,
6409        None,
6410        true,
6411        width,
6412        geometry,
6413    );
6414    let font_trace = engine.font_manager.finish_paragraph_font_trace();
6415    let mut content = content_result?;
6416    engine.header_footer_cache_builds += 1;
6417    let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
6418    if let Some(font_trace) = font_trace {
6419        let bytes =
6420            header_footer_cache_entry_bytes(&key, &content, &cached_diagnostics, &font_trace);
6421        engine.stage_header_footer_cache_entry(HeaderFooterCacheEntry {
6422            key,
6423            content: content.clone(),
6424            diagnostics: cached_diagnostics,
6425            font_trace,
6426            bytes,
6427        });
6428    }
6429    rebind_header_footer_sources(
6430        story_kind,
6431        relationship_id,
6432        part,
6433        &mut content.blocks,
6434        sources,
6435    )?;
6436    Ok(content)
6437}
6438
6439#[allow(clippy::too_many_arguments)]
6440fn layout_header_footer_variant_uncached(
6441    story_kind: HeaderFooterStoryKind,
6442    relationship_id: &str,
6443    part: &rdocx_oxml::header_footer::CT_HdrFtr,
6444    input: &LayoutInput,
6445    styles: &CT_Styles,
6446    media: &MediaRegistry,
6447    fm: &mut FontManager,
6448    num_state: &mut NumberingState,
6449    diagnostics: &mut Vec<Diagnostic>,
6450    sources: Option<&SourceRegistry>,
6451    cache_source: bool,
6452    width: f64,
6453    geometry: PageGeometry,
6454) -> Result<HeaderFooterVariantContent> {
6455    let watermark = if story_kind == HeaderFooterStoryKind::Header {
6456        match part.watermarks().first() {
6457            Some(projected) => layout_watermark(
6458                projected,
6459                relationship_id,
6460                input,
6461                media,
6462                fm,
6463                geometry,
6464                diagnostics,
6465            )?,
6466            None => None,
6467        }
6468    } else {
6469        None
6470    };
6471    let story = match story_kind {
6472        HeaderFooterStoryKind::Header => WordStory::Header {
6473            relationship_id: relationship_id.to_owned(),
6474        },
6475        HeaderFooterStoryKind::Footer => WordStory::Footer {
6476            relationship_id: relationship_id.to_owned(),
6477        },
6478    };
6479    let mut blocks = Vec::with_capacity(part.paragraphs.len());
6480    let mut directions = Vec::with_capacity(part.paragraphs.len());
6481    for (paragraph_index, paragraph) in part.paragraphs.iter().enumerate() {
6482        let source = if cache_source {
6483            Some(CACHE_SOURCE_NODE)
6484        } else {
6485            sources.and_then(|sources| sources.id(&story, &[paragraph_index]))
6486        };
6487        let (block, direction) = layout_paragraph_with_source_and_direction(
6488            paragraph,
6489            width,
6490            styles,
6491            input,
6492            media,
6493            fm,
6494            num_state,
6495            diagnostics,
6496            source,
6497        )?;
6498        blocks.push(block);
6499        directions.push(direction);
6500    }
6501    Ok(HeaderFooterVariantContent {
6502        blocks,
6503        directions,
6504        watermark,
6505    })
6506}
6507
6508fn layout_watermark(
6509    watermark: &VmlWatermark,
6510    header_relationship_id: &str,
6511    input: &LayoutInput,
6512    media: &MediaRegistry,
6513    fm: &mut FontManager,
6514    geometry: PageGeometry,
6515    diagnostics: &mut Vec<Diagnostic>,
6516) -> Result<Option<GroupElement>> {
6517    let (width, height, rotation, opacity) = match watermark {
6518        VmlWatermark::Text {
6519            width_pt,
6520            height_pt,
6521            rotation_degrees,
6522            opacity,
6523            ..
6524        }
6525        | VmlWatermark::Image {
6526            width_pt,
6527            height_pt,
6528            rotation_degrees,
6529            opacity,
6530            ..
6531        } => (*width_pt, *height_pt, *rotation_degrees, *opacity),
6532    };
6533    let translate = Transform {
6534        e: geometry.margin_left + (geometry.content_width() - width) / 2.0,
6535        f: geometry.margin_top + (geometry.content_height() - height) / 2.0,
6536        ..Transform::IDENTITY
6537    };
6538    let transform = Transform::rotate_about(rotation, width / 2.0, height / 2.0).then(translate);
6539    let children = match watermark {
6540        VmlWatermark::Text {
6541            text,
6542            color,
6543            font_family,
6544            ..
6545        } => {
6546            let Some(color) = vml_color(color) else {
6547                diagnostics.push(Diagnostic {
6548                    message: format!("VML watermark colour {color:?} is unsupported"),
6549                });
6550                return Ok(None);
6551            };
6552            let estimated = width / (text.chars().count().max(1) as f64 * 0.62);
6553            let font_size = (height * 0.62).min(estimated).max(1.0);
6554            let font_id = fm.resolve_font_for_text(
6555                font_family.as_deref().or(Some("Calibri")),
6556                false,
6557                false,
6558                text,
6559            )?;
6560            let shaped = fm.shape_text(font_id, text, font_size)?;
6561            let metrics = fm.metrics(font_id, font_size)?;
6562            vec![PositionedElement::Text(GlyphRun {
6563                origin: Point {
6564                    x: (width - shaped.width) / 2.0,
6565                    y: (height + metrics.ascent - metrics.descent) / 2.0,
6566                },
6567                font_id,
6568                font_size,
6569                glyph_ids: shaped.glyph_ids,
6570                advances: shaped.advances,
6571                text: text.clone(),
6572                source: None,
6573                color,
6574                bold: false,
6575                italic: false,
6576                field_kind: None,
6577                note: None,
6578            })]
6579        }
6580        VmlWatermark::Image {
6581            relationship_id, ..
6582        } => {
6583            let scoped_id = format!("{header_relationship_id}\0{relationship_id}");
6584            let Some(image) = input.images.get(&scoped_id) else {
6585                diagnostics.push(Diagnostic {
6586                    message: format!(
6587                        "VML watermark image relationship {relationship_id} in header {header_relationship_id} was not resolved"
6588                    ),
6589                });
6590                return Ok(None);
6591            };
6592            let data = image.data.clone();
6593            vec![PositionedElement::Image {
6594                rect: Rect {
6595                    x: 0.0,
6596                    y: 0.0,
6597                    width,
6598                    height,
6599                },
6600                content_type: image.content_type.clone(),
6601                media_id: media.id_for_relationship(&scoped_id),
6602                data,
6603            }]
6604        }
6605    };
6606    Ok(Some(GroupElement {
6607        transform,
6608        clip: None,
6609        opacity,
6610        effects: Vec::new(),
6611        children,
6612    }))
6613}
6614
6615fn vml_color(value: &str) -> Option<Color> {
6616    let normalized = value.trim().to_ascii_lowercase();
6617    let hex = match normalized.as_str() {
6618        "black" => "000000",
6619        "silver" => "c0c0c0",
6620        "gray" | "grey" => "808080",
6621        "white" => "ffffff",
6622        "maroon" => "800000",
6623        "red" => "ff0000",
6624        "purple" => "800080",
6625        "fuchsia" | "magenta" => "ff00ff",
6626        "green" => "008000",
6627        "lime" => "00ff00",
6628        "olive" => "808000",
6629        "yellow" => "ffff00",
6630        "navy" => "000080",
6631        "blue" => "0000ff",
6632        "teal" => "008080",
6633        "aqua" | "cyan" => "00ffff",
6634        _ => normalized.trim_start_matches('#'),
6635    };
6636    (hex.len() == 6 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
6637        .then(|| Color::from_hex(hex))
6638}
6639
6640/// Resolve the effective font family for a run, considering theme fonts.
6641///
6642/// Priority: explicit font_ascii > theme font > None (use default).
6643fn resolve_font_family(
6644    rpr: &rdocx_oxml::properties::CT_RPr,
6645    theme: Option<&rdocx_oxml::theme::Theme>,
6646) -> Option<String> {
6647    // Explicit font name takes priority
6648    if rpr.font_ascii.is_some() {
6649        return rpr.font_ascii.clone();
6650    }
6651
6652    // Resolve theme font reference
6653    if let (Some(theme_ref), Some(theme)) = (&rpr.font_ascii_theme, theme) {
6654        let font = match theme_ref.as_str() {
6655            "majorAscii" | "majorHAnsi" | "majorBidi" | "majorEastAsia" => {
6656                theme.major_font.as_deref()
6657            }
6658            "minorAscii" | "minorHAnsi" | "minorBidi" | "minorEastAsia" => {
6659                theme.minor_font.as_deref()
6660            }
6661            _ => None,
6662        };
6663        if let Some(f) = font {
6664            return Some(f.to_string());
6665        }
6666    }
6667
6668    None
6669}
6670
6671/// Resolve the effective color for a run, considering theme colors.
6672///
6673/// Priority: literal color (non-auto) > theme color > black.
6674fn resolve_run_color(
6675    rpr: &rdocx_oxml::properties::CT_RPr,
6676    theme: Option<&rdocx_oxml::theme::Theme>,
6677) -> Color {
6678    // If theme color is specified, resolve it from the theme
6679    if let Some(ref theme_name) = rpr.color_theme
6680        && let Some(theme) = theme
6681        && let Some(hex) = theme.colors.get(theme_name)
6682    {
6683        return Color::from_hex(hex);
6684    }
6685
6686    // Fall back to literal color value
6687    rpr.color
6688        .as_ref()
6689        .filter(|c| c.as_str() != "auto")
6690        .map(|c| Color::from_hex(c))
6691        .unwrap_or(Color::BLACK)
6692}
6693
6694#[derive(Clone, Copy, PartialEq, Eq)]
6695enum WordLanguageSlot {
6696    Direct,
6697    EastAsia,
6698    Bidi,
6699}
6700
6701fn word_language_slot(character: char) -> Option<WordLanguageSlot> {
6702    match character as u32 {
6703        0x0590..=0x08ff | 0xfb1d..=0xfdff | 0xfe70..=0xfeff => Some(WordLanguageSlot::Bidi),
6704        0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff => Some(WordLanguageSlot::EastAsia),
6705        0x0041..=0x024f | 0x0900..=0x097f | 0x0e00..=0x0e7f | 0x1e00..=0x1eff => {
6706            Some(WordLanguageSlot::Direct)
6707        }
6708        _ => None,
6709    }
6710}
6711
6712fn word_language_for_slot(style: &WordMultilingualStyle, slot: WordLanguageSlot) -> Option<String> {
6713    match slot {
6714        WordLanguageSlot::Direct => style.language.clone(),
6715        WordLanguageSlot::EastAsia => style
6716            .language_east_asia
6717            .clone()
6718            .or_else(|| style.language.clone()),
6719        WordLanguageSlot::Bidi => style
6720            .language_bidi
6721            .clone()
6722            .or_else(|| style.language.clone()),
6723    }
6724}
6725
6726fn word_text_direction(rtl: Option<bool>) -> TextDirection {
6727    match rtl {
6728        Some(true) => TextDirection::RightToLeft,
6729        Some(false) => TextDirection::LeftToRight,
6730        None => TextDirection::Auto,
6731    }
6732}
6733
6734fn word_language_ranges(text: &str) -> Vec<(usize, usize, WordLanguageSlot)> {
6735    let mut ranges = Vec::new();
6736    let mut start = 0usize;
6737    let mut slot = WordLanguageSlot::Direct;
6738    for (offset, character) in text.char_indices() {
6739        let Some(next_slot) = word_language_slot(character) else {
6740            continue;
6741        };
6742        if next_slot != slot && offset > start {
6743            ranges.push((start, offset, slot));
6744            start = offset;
6745        }
6746        slot = next_slot;
6747    }
6748    if start < text.len() {
6749        ranges.push((start, text.len(), slot));
6750    }
6751    ranges
6752}
6753
6754fn word_multilingual_segment_slice(
6755    segment: &TextSegment,
6756    byte_start: usize,
6757    byte_end: usize,
6758) -> Result<TextSegment> {
6759    let mut slice = segment.clone();
6760    slice.text = segment.text[byte_start..byte_end].to_owned();
6761    if let Some(source) = segment.source {
6762        let char_start =
6763            u32::try_from(segment.text[..byte_start].chars().count()).map_err(|_| {
6764                oxml_layout::LayoutError::Layout(
6765                    "Word multilingual source offset exceeds the supported range".to_owned(),
6766                )
6767            })?;
6768        let char_len = u32::try_from(slice.text.chars().count()).map_err(|_| {
6769            oxml_layout::LayoutError::Layout(
6770                "Word multilingual source length exceeds the supported range".to_owned(),
6771            )
6772        })?;
6773        slice.source = Some(SourceSpan {
6774            node: source.node,
6775            char_start: source.char_start.checked_add(char_start).ok_or_else(|| {
6776                oxml_layout::LayoutError::Layout(
6777                    "Word multilingual source offset overflowed".to_owned(),
6778                )
6779            })?,
6780            char_end: source
6781                .char_start
6782                .checked_add(char_start)
6783                .and_then(|start| start.checked_add(char_len))
6784                .ok_or_else(|| {
6785                    oxml_layout::LayoutError::Layout(
6786                        "Word multilingual source range overflowed".to_owned(),
6787                    )
6788                })?,
6789        });
6790    }
6791    slice.glyph_ids.clear();
6792    slice.advances.clear();
6793    slice.width = 0.0;
6794    Ok(slice)
6795}
6796
6797fn needs_word_multilingual_layout(text: &str) -> bool {
6798    text.chars().any(|character| {
6799        matches!(
6800            character as u32,
6801            0x0590..=0x08ff
6802                | 0x0900..=0x097f
6803                | 0x0e00..=0x0e7f
6804                | 0x3000..=0x30ff
6805                | 0x3400..=0x9fff
6806                | 0xf900..=0xfaff
6807                | 0xfb1d..=0xfdff
6808                | 0xfe70..=0xfeff
6809        )
6810    })
6811}
6812
6813fn inferred_word_base_direction(items: &[InlineItem]) -> TextDirection {
6814    for character in items
6815        .iter()
6816        .filter_map(|item| match item {
6817            InlineItem::Text(segment)
6818            | InlineItem::HyphenatedText { segment, .. }
6819            | InlineItem::Marker(segment) => Some(segment.text.as_str()),
6820            _ => None,
6821        })
6822        .flat_map(str::chars)
6823    {
6824        if character == '\u{200e}' {
6825            return TextDirection::LeftToRight;
6826        }
6827        if character == '\u{200f}' {
6828            return TextDirection::RightToLeft;
6829        }
6830        if !character.is_alphabetic() {
6831            continue;
6832        }
6833        return if matches!(
6834            character as u32,
6835            0x0590..=0x08ff | 0xfb1d..=0xfdff | 0xfe70..=0xfeff
6836        ) {
6837            TextDirection::RightToLeft
6838        } else {
6839            TextDirection::LeftToRight
6840        };
6841    }
6842    TextDirection::LeftToRight
6843}
6844
6845fn multilingual_candidate(item: &InlineItem) -> Option<&TextSegment> {
6846    match item {
6847        InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. }
6848            if !segment.text.is_empty() && segment.field_kind.is_none() =>
6849        {
6850            Some(segment)
6851        }
6852        _ => None,
6853    }
6854}
6855
6856fn apply_word_multilingual_spacing(
6857    segment: oxml_layout::MultilingualTextSegment,
6858    spacing: f64,
6859    exact_word_baseline: bool,
6860) -> Result<oxml_layout::MultilingualTextSegment> {
6861    if spacing == 0.0 && !exact_word_baseline {
6862        return Ok(segment);
6863    }
6864    let mut base = segment.base().clone();
6865    let x_advances = segment
6866        .x_advances()
6867        .iter()
6868        .map(|advance| advance + spacing)
6869        .collect::<Vec<_>>();
6870    base.advances = x_advances.clone();
6871    base.width = x_advances.iter().sum();
6872    if exact_word_baseline {
6873        base.ascent = base.font_size * WORD_EXACT_LINE_BASELINE_EM;
6874    }
6875    oxml_layout::MultilingualTextSegment::new(
6876        base,
6877        segment.logical_index(),
6878        segment.language().map(str::to_owned),
6879        segment.script(),
6880        segment.direction(),
6881        segment.bidi_level(),
6882        x_advances,
6883        segment.y_advances().to_vec(),
6884        segment.x_offsets().to_vec(),
6885        segment.y_offsets().to_vec(),
6886        segment.clusters().to_vec(),
6887        segment.break_after(),
6888    )
6889}
6890
6891fn shape_word_multilingual_items(
6892    font_manager: &mut FontManager,
6893    legacy_items: Vec<InlineItem>,
6894    styles: &HashMap<usize, WordMultilingualStyle>,
6895    base_direction: TextDirection,
6896    no_wrap: bool,
6897    exact_word_baseline: bool,
6898) -> Result<Vec<InlineItem>> {
6899    let mut styled = Vec::new();
6900    for (index, item) in legacy_items.iter().enumerate() {
6901        let Some(segment) = multilingual_candidate(item) else {
6902            continue;
6903        };
6904        if let Some(style) = styles.get(&index) {
6905            for (byte_start, byte_end, slot) in word_language_ranges(&segment.text) {
6906                let mut slice = word_multilingual_segment_slice(segment, byte_start, byte_end)?;
6907                slice.direction = style.direction;
6908                styled.push((slice, word_language_for_slot(style, slot)));
6909            }
6910        } else {
6911            let language = match item {
6912                InlineItem::HyphenatedText { language, .. } => Some(language.clone()),
6913                _ => None,
6914            };
6915            styled.push((segment.clone(), language));
6916        }
6917    }
6918    let mut shaped = VecDeque::from(font_manager.shape_multilingual_paragraph(
6919        styled,
6920        base_direction,
6921        no_wrap,
6922    )?);
6923    let mut items = Vec::with_capacity(legacy_items.len());
6924    for (index, mut item) in legacy_items.into_iter().enumerate() {
6925        let Some(segment) = multilingual_candidate(&item) else {
6926            if exact_word_baseline {
6927                match &mut item {
6928                    InlineItem::Text(segment)
6929                    | InlineItem::Marker(segment)
6930                    | InlineItem::HyphenatedText { segment, .. } => {
6931                        segment.ascent = segment.font_size * WORD_EXACT_LINE_BASELINE_EM;
6932                    }
6933                    _ => {}
6934                }
6935            }
6936            items.push(item);
6937            continue;
6938        };
6939        let target_bytes = segment.text.len();
6940        let mut consumed_bytes = 0usize;
6941        while consumed_bytes < target_bytes {
6942            let span = shaped.pop_front().ok_or_else(|| {
6943                oxml_layout::LayoutError::Layout(
6944                    "multilingual Word shaping lost a styled text run".to_owned(),
6945                )
6946            })?;
6947            consumed_bytes = consumed_bytes
6948                .checked_add(span.text().len())
6949                .filter(|consumed| *consumed <= target_bytes)
6950                .ok_or_else(|| {
6951                    oxml_layout::LayoutError::Layout(
6952                        "multilingual Word shaping crossed a styled text boundary".to_owned(),
6953                    )
6954                })?;
6955            let spacing = styles.get(&index).map_or(0.0, |style| style.spacing);
6956            let span = apply_word_multilingual_spacing(span, spacing, exact_word_baseline)?;
6957            if let InlineItem::HyphenatedText { language, .. } = &item
6958                && !needs_word_multilingual_layout(span.text())
6959            {
6960                items.push(InlineItem::HyphenatedText {
6961                    segment: span.base().clone(),
6962                    language: language.clone(),
6963                });
6964            } else {
6965                items.push(InlineItem::MultilingualText(span));
6966            }
6967        }
6968    }
6969    if !shaped.is_empty() {
6970        return Err(oxml_layout::LayoutError::Layout(
6971            "multilingual Word shaping produced an unmatched text run".to_owned(),
6972        ));
6973    }
6974    Ok(items)
6975}
6976
6977/// Convert a highlight color enum to an RGBA Color.
6978fn highlight_to_color(h: ST_HighlightColor) -> Option<Color> {
6979    match h {
6980        ST_HighlightColor::None => None,
6981        ST_HighlightColor::Black => Some(Color {
6982            r: 0.0,
6983            g: 0.0,
6984            b: 0.0,
6985            a: 1.0,
6986        }),
6987        ST_HighlightColor::Blue => Some(Color {
6988            r: 0.0,
6989            g: 0.0,
6990            b: 1.0,
6991            a: 1.0,
6992        }),
6993        ST_HighlightColor::Cyan => Some(Color {
6994            r: 0.0,
6995            g: 1.0,
6996            b: 1.0,
6997            a: 1.0,
6998        }),
6999        ST_HighlightColor::DarkBlue => Some(Color {
7000            r: 0.0,
7001            g: 0.0,
7002            b: 0.545,
7003            a: 1.0,
7004        }),
7005        ST_HighlightColor::DarkCyan => Some(Color {
7006            r: 0.0,
7007            g: 0.545,
7008            b: 0.545,
7009            a: 1.0,
7010        }),
7011        ST_HighlightColor::DarkGray => Some(Color {
7012            r: 0.663,
7013            g: 0.663,
7014            b: 0.663,
7015            a: 1.0,
7016        }),
7017        ST_HighlightColor::DarkGreen => Some(Color {
7018            r: 0.0,
7019            g: 0.392,
7020            b: 0.0,
7021            a: 1.0,
7022        }),
7023        ST_HighlightColor::DarkMagenta => Some(Color {
7024            r: 0.545,
7025            g: 0.0,
7026            b: 0.545,
7027            a: 1.0,
7028        }),
7029        ST_HighlightColor::DarkRed => Some(Color {
7030            r: 0.545,
7031            g: 0.0,
7032            b: 0.0,
7033            a: 1.0,
7034        }),
7035        ST_HighlightColor::DarkYellow => Some(Color {
7036            r: 0.545,
7037            g: 0.545,
7038            b: 0.0,
7039            a: 1.0,
7040        }),
7041        ST_HighlightColor::Green => Some(Color {
7042            r: 0.0,
7043            g: 1.0,
7044            b: 0.0,
7045            a: 1.0,
7046        }),
7047        ST_HighlightColor::LightGray => Some(Color {
7048            r: 0.827,
7049            g: 0.827,
7050            b: 0.827,
7051            a: 1.0,
7052        }),
7053        ST_HighlightColor::Magenta => Some(Color {
7054            r: 1.0,
7055            g: 0.0,
7056            b: 1.0,
7057            a: 1.0,
7058        }),
7059        ST_HighlightColor::Red => Some(Color {
7060            r: 1.0,
7061            g: 0.0,
7062            b: 0.0,
7063            a: 1.0,
7064        }),
7065        ST_HighlightColor::White => Some(Color {
7066            r: 1.0,
7067            g: 1.0,
7068            b: 1.0,
7069            a: 1.0,
7070        }),
7071        ST_HighlightColor::Yellow => Some(Color {
7072            r: 1.0,
7073            g: 1.0,
7074            b: 0.0,
7075            a: 1.0,
7076        }),
7077    }
7078}
7079
7080#[cfg(test)]
7081mod tests {
7082    use super::*;
7083    use crate::input::ImageData;
7084    use oxml_layout::{MediaId, MultilingualGlyphRun, TextScript};
7085    use std::collections::HashMap;
7086
7087    const LEGACY_RESTART_CACHE_MAX_BYTES: usize = 8 * 1024 * 1024;
7088
7089    fn assert_restart_cache_within_aggregate(engine: &Engine) {
7090        let restart = engine
7091            .restart_cache
7092            .as_ref()
7093            .expect("restart state retained");
7094        let entries = engine
7095            .paragraph_cache
7096            .len()
7097            .checked_add(engine.table_cache.len())
7098            .and_then(|entries| entries.checked_add(engine.header_footer_cache.len()))
7099            .and_then(|entries| entries.checked_add(restart_cache_entries(restart)))
7100            .expect("retained cache entry accounting does not overflow");
7101        let bytes = engine
7102            .paragraph_cache_bytes
7103            .checked_add(engine.table_cache_bytes)
7104            .and_then(|bytes| bytes.checked_add(engine.header_footer_cache_bytes))
7105            .and_then(|bytes| bytes.checked_add(restart.bytes))
7106            .expect("retained cache byte accounting does not overflow");
7107        assert!(entries <= CACHE_MAX_ENTRIES, "{entries}");
7108        assert!(bytes <= CACHE_MAX_BYTES, "{bytes}");
7109    }
7110
7111    fn compatibility_elements(elements: &[PositionedElement]) -> Vec<&PositionedElement> {
7112        fn collect<'a>(
7113            elements: &'a [PositionedElement],
7114            flattened: &mut Vec<&'a PositionedElement>,
7115        ) {
7116            for element in elements {
7117                match element {
7118                    PositionedElement::MarkedContent { children, .. } => {
7119                        collect(children, flattened);
7120                    }
7121                    element => flattened.push(element),
7122                }
7123            }
7124        }
7125
7126        let mut flattened = Vec::new();
7127        collect(elements, &mut flattened);
7128        flattened
7129    }
7130
7131    fn compatibility_page_elements(page: &PageFrame) -> Vec<&PositionedElement> {
7132        compatibility_elements(&page.elements)
7133    }
7134
7135    fn multilingual_runs(layout: &LayoutResult) -> Vec<&MultilingualGlyphRun> {
7136        layout
7137            .pages
7138            .iter()
7139            .flat_map(|page| compatibility_page_elements(page))
7140            .filter_map(|element| match element {
7141                PositionedElement::MultilingualText(run) => Some(run),
7142                _ => None,
7143            })
7144            .collect()
7145    }
7146
7147    #[test]
7148    fn revision_views_project_wrapped_runs_in_document_order() {
7149        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7150            <w:r><w:t>A</w:t></w:r>
7151            <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>
7152            <w:del w:id="3" w:author="Cy"><w:r><w:delText>X</w:delText></w:r></w:del>
7153            <w:moveFrom w:id="4" w:author="Dee"><w:r><w:t>F</w:t></w:r></w:moveFrom>
7154            <w:moveTo w:id="5" w:author="Eve"><w:r><w:t>T</w:t></w:r></w:moveTo>
7155            <w:r><w:t>Z</w:t></w:r>
7156        </w:p></w:body></w:document>"#;
7157        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7158        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7159            panic!("expected paragraph");
7160        };
7161
7162        let accepted = project_paragraph_runs(paragraph, RevisionView::Accepted)
7163            .iter()
7164            .map(|projected| projected.run.text())
7165            .collect::<Vec<_>>();
7166        assert_eq!(accepted, ["A", "I1", "I2", "T", "Z"]);
7167
7168        let tracked = project_paragraph_runs(paragraph, RevisionView::Tracked)
7169            .iter()
7170            .map(|projected| projected.run.text())
7171            .collect::<Vec<_>>();
7172        assert_eq!(tracked, ["A", "I1", "D", "I2", "X", "F", "T", "Z"]);
7173    }
7174
7175    #[test]
7176    fn nested_only_revision_wrappers_project_their_visible_runs() {
7177        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7178            <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>
7179        </w:p></w:body></w:document>"#;
7180        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7181        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7182            panic!("expected paragraph");
7183        };
7184        for view in [RevisionView::Accepted, RevisionView::Tracked] {
7185            assert_eq!(projected_paragraph_text(paragraph, view), "nested");
7186        }
7187        assert!(paragraph_has_visible_revision(paragraph));
7188    }
7189
7190    #[test]
7191    fn pageref_target_follows_an_earlier_revision_at_the_same_boundary() {
7192        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7193            <w:ins w:id="1" w:author="Ada"><w:r><w:t>before</w:t></w:r></w:ins>
7194            <w:bookmarkStart w:id="7" w:name="target"/>
7195            <w:fldSimple w:instr=" PAGEREF target "><w:r><w:t>1</w:t></w:r></w:fldSimple>
7196            <w:bookmarkEnd w:id="7"/>
7197        </w:p></w:body></w:document>"#;
7198        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7199        let mut input = make_input_with_text("");
7200        input.document = document;
7201        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
7202            panic!("expected paragraph");
7203        };
7204        let media = MediaRegistry::new(&input.images);
7205        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7206        let mut numbering = NumberingState::new();
7207        let mut diagnostics = Vec::new();
7208        let block = layout_paragraph(
7209            paragraph,
7210            468.0,
7211            &input.styles,
7212            &input,
7213            &media,
7214            &mut fonts,
7215            &mut numbering,
7216            &mut diagnostics,
7217        )
7218        .expect("paragraph lays out");
7219        let items = &block.reflow.expect("reflow items retained").items;
7220        let revision_index = items
7221            .iter()
7222            .position(|item| matches!(item, InlineItem::Text(text) if text.text == "before"))
7223            .expect("revision text");
7224        let target_index = items
7225            .iter()
7226            .position(|item| {
7227                matches!(item, InlineItem::Text(text) if matches!(text.field_kind, Some(FieldKind::Target(_))))
7228            })
7229            .expect("PAGEREF target");
7230        assert!(revision_index < target_index);
7231    }
7232
7233    #[test]
7234    fn derived_revision_text_uses_the_selected_projection() {
7235        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7236            <w:bookmarkStart w:id="7" w:name="target"/>
7237            <w:ins w:id="1" w:author="Ada"><w:r><w:t>new</w:t></w:r></w:ins>
7238            <w:del w:id="2" w:author="Ben"><w:r><w:delText>old</w:delText></w:r></w:del>
7239            <w:bookmarkEnd w:id="7"/>
7240        </w:p></w:body></w:document>"#;
7241        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7242        let mut input = make_input_with_text("");
7243        input.document = document;
7244
7245        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("new"));
7246        input.revision_view = RevisionView::Tracked;
7247        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("newold"));
7248    }
7249
7250    #[test]
7251    fn bookmark_after_a_terminal_hyperlink_revision_excludes_that_revision() {
7252        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>
7253            <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>
7254            <w:bookmarkStart w:id="7" w:name="target"/><w:r><w:t>inside</w:t></w:r><w:bookmarkEnd w:id="7"/>
7255        </w:p></w:body></w:document>"#;
7256        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7257        let mut input = make_input_with_text("");
7258        input.document = document;
7259
7260        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("inside"));
7261    }
7262
7263    #[test]
7264    fn revision_only_hyperlink_keeps_its_link_annotation() {
7265        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>
7266            <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>
7267        </w:p></w:body></w:document>"#;
7268        let mut document =
7269            rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7270        let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
7271            panic!("expected paragraph");
7272        };
7273        paragraph.hyperlinks[0].rel_id = Some("rId2".to_owned());
7274        let serialized = String::from_utf8(document.to_xml().expect("document serializes"))
7275            .expect("document XML is UTF-8");
7276        assert!(serialized.contains("r:id=\"rId2\""), "{serialized}");
7277        assert!(!serialized.contains("r:id=\"rId1\""), "{serialized}");
7278        let mut input = make_input_with_text("");
7279        input.document = document;
7280        input
7281            .hyperlink_urls
7282            .insert("rId2".to_owned(), "https://example.com".to_owned());
7283
7284        let output = Engine::new_deterministic()
7285            .expect("bundled fonts load")
7286            .layout(&input)
7287            .expect("revision hyperlink lays out");
7288        assert!(
7289            compatibility_page_elements(&output.pages[0])
7290                .into_iter()
7291                .any(|element| {
7292                    matches!(element, PositionedElement::LinkAnnotation { url, .. }
7293                if url == "https://example.com")
7294                })
7295        );
7296    }
7297
7298    #[test]
7299    fn derived_revision_text_keeps_order_after_comment_run_removal() {
7300        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7301            <w:bookmarkStart w:id="7" w:name="target"/>
7302            <w:commentRangeStart w:id="5"/><w:r><w:commentReference w:id="5"/></w:r>
7303            <w:ins w:id="1" w:author="Ada"><w:r><w:t>inside</w:t></w:r></w:ins>
7304            <w:bookmarkEnd w:id="7"/>
7305        </w:p></w:body></w:document>"#;
7306        let mut document =
7307            rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7308        let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
7309            panic!("expected paragraph");
7310        };
7311        paragraph.remove_comment_anchors(&[5]);
7312        let mut input = make_input_with_text("");
7313        input.document = document;
7314
7315        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("inside"));
7316    }
7317
7318    #[test]
7319    fn heading_text_uses_the_selected_revision_projection() {
7320        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7321            <w:ins w:id="1" w:author="Ada"><w:r><w:t>new</w:t></w:r></w:ins>
7322            <w:del w:id="2" w:author="Ben"><w:r><w:delText>old</w:delText></w:r></w:del>
7323        </w:p></w:body></w:document>"#;
7324        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7325        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7326            panic!("expected paragraph");
7327        };
7328        assert_eq!(
7329            projected_paragraph_text(paragraph, RevisionView::Accepted),
7330            "new"
7331        );
7332        assert_eq!(
7333            projected_paragraph_text(paragraph, RevisionView::Tracked),
7334            "newold"
7335        );
7336    }
7337
7338    #[test]
7339    fn revised_floating_anchors_follow_the_selected_projection() {
7340        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
7341            xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
7342            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
7343            xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><w:body><w:p>
7344            <w:ins w:id="1" w:author="Ada"><w:r><w:drawing><wp:anchor behindDoc="0">
7345              <wp:positionH relativeFrom="margin"><wp:align>right</wp:align></wp:positionH>
7346              <wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>
7347              <wp:extent cx="914400" cy="457200"/><wp:wrapSquare wrapText="bothSides"/>
7348              <a:graphic><a:graphicData><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic>
7349            </wp:anchor></w:drawing></w:r></w:ins>
7350        </w:p></w:body></w:document>"#;
7351        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7352        let mut input = make_input_with_text("");
7353        input.document = document;
7354        assert!(document_has_wrapping_drawing(&input));
7355
7356        input.revision_view = RevisionView::Tracked;
7357        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
7358            panic!("expected paragraph");
7359        };
7360        let media = MediaRegistry::new(&input.images);
7361        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7362        let mut numbering = NumberingState::new();
7363        let mut diagnostics = Vec::new();
7364        let anchored = collect_anchored_drawings(
7365            paragraph,
7366            &input.styles,
7367            &input,
7368            &media,
7369            &mut fonts,
7370            &mut numbering,
7371            &mut diagnostics,
7372        )
7373        .expect("tracked anchor collection succeeds");
7374        assert_eq!(anchored.len(), 1);
7375    }
7376
7377    #[test]
7378    fn tracked_revision_decorations_override_only_underline_and_strike() {
7379        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7380            <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>
7381            <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>
7382            <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>
7383            <w:del w:id="4" w:author="Ben"><w:r><w:endnoteReference w:id="12"/></w:r></w:del>
7384        </w:p></w:body></w:document>"#;
7385        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7386        let mut input = make_input_with_text("");
7387        input.document = document;
7388        input.revision_view = RevisionView::Tracked;
7389        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
7390            panic!("expected paragraph");
7391        };
7392        let media = MediaRegistry::new(&input.images);
7393        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7394        let mut numbering = NumberingState::new();
7395        let mut diagnostics = Vec::new();
7396        let block = layout_paragraph(
7397            paragraph,
7398            468.0,
7399            &input.styles,
7400            &input,
7401            &media,
7402            &mut fonts,
7403            &mut numbering,
7404            &mut diagnostics,
7405        )
7406        .expect("tracked paragraph lays out");
7407        let segments = block
7408            .lines
7409            .iter()
7410            .flat_map(|line| &line.items)
7411            .filter_map(|item| match item {
7412                oxml_layout::LineItem::Text(segment) => Some(segment),
7413                _ => None,
7414            })
7415            .collect::<Vec<_>>();
7416        let inserted = segments
7417            .iter()
7418            .find(|segment| segment.text == "inserted")
7419            .expect("inserted segment");
7420        assert_eq!(inserted.underline, Some(Underline::Single));
7421        assert!(!inserted.strike);
7422        assert!(inserted.dstrike);
7423        assert!(inserted.bold && inserted.italic);
7424        assert_eq!(inserted.color, Color::from_hex("AA0000"));
7425        assert_eq!(inserted.highlight, Some(Color::from_hex("FFFF00")));
7426
7427        let deleted = segments
7428            .iter()
7429            .find(|segment| segment.text == "deleted")
7430            .expect("deleted segment");
7431        assert_eq!(deleted.underline, Some(Underline::Double));
7432        assert!(deleted.strike);
7433        assert_eq!(deleted.color, Color::from_hex("0000AA"));
7434
7435        let inserted_note = segments
7436            .iter()
7437            .find(|segment| segment.text == "11")
7438            .expect("inserted note marker");
7439        assert_eq!(inserted_note.underline, Some(Underline::Single));
7440        assert_eq!(inserted_note.highlight, Some(Color::from_hex("FFFF00")));
7441        let deleted_note = segments
7442            .iter()
7443            .find(|segment| segment.text == "12")
7444            .expect("deleted note marker");
7445        assert!(deleted_note.strike);
7446
7447        let mut accepted_input = input.clone();
7448        accepted_input.revision_view = RevisionView::Accepted;
7449        let BodyContent::Paragraph(accepted_paragraph) = &accepted_input.document.body.content[0]
7450        else {
7451            panic!("expected paragraph");
7452        };
7453        let accepted_media = MediaRegistry::new(&accepted_input.images);
7454        let accepted_block = layout_paragraph(
7455            accepted_paragraph,
7456            468.0,
7457            &accepted_input.styles,
7458            &accepted_input,
7459            &accepted_media,
7460            &mut fonts,
7461            &mut numbering,
7462            &mut diagnostics,
7463        )
7464        .expect("accepted paragraph lays out");
7465        let accepted_note = accepted_block
7466            .lines
7467            .iter()
7468            .flat_map(|line| &line.items)
7469            .filter_map(|item| match item {
7470                oxml_layout::LineItem::Text(segment) if segment.text == "11" => Some(segment),
7471                _ => None,
7472            })
7473            .next()
7474            .expect("accepted note marker");
7475        assert_eq!(accepted_note.underline, None);
7476        assert!(!accepted_note.strike && !accepted_note.dstrike);
7477        assert_eq!(accepted_note.highlight, None);
7478    }
7479
7480    #[test]
7481    fn a_split_changed_paragraph_draws_one_margin_bar_on_each_page() {
7482        let changed = "changed ".repeat(3_000);
7483        let xml = format!(
7484            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>"#
7485        );
7486        let document =
7487            rdocx_oxml::CT_Document::from_xml(xml.as_bytes()).expect("revision document parses");
7488        let mut input = make_input_with_text("");
7489        input.document = document;
7490        let accepted = Engine::new_deterministic()
7491            .expect("bundled fonts load")
7492            .layout(&input)
7493            .expect("accepted document lays out");
7494        input.revision_view = RevisionView::Tracked;
7495        let output = Engine::new_deterministic()
7496            .expect("bundled fonts load")
7497            .layout(&input)
7498            .expect("tracked document lays out");
7499        let geometry = PageGeometry::default();
7500        assert!(output.pages.len() > 1);
7501        assert_eq!(accepted.pages.len(), output.pages.len());
7502        for (accepted_page, page) in accepted.pages.iter().zip(&output.pages) {
7503            let accepted_text = compatibility_page_elements(accepted_page)
7504                .into_iter()
7505                .filter_map(|element| match element {
7506                    PositionedElement::Text(text) => Some(text),
7507                    _ => None,
7508                })
7509                .collect::<Vec<_>>();
7510            let tracked_text = compatibility_page_elements(page)
7511                .into_iter()
7512                .filter_map(|element| match element {
7513                    PositionedElement::Text(text) => Some(text),
7514                    _ => None,
7515                })
7516                .collect::<Vec<_>>();
7517            assert_eq!(accepted_text, tracked_text);
7518            let bars = compatibility_page_elements(page)
7519                .into_iter()
7520                .filter_map(|element| match element {
7521                    PositionedElement::Line {
7522                        start,
7523                        end,
7524                        width,
7525                        dash_pattern,
7526                        ..
7527                    } if (*width - 1.5).abs() < f64::EPSILON
7528                        && dash_pattern.is_none()
7529                        && start.x == end.x
7530                        && (start.x < geometry.margin_left
7531                            || start.x > geometry.page_width - geometry.margin_right) =>
7532                    {
7533                        Some((*start, *end))
7534                    }
7535                    _ => None,
7536                })
7537                .collect::<Vec<_>>();
7538            assert_eq!(bars.len(), 1, "page {}", page.page_number);
7539            let (start, end) = bars[0];
7540            assert!(start.x.is_finite() && start.y.is_finite() && end.y.is_finite());
7541            assert!(end.y > start.y);
7542            if page.page_number.is_multiple_of(2) {
7543                assert!(start.x < geometry.margin_left);
7544            } else {
7545                assert!(start.x > geometry.page_width - geometry.margin_right);
7546            }
7547        }
7548    }
7549
7550    fn page_change_bar_count(page: &PageFrame) -> usize {
7551        let geometry = PageGeometry::default();
7552        compatibility_page_elements(page)
7553            .into_iter()
7554            .filter(|element| {
7555                matches!(element, PositionedElement::Line { start, end, width, .. }
7556                    if (*width - 1.5).abs() < f64::EPSILON
7557                        && start.x == end.x
7558                        && (start.x < geometry.margin_left
7559                            || start.x > geometry.page_width - geometry.margin_right))
7560            })
7561            .count()
7562    }
7563
7564    #[test]
7565    fn tracked_header_paragraph_draws_a_change_bar() {
7566        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
7567
7568        let mut input = make_input_with_text("body");
7569        input.revision_view = RevisionView::Tracked;
7570        input.document.body.sect_pr = Some(CT_SectPr::default_letter());
7571        input
7572            .document
7573            .body
7574            .sect_pr
7575            .as_mut()
7576            .expect("section properties")
7577            .header_refs
7578            .push(HdrFtrRef {
7579                hdr_ftr_type: HdrFtrType::Default,
7580                rel_id: "rIdHeader".to_owned(),
7581            });
7582        let header = CT_HdrFtr::from_xml(
7583            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>"#,
7584        )
7585        .expect("header parses");
7586        input.headers.insert("rIdHeader".to_owned(), header);
7587
7588        let output = Engine::new_deterministic()
7589            .expect("bundled fonts load")
7590            .layout(&input)
7591            .expect("tracked header lays out");
7592        assert_eq!(page_change_bar_count(&output.pages[0]), 1);
7593    }
7594
7595    #[test]
7596    fn tracked_note_paragraph_draws_a_change_bar() {
7597        let mut input = make_input_with_footnote(&["plain"]);
7598        input.revision_view = RevisionView::Tracked;
7599        let changed_note = rdocx_oxml::CT_Document::from_xml(
7600            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>"#,
7601        )
7602        .expect("note paragraph parses");
7603        let BodyContent::Paragraph(paragraph) = &changed_note.body.content[0] else {
7604            panic!("expected paragraph");
7605        };
7606        input.footnotes.as_mut().expect("footnote stream").footnotes[0].paragraphs =
7607            vec![paragraph.clone()];
7608
7609        let output = Engine::new_deterministic()
7610            .expect("bundled fonts load")
7611            .layout(&input)
7612            .expect("tracked note lays out");
7613        assert_eq!(page_change_bar_count(&output.pages[0]), 1);
7614        let decoration_widths = compatibility_page_elements(&output.pages[0])
7615            .into_iter()
7616            .filter_map(|element| match element {
7617                PositionedElement::Line {
7618                    start, end, width, ..
7619                } if start.y == end.y && (*width - 0.5).abs() > f64::EPSILON => Some(*width),
7620                _ => None,
7621            })
7622            .collect::<Vec<_>>();
7623        assert!(
7624            decoration_widths
7625                .iter()
7626                .any(|width| (*width - 11.0 / 18.0).abs() < 0.001),
7627            "tracked insertion underline missing: {decoration_widths:?}"
7628        );
7629        assert!(
7630            decoration_widths
7631                .iter()
7632                .any(|width| (*width - 11.0 / 24.0).abs() < 0.001),
7633            "tracked deletion strike missing: {decoration_widths:?}"
7634        );
7635    }
7636
7637    #[test]
7638    fn property_only_revisions_mark_the_tracked_paragraph() {
7639        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>"#;
7640        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7641        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7642            panic!("expected paragraph");
7643        };
7644        assert!(paragraph_has_visible_revision(paragraph));
7645    }
7646
7647    #[test]
7648    fn empty_revision_wrappers_do_not_mark_the_tracked_paragraph() {
7649        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>"#;
7650        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7651        for content in &document.body.content {
7652            let BodyContent::Paragraph(paragraph) = content else {
7653                panic!("expected paragraph");
7654            };
7655            assert!(!paragraph_has_visible_revision(paragraph));
7656        }
7657    }
7658
7659    fn make_input_with_text(text: &str) -> LayoutInput {
7660        let mut doc = rdocx_oxml::document::CT_Document::new();
7661        let mut p = CT_P::new();
7662        p.add_run(text);
7663        doc.body.add_paragraph(p);
7664
7665        LayoutInput {
7666            revision_view: crate::input::RevisionView::Accepted,
7667            automatic_hyphenation: false,
7668            document: doc,
7669            styles: CT_Styles::new_default(),
7670            numbering: None,
7671            headers: HashMap::new(),
7672            footers: HashMap::new(),
7673            images: HashMap::new(),
7674            charts: HashMap::new(),
7675            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
7676            chart_color_map: oxml_drawing::color::ColorMap::default(),
7677            core_properties: None,
7678            hyperlink_urls: HashMap::new(),
7679            footnotes: None,
7680            endnotes: None,
7681            theme: None,
7682            fonts: Vec::new(),
7683        }
7684    }
7685
7686    fn five_large_caller_fonts() -> Vec<oxml_layout::FontFile> {
7687        const TOTAL_BYTES: usize = 22 * 1024 * 1024;
7688        let bundled = oxml_layout::bundled_fonts::bundled_font_data();
7689        [0, 4, 8, 12, 16]
7690            .into_iter()
7691            .enumerate()
7692            .map(|(index, bundled_index)| {
7693                let (family, source) = bundled[bundled_index];
7694                let mut data = source.to_vec();
7695                let target = TOTAL_BYTES / 5 + usize::from(index < TOTAL_BYTES % 5);
7696                data.resize(
7697                    target,
7698                    u8::try_from(index).expect("five font indices fit in u8"),
7699                );
7700                oxml_layout::FontFile {
7701                    family: family.to_owned(),
7702                    data,
7703                }
7704            })
7705            .collect()
7706    }
7707
7708    #[test]
7709    fn warm_layout_does_not_repeat_retained_context_font_byte_equality() {
7710        let mut input = make_input_with_text("warm caller-font comparison");
7711        input.fonts = five_large_caller_fonts();
7712        let aliases = (0..40)
7713            .map(|index| {
7714                (
7715                    format!("Editor Family {index}"),
7716                    input.fonts[index % input.fonts.len()].family.clone(),
7717                )
7718            })
7719            .collect::<Vec<_>>();
7720        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
7721        engine.set_caller_font_aliases(&aliases);
7722        engine.layout(&input).expect("prime caller-font layout");
7723
7724        reset_retained_context_font_bytes_compared();
7725        engine.layout(&input).expect("warm caller-font layout");
7726
7727        assert_eq!(retained_context_font_bytes_compared(), 0);
7728    }
7729
7730    #[test]
7731    fn same_length_changed_font_bytes_still_invalidate_reusable_work() {
7732        let mut input = make_input_with_text("changed caller-font bytes");
7733        input.fonts = five_large_caller_fonts();
7734        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
7735        engine.layout(&input).expect("prime caller-font layout");
7736        assert_eq!(engine.paragraph_cache_counts(), (0, 1));
7737
7738        let last = input.fonts[0]
7739            .data
7740            .last_mut()
7741            .expect("generated font has padding");
7742        *last ^= 1;
7743        let warm = engine.layout(&input).expect("changed-font layout");
7744        let fresh = Engine::new_deterministic()
7745            .expect("bundled fonts load")
7746            .layout(&input)
7747            .expect("fresh changed-font layout");
7748
7749        assert_eq!(engine.paragraph_cache_counts(), (0, 2));
7750        assert_layout_results_equal(&warm, &fresh);
7751    }
7752
7753    #[test]
7754    fn checked_transfer_keeps_exact_ordered_caller_font_bytes() {
7755        let mut input = make_input_with_text("checked caller-font transfer");
7756        input.fonts = five_large_caller_fonts();
7757        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
7758        engine.layout(&input).expect("prime caller-font layout");
7759        let mut source = Some(engine);
7760
7761        let last = input.fonts[0]
7762            .data
7763            .last_mut()
7764            .expect("generated font has padding");
7765        *last ^= 1;
7766
7767        reset_retained_context_font_bytes_compared();
7768        assert!(Engine::take_if_compatible(&mut source, &input).is_none());
7769        assert!(source.is_some(), "rejected transfer preserves its source");
7770        assert_eq!(retained_context_font_bytes_compared(), 22 * 1024 * 1024);
7771    }
7772
7773    fn hyphenation_input(enabled: bool, language: Option<&str>, suppressed: bool) -> LayoutInput {
7774        let mut input = make_input_with_text("representation");
7775        input.automatic_hyphenation = enabled;
7776        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7777            panic!("expected paragraph")
7778        };
7779        paragraph.properties = Some(CT_PPr {
7780            ind_right: Some(rdocx_oxml::units::Twips(8_500)),
7781            suppress_auto_hyphens: suppressed.then_some(true),
7782            ..Default::default()
7783        });
7784        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
7785            language: language.map(str::to_owned),
7786            ..Default::default()
7787        });
7788        input
7789    }
7790
7791    #[test]
7792    fn document_enablement_language_and_paragraph_suppression_gate_hyphenation() {
7793        let enabled = output_text(&deterministic_layout(&hyphenation_input(
7794            true,
7795            Some("en-US"),
7796            false,
7797        )))
7798        .concat();
7799        assert_eq!(enabled, "repre-sentation");
7800
7801        for input in [
7802            hyphenation_input(false, Some("en-US"), false),
7803            hyphenation_input(true, None, false),
7804            hyphenation_input(true, Some("it-IT"), false),
7805            hyphenation_input(true, Some("en-US"), true),
7806        ] {
7807            let text = output_text(&deterministic_layout(&input)).concat();
7808            assert_eq!(text, "representation");
7809        }
7810    }
7811
7812    #[test]
7813    fn rtl_first_rich_paragraph_keeps_hyphenatable_english_in_visual_order() {
7814        let mut input = make_input_with_text("");
7815        input.automatic_hyphenation = true;
7816        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7817            panic!("expected paragraph")
7818        };
7819        let mut arabic = CT_R::new("العربية");
7820        arabic.properties = Some(rdocx_oxml::properties::CT_RPr {
7821            language: Some("ar-SA".to_owned()),
7822            language_bidi: Some("ar-SA".to_owned()),
7823            ..Default::default()
7824        });
7825        let mut english = CT_R::new(" representation");
7826        english.properties = Some(rdocx_oxml::properties::CT_RPr {
7827            language: Some("en-US".to_owned()),
7828            ..Default::default()
7829        });
7830        paragraph.runs = vec![arabic, english];
7831
7832        let output = crate::layout_document_deterministic_with_provenance(&input)
7833            .expect("hybrid bidi layout with sources")
7834            .layout;
7835        let arabic_x = multilingual_runs(&output)
7836            .into_iter()
7837            .find(|run| run.logical_text == "العربية")
7838            .expect("Arabic run uses rich shaping")
7839            .origin
7840            .x;
7841        let english_x = output
7842            .pages
7843            .iter()
7844            .flat_map(|page| compatibility_page_elements(page))
7845            .find_map(|element| match element {
7846                PositionedElement::Text(run) if run.text.contains("representation") => {
7847                    Some(run.origin.x)
7848                }
7849                _ => None,
7850            })
7851            .expect("hyphenatable English run stays in the line");
7852        assert!(
7853            english_x < arabic_x,
7854            "RTL paragraph paints English left of Arabic: English {english_x}, Arabic {arabic_x}"
7855        );
7856        let extraction_order = output
7857            .pages
7858            .iter()
7859            .flat_map(|page| compatibility_page_elements(page))
7860            .filter_map(|element| match element {
7861                PositionedElement::Text(run) if run.text.contains("representation") => {
7862                    Some(run.text.as_str())
7863                }
7864                PositionedElement::MultilingualText(run)
7865                    if run.logical_text.contains("العربية") =>
7866                {
7867                    Some(run.logical_text.as_str())
7868                }
7869                _ => None,
7870            })
7871            .collect::<Vec<_>>();
7872        assert_eq!(extraction_order, ["العربية", "representation"]);
7873    }
7874
7875    #[test]
7876    fn explicit_rtl_hyphenatable_latin_spans_keep_resolved_even_level_order() {
7877        let mut input = make_input_with_text("ABC 123");
7878        input.automatic_hyphenation = true;
7879        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7880            panic!("expected paragraph")
7881        };
7882        paragraph.properties = Some(CT_PPr {
7883            bidi: Some(false),
7884            ..Default::default()
7885        });
7886        paragraph.runs[0].properties = Some(CT_RPr {
7887            rtl: Some(true),
7888            language: Some("en-US".to_owned()),
7889            ..Default::default()
7890        });
7891
7892        let output = deterministic_layout(&input);
7893        let positions = output
7894            .pages
7895            .iter()
7896            .flat_map(|page| compatibility_page_elements(page))
7897            .filter_map(|element| match element {
7898                PositionedElement::Text(run) if run.text.contains("ABC") => {
7899                    Some(("ABC", run.origin.x))
7900                }
7901                PositionedElement::Text(run) if run.text.contains("123") => {
7902                    Some(("123", run.origin.x))
7903                }
7904                _ => None,
7905            })
7906            .collect::<Vec<_>>();
7907        assert_eq!(positions.len(), 2, "{positions:?}");
7908        let abc_x = positions
7909            .iter()
7910            .find_map(|(text, x)| (*text == "ABC").then_some(*x))
7911            .unwrap();
7912        let digits_x = positions
7913            .iter()
7914            .find_map(|(text, x)| (*text == "123").then_some(*x))
7915            .unwrap();
7916        assert!(
7917            abc_x < digits_x,
7918            "resolved even-level spans stay LTR: {positions:?}"
7919        );
7920    }
7921
7922    #[test]
7923    fn right_to_left_paragraph_resolves_start_alignment_and_indents_from_the_right() {
7924        let mut input = make_input_with_text("123 العربية");
7925        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7926            panic!("expected paragraph")
7927        };
7928        paragraph.properties = Some(CT_PPr {
7929            bidi: Some(true),
7930            jc: Some(rdocx_oxml::shared::ST_Jc::Start),
7931            ind_start: Some(rdocx_oxml::units::Twips(720)),
7932            ind_end: Some(rdocx_oxml::units::Twips(360)),
7933            num_id: Some(1),
7934            num_ilvl: Some(0),
7935            ..Default::default()
7936        });
7937        let mut level = rdocx_oxml::numbering::CT_Lvl::new(0);
7938        level.num_fmt = Some(rdocx_oxml::numbering::ST_NumberFormat::Bullet);
7939        level.suffix = Some(rdocx_oxml::numbering::ST_LvlSuffix::Nothing);
7940        level.lvl_text = Some("•".to_owned());
7941        let mut abstract_num = rdocx_oxml::numbering::CT_AbstractNum::new(1);
7942        abstract_num.levels.push(level);
7943        input.numbering = Some(rdocx_oxml::numbering::CT_Numbering {
7944            abstract_nums: vec![abstract_num],
7945            nums: vec![rdocx_oxml::numbering::CT_Num {
7946                num_id: 1,
7947                abstract_num_id: 1,
7948                extra_xml: Vec::new(),
7949                extra_attributes: Vec::new(),
7950            }],
7951            root_attributes: Vec::new(),
7952            extra_xml: Vec::new(),
7953        });
7954        let paragraph = paragraph.clone();
7955
7956        let media = MediaRegistry::new(&input.images);
7957        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7958        let mut numbering = NumberingState::new();
7959        let mut diagnostics = Vec::new();
7960        let block = layout_paragraph(
7961            &paragraph,
7962            468.0,
7963            &input.styles,
7964            &input,
7965            &media,
7966            &mut fonts,
7967            &mut numbering,
7968            &mut diagnostics,
7969        )
7970        .expect("RTL paragraph lays out");
7971
7972        assert_eq!(block.indent_left, 18.0);
7973        assert_eq!(block.indent_right, 36.0);
7974        assert_eq!(block.jc, Some(oxml_layout::Align::End));
7975        assert!(matches!(
7976            block.lines[0].items.last(),
7977            Some(LineItem::Marker(marker)) if marker.text == "•"
7978        ));
7979        assert_eq!(
7980            block.lines[0]
7981                .items
7982                .iter()
7983                .filter_map(|item| match item {
7984                    LineItem::Text(text) => Some(text.text.as_str()),
7985                    LineItem::MultilingualText(text) => Some(text.text()),
7986                    LineItem::Marker(marker) => Some(marker.text.as_str()),
7987                    _ => None,
7988                })
7989                .collect::<String>(),
7990            "العربية 123•"
7991        );
7992    }
7993
7994    #[test]
7995    fn run_level_direction_override_shapes_the_exact_source_span() {
7996        let mut input = make_input_with_text("");
7997        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7998            panic!("expected paragraph")
7999        };
8000        paragraph.properties = Some(CT_PPr {
8001            bidi: Some(false),
8002            ..Default::default()
8003        });
8004        let mut leading = CT_R::new("left ");
8005        leading.properties = Some(CT_RPr {
8006            language: Some("en-US".to_owned()),
8007            ..Default::default()
8008        });
8009        let mut overridden = CT_R::new("123");
8010        overridden.properties = Some(CT_RPr {
8011            rtl: Some(true),
8012            language_bidi: Some("ar-SA".to_owned()),
8013            ..Default::default()
8014        });
8015        let mut trailing = CT_R::new(" right");
8016        trailing.properties = Some(CT_RPr {
8017            language: Some("en-US".to_owned()),
8018            ..Default::default()
8019        });
8020        paragraph.runs = vec![leading, overridden, trailing];
8021
8022        let output = crate::layout_document_deterministic_with_provenance(&input)
8023            .expect("directional layout with sources");
8024        let overridden = multilingual_runs(&output.layout)
8025            .into_iter()
8026            .find(|run| run.logical_text == "123")
8027            .expect("run override enters rich shaping");
8028        assert_eq!(overridden.direction, TextDirection::LeftToRight);
8029        assert_eq!(overridden.bidi_level, 2);
8030        assert_eq!(overridden.source.expect("source span").char_start, 5);
8031        assert_eq!(overridden.source.expect("source span").char_end, 8);
8032    }
8033
8034    #[test]
8035    fn computed_field_retains_its_stored_run_direction() {
8036        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:bidi w:val="0"/></w:pPr><w:fldSimple w:instr=" PAGE "><w:r><w:rPr><w:rtl/><w:lang w:val="en-US" w:bidi="ar-SA"/></w:rPr><w:t>99</w:t></w:r></w:fldSimple></w:p></w:body></w:document>"#;
8037        let mut input = make_input_with_text("");
8038        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
8039        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8040            panic!("expected paragraph")
8041        };
8042        let media = MediaRegistry::new(&input.images);
8043        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8044        let mut numbering = NumberingState::new();
8045        let mut diagnostics = Vec::new();
8046        let block = layout_paragraph(
8047            paragraph,
8048            468.0,
8049            &input.styles,
8050            &input,
8051            &media,
8052            &mut fonts,
8053            &mut numbering,
8054            &mut diagnostics,
8055        )
8056        .expect("directional field lays out");
8057        let field = block
8058            .lines
8059            .iter()
8060            .flat_map(|line| &line.items)
8061            .find_map(|item| match item {
8062                LineItem::Text(segment) if segment.field_kind == Some(FieldKind::Page) => {
8063                    Some(segment)
8064                }
8065                _ => None,
8066            })
8067            .expect("computed field remains substitutable");
8068        assert_eq!(field.text, "99");
8069        assert_eq!(field.direction, TextDirection::RightToLeft);
8070    }
8071
8072    #[test]
8073    fn field_only_directional_paragraph_keeps_bidi_through_drawing_reflow() {
8074        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
8075        use rdocx_oxml::text::Field;
8076
8077        let mut input =
8078            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
8079        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8080            panic!("expected paragraph")
8081        };
8082        paragraph.properties = Some(CT_PPr {
8083            bidi: Some(true),
8084            ..Default::default()
8085        });
8086        let drawing = paragraph.runs.pop().expect("wrapping drawing run");
8087        let mut page = CT_R::new("");
8088        page.properties = Some(CT_RPr {
8089            rtl: Some(true),
8090            ..Default::default()
8091        });
8092        page.content = vec![RunContent::Field(Field::new("PAGE", "אבג"))];
8093        let mut pages = CT_R::new("");
8094        pages.properties = Some(CT_RPr {
8095            rtl: Some(false),
8096            ..Default::default()
8097        });
8098        pages.content = vec![RunContent::Field(Field::new("NUMPAGES", "ABC"))];
8099        paragraph.runs = vec![page, pages, drawing];
8100
8101        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8102            panic!("expected paragraph")
8103        };
8104        let media = MediaRegistry::new(&input.images);
8105        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8106        let mut numbering = NumberingState::new();
8107        let mut diagnostics = Vec::new();
8108        let (block, direction) = layout_paragraph_with_source_and_direction(
8109            paragraph,
8110            468.0,
8111            &input.styles,
8112            &input,
8113            &media,
8114            &mut fonts,
8115            &mut numbering,
8116            &mut diagnostics,
8117            None,
8118        )
8119        .expect("field-only paragraph lays out");
8120        assert_eq!(direction, TextDirection::RightToLeft);
8121        assert_eq!(
8122            block.reflow.as_ref().expect("reflow state").items.len(),
8123            2,
8124            "private direction state must not masquerade as a public inline item"
8125        );
8126
8127        let output = deterministic_layout(&input);
8128        let fields = output.pages[0]
8129            .elements
8130            .iter()
8131            .filter_map(|element| match element {
8132                PositionedElement::Text(run) => run.field_kind.map(|kind| (kind, run.origin.x)),
8133                PositionedElement::MarkedContent { children, .. } => {
8134                    children.iter().find_map(|child| match child {
8135                        PositionedElement::Text(run) => {
8136                            run.field_kind.map(|kind| (kind, run.origin.x))
8137                        }
8138                        _ => None,
8139                    })
8140                }
8141                _ => None,
8142            })
8143            .collect::<Vec<_>>();
8144        assert_eq!(
8145            fields.iter().map(|(kind, _)| *kind).collect::<Vec<_>>(),
8146            vec![FieldKind::Page, FieldKind::NumPages],
8147            "logical extraction keeps the stored field sequence"
8148        );
8149    }
8150
8151    #[test]
8152    fn word_positioned_runs_keep_logical_order_with_visual_origins() {
8153        let mut input = make_input_with_text("");
8154        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8155            panic!("expected paragraph")
8156        };
8157        paragraph.properties = Some(CT_PPr {
8158            bidi: Some(true),
8159            ..Default::default()
8160        });
8161        let mut arabic = CT_R::new("العربية ");
8162        arabic.properties = Some(CT_RPr {
8163            rtl: Some(true),
8164            language_bidi: Some("ar-SA".to_owned()),
8165            ..Default::default()
8166        });
8167        let mut latin = CT_R::new("ABC");
8168        latin.properties = Some(CT_RPr {
8169            rtl: Some(false),
8170            language: Some("en-US".to_owned()),
8171            ..Default::default()
8172        });
8173        paragraph.runs = vec![arabic, latin];
8174
8175        let output = deterministic_layout(&input);
8176        let runs = multilingual_runs(&output);
8177        assert_eq!(
8178            runs.iter()
8179                .map(|run| run.logical_text.as_str())
8180                .collect::<String>(),
8181            "العربية ABC"
8182        );
8183        assert!(
8184            runs.windows(2)
8185                .all(|pair| pair[0].logical_index < pair[1].logical_index)
8186        );
8187        let arabic_x = runs
8188            .iter()
8189            .find(|run| run.logical_text.contains("العربية"))
8190            .unwrap()
8191            .origin
8192            .x;
8193        let latin_x = runs
8194            .iter()
8195            .find(|run| run.logical_text == "ABC")
8196            .unwrap()
8197            .origin
8198            .x;
8199        assert!(latin_x < arabic_x, "visual origins remain RTL");
8200    }
8201
8202    #[test]
8203    fn absent_word_bidi_infers_one_rtl_base_for_reordering_alignment_and_indents() {
8204        let mut input = make_input_with_text("");
8205        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8206            panic!("expected paragraph")
8207        };
8208        paragraph.properties = Some(CT_PPr {
8209            jc: Some(rdocx_oxml::shared::ST_Jc::Start),
8210            ind_start: Some(rdocx_oxml::units::Twips(720)),
8211            ind_end: Some(rdocx_oxml::units::Twips(360)),
8212            ..Default::default()
8213        });
8214        let mut arabic = CT_R::new("العربية ");
8215        arabic.properties = Some(CT_RPr {
8216            language_bidi: Some("ar-SA".to_owned()),
8217            ..Default::default()
8218        });
8219        let mut latin = CT_R::new("ABC");
8220        latin.properties = Some(CT_RPr {
8221            language: Some("en-US".to_owned()),
8222            ..Default::default()
8223        });
8224        paragraph.runs = vec![arabic, latin];
8225        let paragraph = paragraph.clone();
8226
8227        let media = MediaRegistry::new(&input.images);
8228        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8229        let mut numbering = NumberingState::new();
8230        let mut diagnostics = Vec::new();
8231        let block = layout_paragraph(
8232            &paragraph,
8233            468.0,
8234            &input.styles,
8235            &input,
8236            &media,
8237            &mut fonts,
8238            &mut numbering,
8239            &mut diagnostics,
8240        )
8241        .expect("default-direction paragraph lays out");
8242        assert_eq!(block.indent_left, 18.0);
8243        assert_eq!(block.indent_right, 36.0);
8244        assert_eq!(block.jc, Some(oxml_layout::Align::End));
8245
8246        let output = deterministic_layout(&input);
8247        let runs = multilingual_runs(&output);
8248        let arabic_x = runs
8249            .iter()
8250            .find(|run| run.logical_text.contains("العربية"))
8251            .unwrap()
8252            .origin
8253            .x;
8254        let latin_x = runs
8255            .iter()
8256            .find(|run| run.logical_text == "ABC")
8257            .unwrap()
8258            .origin
8259            .x;
8260        assert!(
8261            latin_x < arabic_x,
8262            "absent w:bidi uses one inferred RTL base"
8263        );
8264    }
8265
8266    #[test]
8267    fn changed_document_hyphenation_state_invalidates_reusable_paragraph_work() {
8268        let mut input = hyphenation_input(false, Some("en-US"), false);
8269        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8270        assert_eq!(
8271            output_text(&engine.layout(&input).expect("disabled layout")).concat(),
8272            "representation"
8273        );
8274
8275        input.automatic_hyphenation = true;
8276        let warm = engine.layout(&input).expect("enabled warm layout");
8277        let fresh = Engine::new_deterministic()
8278            .expect("bundled fonts load")
8279            .layout(&input)
8280            .expect("enabled fresh layout");
8281        assert_layout_results_equal(&warm, &fresh);
8282        assert!(output_text(&warm).concat().contains('-'));
8283    }
8284
8285    #[test]
8286    fn inherited_run_language_hyphenates_but_generated_fields_do_not() {
8287        let mut inherited = hyphenation_input(true, None, false);
8288        inherited
8289            .styles
8290            .doc_defaults
8291            .as_mut()
8292            .unwrap()
8293            .rpr
8294            .as_mut()
8295            .unwrap()
8296            .language = Some("en-GB".to_owned());
8297        assert!(
8298            output_text(&deterministic_layout(&inherited))
8299                .concat()
8300                .contains('-')
8301        );
8302
8303        let mut field = hyphenation_input(true, Some("en-US"), false);
8304        let BodyContent::Paragraph(paragraph) = &mut field.document.body.content[0] else {
8305            panic!("expected paragraph")
8306        };
8307        paragraph.runs[0].content = vec![RunContent::Field(Field::new("DATE", "representation"))];
8308        assert_eq!(
8309            output_text(&deterministic_layout(&field)).concat(),
8310            "representation"
8311        );
8312    }
8313
8314    #[test]
8315    fn mixed_languages_and_table_paragraphs_keep_hyphenation_run_local() {
8316        use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
8317        use rdocx_oxml::text::CT_R;
8318
8319        let mut paragraph = CT_P::new();
8320        paragraph.properties = Some(CT_PPr {
8321            ind_right: Some(rdocx_oxml::units::Twips(8_500)),
8322            ..Default::default()
8323        });
8324        for (text, language) in [("representation ", "en-US"), ("rappresentazione", "it-IT")] {
8325            let mut run = CT_R::new(text);
8326            run.properties = Some(rdocx_oxml::properties::CT_RPr {
8327                language: Some(language.to_owned()),
8328                ..Default::default()
8329            });
8330            paragraph.runs.push(run);
8331        }
8332
8333        let mut cell = CT_Tc::new();
8334        cell.content = vec![CellContent::Paragraph(paragraph)];
8335        let mut row = CT_Row::new();
8336        row.cells.push(cell);
8337        let mut table = CT_Tbl::new();
8338        table.rows.push(row);
8339        let mut input = make_input_with_text("");
8340        input.automatic_hyphenation = true;
8341        input.document.body.content = vec![BodyContent::Table(table)];
8342
8343        let text = output_text(&deterministic_layout(&input));
8344        assert!(text.iter().any(|item| item == "-"), "{text:?}");
8345        assert!(
8346            text.iter().any(|item| item == "rappresentazione"),
8347            "{text:?}"
8348        );
8349    }
8350
8351    #[test]
8352    fn oversized_caller_aliases_have_one_bounded_reusable_identity() {
8353        fn retained_bytes(aliases: &[(String, String)]) -> usize {
8354            aliases
8355                .iter()
8356                .map(|(requested, target)| requested.len() + target.len())
8357                .sum()
8358        }
8359
8360        fn assert_compatible_after_bound(
8361            input: &LayoutInput,
8362            first: &[(String, String)],
8363            second: &[(String, String)],
8364        ) {
8365            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8366            engine.set_caller_font_aliases(first);
8367            assert!(engine.caller_font_aliases.len() <= CALLER_ALIAS_MAX_ENTRIES);
8368            assert!(retained_bytes(&engine.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES);
8369            engine.layout(input).expect("prime reusable engine");
8370            let context = engine
8371                .paragraph_cache_context
8372                .as_ref()
8373                .expect("layout retains its context");
8374            assert_eq!(context.caller_font_aliases, engine.caller_font_aliases);
8375            assert!(context.caller_font_aliases.len() <= CALLER_ALIAS_MAX_ENTRIES);
8376            assert!(
8377                retained_bytes(&context.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES
8378            );
8379
8380            let mut source = Some(engine);
8381            assert!(
8382                Engine::take_if_compatible_with_caller_aliases(&mut source, input, second)
8383                    .is_some(),
8384                "aliases discarded by the bounds must not change reusable identity"
8385            );
8386            assert!(source.is_none());
8387        }
8388
8389        let retained_prefix = (0..CALLER_ALIAS_MAX_ENTRIES)
8390            .map(|index| (format!("Document Serif {index}"), "Caladea".to_owned()))
8391            .collect::<Vec<_>>();
8392        let mut entry_limited_a = retained_prefix.clone();
8393        entry_limited_a.push(("discarded entry a".to_owned(), "Caladea".to_owned()));
8394        let mut entry_limited_b = retained_prefix;
8395        entry_limited_b.push(("discarded entry b".to_owned(), "Carlito".to_owned()));
8396        let input = make_input_with_text("bounded caller aliases");
8397        let mut boundary_engine = Engine::new_deterministic().expect("bundled fonts load");
8398        boundary_engine.set_caller_font_aliases(&entry_limited_a);
8399        assert_eq!(
8400            boundary_engine.caller_font_aliases.as_slice(),
8401            &entry_limited_a[..CALLER_ALIAS_MAX_ENTRIES]
8402        );
8403        assert_compatible_after_bound(&input, &entry_limited_a, &entry_limited_b);
8404
8405        let retained_large = ("x".repeat(32_760), String::new());
8406        let byte_limited_a = vec![
8407            retained_large.clone(),
8408            ("discarded bytes a".to_owned(), "Caladea".to_owned()),
8409        ];
8410        let byte_limited_b = vec![
8411            retained_large,
8412            ("discarded bytes b".to_owned(), "Carlito".to_owned()),
8413        ];
8414        boundary_engine.set_caller_font_aliases(&byte_limited_a);
8415        assert_eq!(
8416            boundary_engine.caller_font_aliases.as_slice(),
8417            &byte_limited_a[..1]
8418        );
8419        assert_compatible_after_bound(&input, &byte_limited_a, &byte_limited_b);
8420
8421        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8422        engine.set_caller_font_aliases(&[("x".repeat(70_000), "Caladea".to_owned())]);
8423        assert!(retained_bytes(&engine.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES);
8424        assert!(engine.caller_font_aliases.is_empty());
8425        let context = ReusableEngineContext::for_input(&input, &engine.caller_font_aliases);
8426        assert!(retained_bytes(&context.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES);
8427    }
8428
8429    fn header_footer_part(text: &str) -> rdocx_oxml::header_footer::CT_HdrFtr {
8430        let mut part = rdocx_oxml::header_footer::CT_HdrFtr::new();
8431        let mut paragraph = CT_P::new();
8432        paragraph.add_run(text);
8433        part.paragraphs.push(paragraph);
8434        part
8435    }
8436
8437    fn image_watermark_header(text: &str, width_pt: f64) -> rdocx_oxml::header_footer::CT_HdrFtr {
8438        rdocx_oxml::header_footer::CT_HdrFtr::from_xml(
8439            format!(
8440                r#"<w:hdr xmlns:w="{}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:p><w:r><w:pict><v:shape style="width:{width_pt}pt;height:36pt"><v:fill opacity=".5"/><v:imagedata r:id="rIdWatermark"/></v:shape></w:pict><w:t>{text}</w:t></w:r></w:p></w:hdr>"#,
8441                rdocx_oxml::namespace::W_NS
8442            )
8443            .as_bytes(),
8444        )
8445        .expect("watermark header parses")
8446    }
8447
8448    fn cacheable_header_footer_input(body: &str) -> LayoutInput {
8449        use rdocx_oxml::header_footer::HdrFtrRef;
8450
8451        let mut input = make_input_with_text(body);
8452        let mut section = CT_SectPr::default_letter();
8453        section.title_pg = Some(true);
8454        for (variant, suffix) in [
8455            (HdrFtrType::Default, "default"),
8456            (HdrFtrType::First, "first"),
8457            (HdrFtrType::Even, "even"),
8458        ] {
8459            let header_id = format!("rId-{suffix}-header");
8460            let footer_id = format!("rId-{suffix}-footer");
8461            section.header_refs.push(HdrFtrRef {
8462                hdr_ftr_type: variant,
8463                rel_id: header_id.clone(),
8464            });
8465            section.footer_refs.push(HdrFtrRef {
8466                hdr_ftr_type: variant,
8467                rel_id: footer_id.clone(),
8468            });
8469            let header = if variant == HdrFtrType::Default {
8470                image_watermark_header(&format!("{suffix} header"), 72.0)
8471            } else {
8472                header_footer_part(&format!("{suffix} header"))
8473            };
8474            input.headers.insert(header_id, header);
8475            input
8476                .footers
8477                .insert(footer_id, header_footer_part(&format!("{suffix} footer")));
8478        }
8479        input.images.insert(
8480            "rId-default-header\0rIdWatermark".to_owned(),
8481            ImageData {
8482                data: vec![1, 2, 3, 4],
8483                content_type: "image/png".to_owned(),
8484            },
8485        );
8486        input.document.body.sect_pr = Some(section);
8487        input
8488    }
8489
8490    fn header_footer_page_text(page: &PageFrame) -> String {
8491        compatibility_page_elements(page)
8492            .into_iter()
8493            .filter_map(|element| match element {
8494                PositionedElement::Text(text) => Some(text.text.as_str()),
8495                _ => None,
8496            })
8497            .collect()
8498    }
8499
8500    fn assert_header_footer_context_miss(
8501        base: &LayoutInput,
8502        name: &str,
8503        mutate: impl FnOnce(&mut LayoutInput),
8504    ) {
8505        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8506        engine.layout(base).expect("prime exact identity");
8507        let mut changed = base.clone();
8508        mutate(&mut changed);
8509        engine
8510            .layout(&changed)
8511            .unwrap_or_else(|error| panic!("{name}: {error}"));
8512        assert_eq!(engine.header_footer_cache_counts(), (0, 12), "{name}");
8513    }
8514
8515    #[test]
8516    fn safe_header_footer_variants_reuse_exactly() {
8517        let mut input = cacheable_header_footer_input(&"body ".repeat(4_000));
8518        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8519        let (cold, cold_sources) = engine
8520            .layout_with_provenance(&input)
8521            .expect("cold header/footer layout");
8522        assert!(cold.pages.len() > 2);
8523        assert!(header_footer_page_text(&cold.pages[0]).contains("first header"));
8524        assert!(header_footer_page_text(&cold.pages[0]).contains("first footer"));
8525        assert!(header_footer_page_text(&cold.pages[1]).contains("even header"));
8526        assert!(header_footer_page_text(&cold.pages[1]).contains("even footer"));
8527        assert!(header_footer_page_text(&cold.pages[2]).contains("default header"));
8528        assert!(header_footer_page_text(&cold.pages[2]).contains("default footer"));
8529        assert_eq!(engine.header_footer_cache_counts(), (0, 6));
8530        assert_eq!(engine.header_footer_cache.len(), 6);
8531        assert!(
8532            engine
8533                .header_footer_cache
8534                .iter()
8535                .all(|entry| !entry.font_trace.is_empty())
8536        );
8537
8538        for (index, entry) in engine.header_footer_cache.iter_mut().enumerate() {
8539            entry.diagnostics = vec![Diagnostic {
8540                message: format!("cached header/footer diagnostic {index}"),
8541            }];
8542        }
8543        input.document.body.content.insert(
8544            0,
8545            BodyContent::Paragraph({
8546                let mut paragraph = CT_P::new();
8547                paragraph.add_run("inserted body source");
8548                paragraph
8549            }),
8550        );
8551        let (warm, warm_sources) = engine
8552            .layout_with_provenance(&input)
8553            .expect("warm header/footer layout");
8554        let (fresh, fresh_sources) = Engine::new_deterministic()
8555            .expect("bundled fonts load")
8556            .layout_with_provenance(&input)
8557            .expect("fresh comparison layout");
8558        assert_eq!(format!("{:?}", warm.pages), format!("{:?}", fresh.pages));
8559        assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", fresh.fonts));
8560        assert_eq!(
8561            format!("{:?}", warm.outlines),
8562            format!("{:?}", fresh.outlines)
8563        );
8564        assert_eq!(warm_sources, fresh_sources);
8565        assert_ne!(cold_sources, warm_sources);
8566        assert_eq!(engine.header_footer_cache_counts(), (6, 6));
8567        assert_eq!(warm.diagnostics.len(), 6);
8568        assert!(warm.diagnostics.iter().all(|diagnostic| {
8569            diagnostic
8570                .message
8571                .starts_with("cached header/footer diagnostic")
8572        }));
8573        let header_source = warm
8574            .pages
8575            .iter()
8576            .flat_map(|page| compatibility_page_elements(page))
8577            .filter_map(|element| match element {
8578                PositionedElement::Text(text) if text.text.contains("header") => text.source,
8579                _ => None,
8580            })
8581            .next()
8582            .expect("cached header text has provenance");
8583        assert!(matches!(
8584            warm_sources[header_source.node.get() as usize - 1].story,
8585            WordStory::Header { .. }
8586        ));
8587
8588        // F-X042 resolves inherited references onto each section before layout.
8589        // Model that exact input shape and prove both the authored first section
8590        // and inherited final section reuse their variants on the next layout.
8591        let mut inherited_input = cacheable_header_footer_input(&"second section ".repeat(2_000));
8592        let inherited_section = inherited_input
8593            .document
8594            .body
8595            .sect_pr
8596            .clone()
8597            .expect("final section");
8598        let mut first_section_end = CT_P::new();
8599        first_section_end.add_run("authored section with shared variants");
8600        first_section_end.properties.get_or_insert_default().sect_pr = Some(inherited_section);
8601        inherited_input
8602            .document
8603            .body
8604            .content
8605            .insert(0, BodyContent::Paragraph(first_section_end));
8606        let mut inherited_engine = Engine::new_deterministic().expect("bundled fonts load");
8607        inherited_engine
8608            .layout_with_provenance(&inherited_input)
8609            .expect("cold inherited layout");
8610        let (inherited_warm, inherited_sources) = inherited_engine
8611            .layout_with_provenance(&inherited_input)
8612            .expect("warm inherited layout");
8613        let (inherited_fresh, fresh_inherited_sources) = Engine::new_deterministic()
8614            .expect("bundled fonts load")
8615            .layout_with_provenance(&inherited_input)
8616            .expect("fresh inherited layout");
8617        assert_eq!(inherited_engine.header_footer_cache_counts(), (12, 12));
8618        assert_eq!(
8619            format!("{:?}", inherited_warm.pages),
8620            format!("{:?}", inherited_fresh.pages)
8621        );
8622        assert_eq!(inherited_sources, fresh_inherited_sources);
8623    }
8624
8625    #[test]
8626    fn header_footer_media_geometry_and_context_changes_miss() {
8627        use rdocx_oxml::footnotes::CT_Footnotes;
8628        use rdocx_oxml::numbering::CT_Numbering;
8629        use rdocx_oxml::theme::Theme;
8630        use rdocx_oxml::units::Twips;
8631
8632        let base = cacheable_header_footer_input("body");
8633        assert_header_footer_context_miss(&base, "header text", |input| {
8634            input.headers.insert(
8635                "rId-first-header".to_owned(),
8636                header_footer_part("changed first header"),
8637            );
8638        });
8639        assert_header_footer_context_miss(&base, "media bytes", |input| {
8640            input
8641                .images
8642                .get_mut("rId-default-header\0rIdWatermark")
8643                .expect("watermark image")
8644                .data
8645                .push(5);
8646        });
8647        assert_header_footer_context_miss(&base, "watermark", |input| {
8648            input.headers.insert(
8649                "rId-default-header".to_owned(),
8650                image_watermark_header("default header", 73.0),
8651            );
8652        });
8653        assert_header_footer_context_miss(&base, "same-width page height", |input| {
8654            input
8655                .document
8656                .body
8657                .sect_pr
8658                .as_mut()
8659                .expect("section")
8660                .page_height = Some(Twips(15_841));
8661        });
8662        assert_header_footer_context_miss(&base, "styles", |input| {
8663            input.styles = CT_Styles::new();
8664        });
8665        assert_header_footer_context_miss(&base, "numbering", |input| {
8666            input.numbering = Some(CT_Numbering::new());
8667        });
8668        assert_header_footer_context_miss(&base, "notes", |input| {
8669            input.footnotes = Some(CT_Footnotes::new());
8670        });
8671        assert_header_footer_context_miss(&base, "theme", |input| {
8672            input.theme = Some(Theme::default());
8673        });
8674        assert_header_footer_context_miss(&base, "revision", |input| {
8675            input.revision_view = RevisionView::Tracked;
8676        });
8677        assert_header_footer_context_miss(&base, "fonts", |input| {
8678            let (family, data) = oxml_layout::bundled_fonts::bundled_font_data()[0];
8679            input.fonts.push(oxml_layout::FontFile {
8680                family: family.to_owned(),
8681                data: data.to_vec(),
8682            });
8683        });
8684
8685        let mut source_mode = Engine::new_deterministic().expect("bundled fonts load");
8686        source_mode.layout(&base).expect("prime unsourced cache");
8687        source_mode
8688            .layout_with_provenance(&base)
8689            .expect("sourced layout misses unsourced entries");
8690        assert_eq!(source_mode.header_footer_cache_counts(), (0, 12));
8691
8692        let mut unsafe_input = base.clone();
8693        unsafe_input
8694            .headers
8695            .get_mut("rId-first-header")
8696            .expect("first header")
8697            .paragraphs[0]
8698            .properties
8699            .get_or_insert_default()
8700            .num_id = Some(1);
8701        let mut unsafe_engine = Engine::new_deterministic().expect("bundled fonts load");
8702        unsafe_engine
8703            .layout(&unsafe_input)
8704            .expect("unsafe part lays out");
8705        assert_eq!(unsafe_engine.header_footer_cache_counts(), (0, 5));
8706
8707        let mut opaque_input = base.clone();
8708        let opaque_run = &mut opaque_input
8709            .headers
8710            .get_mut("rId-first-header")
8711            .expect("first header")
8712            .paragraphs[0]
8713            .runs[0];
8714        opaque_run
8715            .extra_xml
8716            .push(br#"<w:object xmlns:w="urn:unrepresented"/>"#.to_vec());
8717        opaque_run.extra_xml_positions.push(0);
8718        let mut opaque_engine = Engine::new_deterministic().expect("bundled fonts load");
8719        opaque_engine
8720            .layout(&opaque_input)
8721            .expect("opaque producer XML lays out without reuse");
8722        assert_eq!(opaque_engine.header_footer_cache_counts(), (0, 5));
8723
8724        let foreign_wrapper = rdocx_oxml::header_footer::CT_HdrFtr::from_xml(
8725            format!(
8726                r#"<w:hdr xmlns:w="{}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:producer"><w:p><w:r><x:pict><w:pict><v:shape style="width:72pt;height:36pt"><v:textpath string="DRAFT"/></v:shape></w:pict></x:pict></w:r></w:p></w:hdr>"#,
8727                rdocx_oxml::namespace::W_NS
8728            )
8729            .as_bytes(),
8730        )
8731        .expect("foreign pict wrapper parses");
8732        assert_eq!(foreign_wrapper.watermarks().len(), 1);
8733        assert!(!header_footer_part_is_cache_safe(
8734            &foreign_wrapper,
8735            &base.styles
8736        ));
8737        let rebound_word_prefix = rdocx_oxml::header_footer::CT_HdrFtr::from_xml(
8738            format!(
8739                r#"<q:hdr xmlns:q="{}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:producer"><q:p><q:r><w:pict><q:pict><v:shape style="width:72pt;height:36pt"><v:textpath string="DRAFT"/></v:shape></q:pict></w:pict></q:r></q:p></q:hdr>"#,
8740                rdocx_oxml::namespace::W_NS
8741            )
8742            .as_bytes(),
8743        )
8744        .expect("rebound conventional prefix parses");
8745        assert_eq!(rebound_word_prefix.watermarks().len(), 1);
8746        assert!(!header_footer_part_is_cache_safe(
8747            &rebound_word_prefix,
8748            &base.styles
8749        ));
8750    }
8751
8752    #[test]
8753    fn header_footer_cache_publishes_transactionally_and_stays_bounded() {
8754        use rdocx_oxml::header_footer::HdrFtrRef;
8755
8756        let (valid_family, valid_bytes) = oxml_layout::bundled_fonts::bundled_font_data()[0];
8757        let (invalid_family, invalid_source) = oxml_layout::bundled_fonts::bundled_font_data()[4];
8758        let mut invalid_bytes = invalid_source.to_vec();
8759        let table_count = u16::from_be_bytes([invalid_bytes[4], invalid_bytes[5]]) as usize;
8760        let head_offset = (0..table_count)
8761            .find_map(|table| {
8762                let record = 12 + table * 16;
8763                (&invalid_bytes[record..record + 4] == b"head").then(|| {
8764                    u32::from_be_bytes(
8765                        invalid_bytes[record + 8..record + 12]
8766                            .try_into()
8767                            .expect("head offset"),
8768                    ) as usize
8769                })
8770            })
8771            .expect("font has head table");
8772        invalid_bytes[head_offset + 18..head_offset + 20].copy_from_slice(&0u16.to_be_bytes());
8773
8774        let mut failing_input = make_input_with_text("section-ending prefix");
8775        let mut section = CT_SectPr::default_letter();
8776        section.header_refs.push(HdrFtrRef {
8777            hdr_ftr_type: HdrFtrType::Default,
8778            rel_id: "rIdHeader".to_owned(),
8779        });
8780        let BodyContent::Paragraph(prefix) = &mut failing_input.document.body.content[0] else {
8781            panic!("prefix paragraph");
8782        };
8783        prefix.properties.get_or_insert_default().sect_pr = Some(section);
8784        prefix.runs[0].properties.get_or_insert_default().font_ascii =
8785            Some(valid_family.to_owned());
8786        failing_input.headers.insert(
8787            "rIdHeader".to_owned(),
8788            header_footer_part("staged header before late failure"),
8789        );
8790        let mut later = CT_P::new();
8791        later
8792            .add_run("late font failure")
8793            .properties
8794            .get_or_insert_default()
8795            .font_ascii = Some(invalid_family.to_owned());
8796        failing_input.document.body.add_paragraph(later);
8797        failing_input.fonts.push(oxml_layout::FontFile {
8798            family: invalid_family.to_owned(),
8799            data: invalid_bytes,
8800        });
8801        let mut failing = Engine::with_font_manager(FontManager::new_with_fonts(vec![(
8802            valid_family.to_owned(),
8803            valid_bytes.to_vec(),
8804        )]));
8805        assert!(failing.layout(&failing_input).is_err());
8806        assert!(failing.header_footer_cache.is_empty());
8807        assert_eq!(failing.header_footer_cache_bytes, 0);
8808        assert_eq!(failing.header_footer_cache_counts(), (0, 1));
8809
8810        let mut bounded_input = make_input_with_text("bounded body");
8811        let mut bounded_section = CT_SectPr::default_letter();
8812        for index in 0..(HEADER_FOOTER_CACHE_MAX_ENTRIES * 2) {
8813            let relationship_id = format!("rIdHeader{index:03}");
8814            bounded_section.header_refs.push(HdrFtrRef {
8815                hdr_ftr_type: HdrFtrType::Default,
8816                rel_id: relationship_id.clone(),
8817            });
8818            bounded_input.headers.insert(
8819                relationship_id,
8820                header_footer_part(&format!("bounded header {index:03}")),
8821            );
8822        }
8823        bounded_input.document.body.sect_pr = Some(bounded_section);
8824        let mut bounded = Engine::new_deterministic().expect("bundled fonts load");
8825        bounded
8826            .layout(&bounded_input)
8827            .expect("bounded pending layout succeeds");
8828        assert_eq!(
8829            bounded.header_footer_cache.len(),
8830            HEADER_FOOTER_CACHE_MAX_ENTRIES
8831        );
8832        assert!(bounded.header_footer_cache_bytes <= HEADER_FOOTER_CACHE_MAX_BYTES);
8833        assert!(
8834            bounded.pending_header_footer_cache_peak_entries <= HEADER_FOOTER_CACHE_MAX_ENTRIES
8835        );
8836        assert!(bounded.pending_header_footer_cache_peak_bytes <= HEADER_FOOTER_CACHE_MAX_BYTES);
8837        assert_eq!(
8838            bounded.header_footer_cache_bytes,
8839            bounded
8840                .header_footer_cache
8841                .iter()
8842                .map(|entry| entry.bytes)
8843                .sum::<usize>()
8844        );
8845        assert!(
8846            bounded.paragraph_cache.len()
8847                + bounded.table_cache.len()
8848                + bounded.header_footer_cache.len()
8849                + bounded
8850                    .restart_cache
8851                    .as_ref()
8852                    .map_or(0, |cache| cache.checkpoints.len())
8853                <= CACHE_MAX_ENTRIES
8854        );
8855        assert!(
8856            bounded.paragraph_cache_bytes
8857                + bounded.table_cache_bytes
8858                + bounded.header_footer_cache_bytes
8859                + bounded
8860                    .restart_cache
8861                    .as_ref()
8862                    .map_or(0, |cache| cache.bytes)
8863                <= CACHE_MAX_BYTES
8864        );
8865
8866        let one = cacheable_header_footer_input("oversized entry body");
8867        let mut oversized = Engine::new_deterministic().expect("bundled fonts load");
8868        oversized.layout(&one).expect("prime oversized template");
8869        let mut entry = oversized
8870            .header_footer_cache
8871            .pop_front()
8872            .expect("header/footer template retained");
8873        let mut oversized_key = oversized
8874            .header_footer_cache
8875            .pop_front()
8876            .expect("second header/footer template retained");
8877        oversized.header_footer_cache.clear();
8878        oversized.header_footer_cache_bytes = 0;
8879        let mut reserved_namespace = String::with_capacity(HEADER_FOOTER_CACHE_MAX_BYTES + 1);
8880        reserved_namespace.push('x');
8881        oversized_key
8882            .key
8883            .part
8884            .extra_namespaces
8885            .push((reserved_namespace, "urn:test".to_owned()));
8886        oversized_key.bytes = header_footer_cache_entry_bytes(
8887            &oversized_key.key,
8888            &oversized_key.content,
8889            &oversized_key.diagnostics,
8890            &oversized_key.font_trace,
8891        );
8892        assert!(oversized_key.bytes > HEADER_FOOTER_CACHE_MAX_BYTES);
8893        oversized.publish_header_footer_cache_entry(oversized_key);
8894        assert!(oversized.header_footer_cache.is_empty());
8895        assert_eq!(oversized.header_footer_cache_bytes, 0);
8896
8897        let text = entry.content.blocks[0]
8898            .lines
8899            .iter_mut()
8900            .flat_map(|line| &mut line.items)
8901            .find_map(|item| match item {
8902                LineItem::Text(text) => Some(text),
8903                _ => None,
8904            })
8905            .expect("template has text");
8906        text.advances = vec![0.0; HEADER_FOOTER_CACHE_MAX_BYTES / 8 + 1];
8907        entry.bytes = header_footer_cache_entry_bytes(
8908            &entry.key,
8909            &entry.content,
8910            &entry.diagnostics,
8911            &entry.font_trace,
8912        );
8913        assert!(entry.bytes > HEADER_FOOTER_CACHE_MAX_BYTES);
8914        oversized.publish_header_footer_cache_entry(entry);
8915        assert!(oversized.header_footer_cache.is_empty());
8916        assert_eq!(oversized.header_footer_cache_bytes, 0);
8917    }
8918
8919    #[test]
8920    fn word_projection_leaves_break_segmentation_to_shared_layout() {
8921        let input = make_input_with_text("financial planning");
8922        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8923            panic!("expected paragraph");
8924        };
8925        let media = MediaRegistry::new(&input.images);
8926        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8927        let mut numbering = NumberingState::new();
8928        let mut diagnostics = Vec::new();
8929        let block = layout_paragraph_with_source(
8930            paragraph,
8931            468.0,
8932            &input.styles,
8933            &input,
8934            &media,
8935            &mut fonts,
8936            &mut numbering,
8937            &mut diagnostics,
8938            SourceNodeId::new(1),
8939        )
8940        .expect("paragraph lays out");
8941        let text_items = block
8942            .reflow
8943            .expect("line-breaking inputs retained")
8944            .items
8945            .into_iter()
8946            .filter_map(|item| match item {
8947                InlineItem::Text(segment) => Some(segment),
8948                _ => None,
8949            })
8950            .collect::<Vec<_>>();
8951
8952        assert_eq!(text_items.len(), 1);
8953        assert_eq!(text_items[0].text, "financial planning");
8954        assert_eq!(text_items[0].source.expect("source span").char_start, 0);
8955        assert_eq!(text_items[0].source.expect("source span").char_end, 18);
8956    }
8957
8958    #[test]
8959    fn mixed_script_fallback_uses_each_covering_font_without_boxes() {
8960        let input = make_input_with_text("Latin العربية देवनागरी ภาษาไทย 你好世界");
8961        let result = crate::layout_document_deterministic_with_provenance(&input)
8962            .expect("deterministic multilingual layout");
8963        let runs = multilingual_runs(&result.layout);
8964
8965        assert!(
8966            !runs.is_empty(),
8967            "Word layout still emits only legacy glyph runs"
8968        );
8969        for script in [
8970            TextScript::Latin,
8971            TextScript::Arabic,
8972            TextScript::Devanagari,
8973            TextScript::Thai,
8974            TextScript::Han,
8975        ] {
8976            assert!(
8977                runs.iter().any(|run| run.script == script),
8978                "missing {script:?} span"
8979            );
8980        }
8981        assert!(
8982            runs.iter().all(|run| !run.glyph_ids.contains(&0)),
8983            "deterministic fallback emitted a .notdef glyph: {:?}",
8984            runs.iter()
8985                .map(|run| (&run.logical_text, run.script, &run.glyph_ids))
8986                .collect::<Vec<_>>()
8987        );
8988    }
8989
8990    #[test]
8991    fn complex_shaping_preserves_clusters_offsets_and_logical_source_spans() {
8992        let text = "سلام क्षि ภาษาไทย 你好世界";
8993        let input = make_input_with_text(text);
8994        let result = crate::layout_document_deterministic_with_provenance(&input)
8995            .expect("deterministic multilingual layout");
8996        let mut runs = multilingual_runs(&result.layout);
8997
8998        assert!(!runs.is_empty(), "Word did not consume rich shaped spans");
8999        runs.sort_by_key(|run| run.logical_index);
9000        assert_eq!(
9001            runs.iter()
9002                .map(|run| run.logical_text.as_str())
9003                .collect::<String>(),
9004            text
9005        );
9006        assert!(runs.iter().all(|run| run.is_valid()));
9007        assert!(runs.iter().all(|run| run.source.is_some()));
9008        assert!(runs.iter().any(|run| {
9009            run.script == TextScript::Devanagari
9010                && run
9011                    .clusters
9012                    .iter()
9013                    .any(|cluster| cluster.char_end - cluster.char_start > 1)
9014        }));
9015    }
9016
9017    #[test]
9018    fn rich_mixed_script_paragraph_retains_conditional_hyphenation() {
9019        let mut input = hyphenation_input(true, Some("en-US"), false);
9020        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9021            panic!("expected paragraph")
9022        };
9023        let mut arabic = CT_R::new(" العربية");
9024        arabic.properties = Some(rdocx_oxml::properties::CT_RPr {
9025            language: Some("en-US".to_owned()),
9026            language_bidi: Some("ar-SA".to_owned()),
9027            ..Default::default()
9028        });
9029        paragraph.runs.push(arabic);
9030
9031        let output = deterministic_layout(&input);
9032        let text = output_text(&output);
9033        assert!(text.iter().any(|item| item == "-"), "{text:?}");
9034        assert!(
9035            multilingual_runs(&output)
9036                .iter()
9037                .any(|run| run.script == TextScript::Arabic),
9038            "the Arabic run must still use rich shaping"
9039        );
9040    }
9041
9042    #[test]
9043    fn one_mixed_text_node_uses_each_effective_word_language_slot() {
9044        let text = "Latin العربية 你好";
9045        let mut input = make_input_with_text(text);
9046        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9047            panic!("expected paragraph")
9048        };
9049        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
9050            language: Some("en-US".to_owned()),
9051            language_east_asia: Some("zh-CN".to_owned()),
9052            language_bidi: Some("ar-SA".to_owned()),
9053            ..Default::default()
9054        });
9055        let result = crate::layout_document_deterministic_with_provenance(&input)
9056            .expect("deterministic multilingual layout");
9057        let runs = multilingual_runs(&result.layout);
9058
9059        for (script, language) in [
9060            (TextScript::Latin, "en-US"),
9061            (TextScript::Arabic, "ar-SA"),
9062            (TextScript::Han, "zh-CN"),
9063        ] {
9064            let run = runs
9065                .iter()
9066                .find(|run| run.script == script && run.language.as_deref() == Some(language))
9067                .unwrap_or_else(|| {
9068                    panic!(
9069                        "missing {script:?} with {language}: {:?}",
9070                        runs.iter()
9071                            .map(|run| (
9072                                run.script,
9073                                run.language.as_deref(),
9074                                run.logical_text.as_str()
9075                            ))
9076                            .collect::<Vec<_>>()
9077                    )
9078                });
9079            let source = run.source.unwrap_or_else(|| {
9080                panic!(
9081                    "mixed-language {script:?} span {:?} retains source: {:?}",
9082                    run.logical_text,
9083                    runs.iter()
9084                        .map(|run| (&run.logical_text, run.script, run.source))
9085                        .collect::<Vec<_>>()
9086                )
9087            });
9088            let source_text = text
9089                .chars()
9090                .skip(source.char_start as usize)
9091                .take((source.char_end - source.char_start) as usize)
9092                .collect::<String>();
9093            assert_eq!(source_text, run.logical_text);
9094        }
9095    }
9096
9097    #[test]
9098    fn rich_stored_field_retains_resolved_language_and_character_spacing() {
9099        for (xml, expected) in [
9100            (
9101                r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:fldSimple w:instr="DATE"><w:r><w:rPr><w:spacing w:val="40"/><w:lang w:val="en-US"/></w:rPr><w:t>stored</w:t></w:r></w:fldSimple><w:r><w:rPr><w:lang w:bidi="ar-SA"/></w:rPr><w:t> العربية</w:t></w:r></w:p></w:body></w:document>"#,
9102                "stored",
9103            ),
9104            (
9105                r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:bookmarkStart w:id="1" w:name="target"/><w:r><w:t>resolved</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p><w:p><w:fldSimple w:instr="REF target"><w:r><w:rPr><w:spacing w:val="40"/><w:lang w:val="en-US"/></w:rPr><w:t>cached</w:t></w:r></w:fldSimple><w:r><w:rPr><w:lang w:bidi="ar-SA"/></w:rPr><w:t> العربية</w:t></w:r></w:p></w:body></w:document>"#,
9106                "resolved",
9107            ),
9108        ] {
9109            let mut input = make_input_with_text("");
9110            input.document =
9111                rdocx_oxml::CT_Document::from_xml(xml.as_bytes()).expect("stored field XML parses");
9112            let output = deterministic_layout(&input);
9113            let run = multilingual_runs(&output)
9114                .into_iter()
9115                .find(|run| {
9116                    run.logical_text == expected && run.language.as_deref() == Some("en-US")
9117                })
9118                .expect("stored or resolved field is rich-shaped with its language");
9119
9120            let family = output
9121                .fonts
9122                .iter()
9123                .find(|font| font.id == run.font_id)
9124                .expect("field font is present")
9125                .family
9126                .clone();
9127            let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
9128            let font_id = fonts
9129                .resolve_font(Some(&family), run.bold, run.italic)
9130                .expect("field font resolves");
9131            let unspaced = fonts
9132                .shape_text(font_id, &run.logical_text, run.font_size)
9133                .expect("field text reshapes");
9134            assert_eq!(run.x_advances.len(), unspaced.advances.len());
9135            assert!(
9136                run.x_advances
9137                    .iter()
9138                    .zip(unspaced.advances)
9139                    .all(|(actual, unspaced)| (*actual - unspaced - 2.0).abs() < 0.001),
9140                "stored or resolved field spacing was not retained"
9141            );
9142        }
9143    }
9144
9145    #[test]
9146    fn exact_word_lines_place_every_complex_script_on_the_word_em_baseline() {
9147        for (family, language_attributes, text) in [
9148            (
9149                "Noto Sans Arabic",
9150                r#"w:val="ar-SA" w:bidi="ar-SA""#,
9151                "العربية مرحبا بالعالم",
9152            ),
9153            (
9154                "Noto Sans Devanagari",
9155                r#"w:val="hi-IN""#,
9156                "देवनागरी नमस्ते दुनिया",
9157            ),
9158            ("Noto Sans Thai", r#"w:val="th-TH""#, "ภาษาไทยยินดีต้อนรับ"),
9159            (
9160                "Noto Sans SC",
9161                r#"w:val="zh-CN" w:eastAsia="zh-CN""#,
9162                "〈中〉、你好世界",
9163            ),
9164        ] {
9165            let xml = format!(
9166                r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:spacing w:after="0" w:line="480" w:lineRule="exact"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="{family}" w:hAnsi="{family}" w:eastAsia="{family}" w:cs="{family}"/><w:sz w:val="48"/><w:szCs w:val="48"/><w:lang {language_attributes}/></w:rPr><w:t>{text}</w:t></w:r></w:p><w:sectPr><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>"#
9167            );
9168            let mut input = make_input_with_text("");
9169            input.document = rdocx_oxml::CT_Document::from_xml(xml.as_bytes())
9170                .expect("complex-script metric fixture parses");
9171            let result = crate::layout_document_deterministic_with_provenance(&input)
9172                .expect("complex-script metric fixture lays out");
9173            let runs = multilingual_runs(&result.layout);
9174            let first = runs
9175                .iter()
9176                .min_by_key(|run| run.logical_index)
9177                .expect("fixture emits rich text");
9178            assert!(
9179                (first.origin.y - 91.2).abs() < 0.001,
9180                "{family} baseline was {}, expected 91.2",
9181                first.origin.y
9182            );
9183        }
9184    }
9185
9186    #[test]
9187    fn latin_shaping_and_hash_outputs_remain_byte_identical() {
9188        let text = "financial العربية";
9189        let input = make_input_with_text(text);
9190        let result = crate::layout_document_deterministic_with_provenance(&input)
9191            .expect("deterministic Latin layout");
9192        let mut runs = multilingual_runs(&result.layout);
9193
9194        assert!(
9195            !runs.is_empty(),
9196            "Word has not migrated to the shared rich path"
9197        );
9198        runs.sort_by_key(|run| run.logical_index);
9199        assert_eq!(
9200            runs.iter()
9201                .map(|run| run.logical_text.as_str())
9202                .collect::<String>(),
9203            text
9204        );
9205        let latin = runs
9206            .iter()
9207            .find(|run| run.script == TextScript::Latin && run.logical_text == "financial")
9208            .expect("mixed-script paragraph retains its Latin span");
9209        let projection = latin.legacy_projection();
9210        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
9211        let family = result
9212            .layout
9213            .fonts
9214            .iter()
9215            .find(|font| font.id == latin.font_id)
9216            .expect("Latin font is in the result")
9217            .family
9218            .clone();
9219        let font_id = fonts
9220            .resolve_font(Some(&family), latin.bold, latin.italic)
9221            .expect("bundled Latin font resolves");
9222        let independently_shaped = fonts
9223            .shape_text(font_id, &latin.logical_text, latin.font_size)
9224            .expect("Latin span shapes independently");
9225        assert_eq!(projection.glyph_ids, independently_shaped.glyph_ids);
9226        assert_eq!(projection.advances, independently_shaped.advances);
9227    }
9228
9229    #[test]
9230    fn break_opportunities_emit_every_scalar_and_glyph_once() {
9231        let text = "financial planning ttf-parser  double  spaces e\u{301}lan allocated \u{754c} "
9232            .repeat(12);
9233        let input = make_input_with_text(&text);
9234        let result = crate::layout_document_deterministic_with_provenance(&input)
9235            .expect("deterministic layout");
9236        let runs = multilingual_runs(&result.layout);
9237
9238        assert_eq!(
9239            runs.iter()
9240                .map(|run| run.logical_text.as_str())
9241                .collect::<String>(),
9242            text
9243        );
9244        let mut expected_start = 0;
9245        for run in runs {
9246            let source = run.source.expect("filtered sourced run");
9247            assert_eq!(source.char_start, expected_start);
9248            assert_eq!(
9249                source.char_end - source.char_start,
9250                run.logical_text.chars().count() as u32
9251            );
9252            expected_start = source.char_end;
9253            assert!(run.is_valid(), "{}", run.logical_text);
9254        }
9255        assert_eq!(expected_start, text.chars().count() as u32);
9256    }
9257
9258    #[test]
9259    fn reported_words_do_not_duplicate_boundary_glyphs() {
9260        for text in [
9261            "ttf-parser follows",
9262            "double  spaces follow",
9263            "financial planning",
9264            "allocated space",
9265        ] {
9266            let input = make_input_with_text(text);
9267            let result = crate::layout_document_deterministic_with_provenance(&input)
9268                .expect("deterministic layout");
9269            let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
9270            for run in result.layout.pages.iter().flat_map(|page| {
9271                compatibility_page_elements(page)
9272                    .into_iter()
9273                    .filter_map(|element| match element {
9274                        PositionedElement::Text(run) if run.source.is_some() => Some(run),
9275                        _ => None,
9276                    })
9277            }) {
9278                let family = result
9279                    .layout
9280                    .fonts
9281                    .iter()
9282                    .find(|font| font.id == run.font_id)
9283                    .expect("run font is in result")
9284                    .family
9285                    .clone();
9286                let font_id = fonts
9287                    .resolve_font(Some(&family), run.bold, run.italic)
9288                    .expect("bundled run font resolves");
9289                let independently_shaped = fonts
9290                    .shape_text(font_id, &run.text, run.font_size)
9291                    .expect("emitted chunk reshapes");
9292                assert_eq!(run.glyph_ids, independently_shaped.glyph_ids, "{text}");
9293                assert_eq!(run.advances, independently_shaped.advances, "{text}");
9294            }
9295        }
9296    }
9297
9298    #[test]
9299    fn warm_relayout_matches_cold_and_rebuilds_only_changed_safe_paragraphs() {
9300        let mut input = make_input_with_text("first cache-safe paragraph");
9301        for text in ["second cache-safe paragraph", "third cache-safe paragraph"] {
9302            let mut paragraph = CT_P::new();
9303            paragraph.add_run(text);
9304            input.document.body.add_paragraph(paragraph);
9305        }
9306
9307        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9308        let cold = warm_engine
9309            .layout_with_provenance(&input)
9310            .expect("cold layout succeeds");
9311        let after_cold = warm_engine.paragraph_cache_counts();
9312
9313        let BodyContent::Paragraph(changed) = &mut input.document.body.content[1] else {
9314            panic!("second body item is a paragraph");
9315        };
9316        changed.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
9317            "changed cache-safe paragraph",
9318        ))];
9319
9320        let warm = warm_engine
9321            .layout_with_provenance(&input)
9322            .expect("warm relayout succeeds");
9323        let after_warm = warm_engine.paragraph_cache_counts();
9324        let cold_after_edit = Engine::new_deterministic()
9325            .expect("bundled fonts load")
9326            .layout_with_provenance(&input)
9327            .expect("independent cold relayout succeeds");
9328
9329        assert_eq!(format!("{:?}", warm.0), format!("{:?}", cold_after_edit.0));
9330        assert_eq!(warm.1, cold_after_edit.1);
9331        assert_eq!(after_cold, (0, 3));
9332        assert_eq!(after_warm, (2, 4));
9333        assert_ne!(output_text(&cold.0), output_text(&warm.0));
9334    }
9335
9336    #[test]
9337    fn paragraph_and_table_fingerprint_collisions_require_typed_equality() {
9338        let first = make_input_with_text("first collision candidate");
9339        let second = make_input_with_text("second collision candidate");
9340        let BodyContent::Paragraph(second_paragraph) = &second.document.body.content[0] else {
9341            panic!("body item is a paragraph");
9342        };
9343        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9344        engine.layout(&first).expect("first layout succeeds");
9345
9346        let forced_fingerprint = paragraph_fingerprint(second_paragraph);
9347        let retained = engine
9348            .paragraph_cache
9349            .front_mut()
9350            .expect("first paragraph is retained");
9351        assert_ne!(retained.fingerprint, forced_fingerprint);
9352        retained.fingerprint = forced_fingerprint;
9353
9354        let output = engine.layout(&second).expect("collision layout succeeds");
9355        assert_eq!(output_text(&output).concat(), "second collision candidate");
9356        assert_eq!(engine.paragraph_cache_counts(), (0, 2));
9357
9358        let mut first = make_input_with_text("before first table");
9359        first.document.body.add_table(safe_table("first table"));
9360        let mut second = make_input_with_text("before first table");
9361        second.document.body.add_table(safe_table("second table"));
9362        let BodyContent::Table(second_table) = &second.document.body.content[1] else {
9363            panic!("body item is a table");
9364        };
9365        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9366        engine.layout(&first).expect("first table layout succeeds");
9367        let forced_fingerprint = table_fingerprint(second_table);
9368        let retained = engine
9369            .table_cache
9370            .front_mut()
9371            .expect("first table is retained");
9372        assert_ne!(retained.fingerprint, forced_fingerprint);
9373        retained.fingerprint = forced_fingerprint;
9374
9375        let output = engine
9376            .layout(&second)
9377            .expect("table collision layout succeeds");
9378        assert!(output_text(&output).concat().contains("second table"));
9379        assert_eq!(engine.table_cache_counts(), (0, 2));
9380    }
9381
9382    #[test]
9383    fn body_only_layout_and_transfer_do_not_rebuild_owned_context() {
9384        let input = restart_input();
9385        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9386        engine.layout(&input).expect("cold layout succeeds");
9387        assert_eq!(engine.owned_context_build_count(), 1);
9388
9389        let mut edited = input.clone();
9390        set_body_paragraph_text(&mut edited, 70, "body-only edit");
9391        engine.layout(&edited).expect("warm layout succeeds");
9392        assert_eq!(engine.owned_context_build_count(), 1);
9393
9394        let mut source = Some(engine);
9395        let transferred = Engine::take_if_compatible(&mut source, &edited)
9396            .expect("body-only restore accepts the retained engine");
9397        assert_eq!(transferred.owned_context_build_count(), 1);
9398        assert!(source.is_none());
9399    }
9400
9401    #[test]
9402    fn editor_scale_paragraph_cache_avoids_warm_thrash() {
9403        let mut input = make_input_with_text("editor paragraph 000");
9404        for index in 1..700 {
9405            let mut paragraph = CT_P::new();
9406            paragraph.add_run(&format!("editor paragraph {index:03}"));
9407            input.document.body.add_paragraph(paragraph);
9408        }
9409        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9410        engine
9411            .layout_with_provenance(&input)
9412            .expect("editor cold layout succeeds");
9413        assert_eq!(engine.paragraph_cache.len(), 700);
9414        assert_eq!(engine.paragraph_cache_counts(), (0, 700));
9415
9416        set_body_paragraph_text(&mut input, 350, "editor paragraph 350 changed");
9417        let warm = engine
9418            .layout_with_provenance(&input)
9419            .expect("editor warm layout succeeds");
9420        let cold = Engine::new_deterministic()
9421            .expect("bundled fonts load")
9422            .layout_with_provenance(&input)
9423            .expect("editor cold comparison succeeds");
9424
9425        assert_layout_results_equal(&warm.0, &cold.0);
9426        assert_eq!(warm.1, cold.1);
9427        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9428        assert_eq!(engine.paragraph_cache.len(), 701);
9429        assert_eq!(
9430            engine
9431                .paragraph_cache
9432                .front()
9433                .expect("insertion order has a front")
9434                .key
9435                .paragraph
9436                .text(),
9437            "editor paragraph 000"
9438        );
9439        let rebuilt = engine
9440            .last_rebuilt_page_range
9441            .clone()
9442            .expect("edited layout reports a rebuilt range");
9443        assert!(
9444            rebuilt.end.saturating_sub(rebuilt.start) <= 2,
9445            "{rebuilt:?}"
9446        );
9447    }
9448
9449    fn note_reference_cache_input(stream: NoteStream, include_second_note: bool) -> LayoutInput {
9450        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
9451
9452        let mut input = make_input_with_text("note cache paragraph 000");
9453        for index in 1..700 {
9454            let mut paragraph = CT_P::new();
9455            paragraph.add_run(&format!("note cache paragraph {index:03}"));
9456            input.document.body.add_paragraph(paragraph);
9457        }
9458        let BodyContent::Paragraph(reference) = &mut input.document.body.content[20] else {
9459            panic!("note reference belongs to a paragraph");
9460        };
9461        let mut marker = CT_R::new("");
9462        marker.content = vec![match stream {
9463            NoteStream::Footnote => RunContent::FootnoteRef { id: 1 },
9464            NoteStream::Endnote => RunContent::EndnoteRef { id: 1 },
9465        }];
9466        reference.runs.push(marker);
9467
9468        let note = |id, text: &str| {
9469            let mut paragraph = CT_P::new();
9470            paragraph.add_run(text);
9471            CT_Footnote {
9472                id,
9473                note_type: NoteType::Normal,
9474                paragraphs: vec![paragraph],
9475            }
9476        };
9477        let mut notes = vec![note(1, "first note text")];
9478        if include_second_note {
9479            notes.push(note(2, "second note text"));
9480        }
9481        let part = Some(CT_Footnotes { footnotes: notes });
9482        match stream {
9483            NoteStream::Footnote => input.footnotes = part,
9484            NoteStream::Endnote => input.endnotes = part,
9485        }
9486        input
9487    }
9488
9489    fn assert_note_reference_does_not_poison_later_hits(stream: NoteStream) {
9490        let mut input = note_reference_cache_input(stream, false);
9491        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9492        engine.layout(&input).expect("cold note layout succeeds");
9493        assert_eq!(engine.paragraph_cache_counts(), (0, 700));
9494
9495        set_body_paragraph_text(&mut input, 350, "note cache paragraph 350 changed");
9496        let warm = engine.layout(&input).expect("warm note layout succeeds");
9497        let fresh = Engine::new_deterministic()
9498            .expect("bundled fonts load")
9499            .layout(&input)
9500            .expect("fresh note layout succeeds");
9501
9502        assert_layout_results_equal(&warm, &fresh);
9503        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9504    }
9505
9506    #[test]
9507    fn note_reference_does_not_poison_later_paragraph_cache_hits() {
9508        assert_note_reference_does_not_poison_later_hits(NoteStream::Footnote);
9509    }
9510
9511    #[test]
9512    fn endnote_reference_does_not_poison_later_paragraph_cache_hits() {
9513        assert_note_reference_does_not_poison_later_hits(NoteStream::Endnote);
9514    }
9515
9516    #[test]
9517    fn changed_note_reference_or_note_part_invalidates_required_cache_entry() {
9518        let mut input = note_reference_cache_input(NoteStream::Footnote, true);
9519        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9520        engine.layout(&input).expect("cold note layout succeeds");
9521        assert_eq!(engine.paragraph_cache_counts(), (0, 700));
9522
9523        let BodyContent::Paragraph(reference) = &mut input.document.body.content[20] else {
9524            panic!("note reference belongs to a paragraph");
9525        };
9526        reference.runs.last_mut().expect("marker run").content =
9527            vec![RunContent::FootnoteRef { id: 2 }];
9528        let warm_reference = engine
9529            .layout(&input)
9530            .expect("changed reference layout succeeds");
9531        let fresh_reference = Engine::new_deterministic()
9532            .expect("bundled fonts load")
9533            .layout(&input)
9534            .expect("fresh changed reference layout succeeds");
9535        assert_layout_results_equal(&warm_reference, &fresh_reference);
9536        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9537
9538        input.footnotes.as_mut().expect("footnotes exist").footnotes[1].paragraphs[0].runs[0]
9539            .content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
9540            "second note text changed",
9541        ))];
9542        let warm_note = engine.layout(&input).expect("changed note layout succeeds");
9543        let fresh_note = Engine::new_deterministic()
9544            .expect("bundled fonts load")
9545            .layout(&input)
9546            .expect("fresh changed note layout succeeds");
9547        assert_layout_results_equal(&warm_note, &fresh_note);
9548        assert_eq!(engine.paragraph_cache_counts(), (699, 1_401));
9549    }
9550
9551    #[test]
9552    fn note_reference_warm_layout_equals_fresh_layout() {
9553        let mut input = related_story_restart_input(700);
9554        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9555        engine
9556            .layout(&input)
9557            .expect("cold related-story layout succeeds");
9558        set_body_paragraph_text(&mut input, 350, "related note paragraph changed");
9559
9560        let warm = engine
9561            .layout(&input)
9562            .expect("warm related-story layout succeeds");
9563        let fresh = Engine::new_deterministic()
9564            .expect("bundled fonts load")
9565            .layout(&input)
9566            .expect("fresh related-story layout succeeds");
9567        assert_layout_results_equal(&warm, &fresh);
9568        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9569    }
9570
9571    fn mixed_editor_input() -> LayoutInput {
9572        let mut input = make_input_with_text("");
9573        input.document.body.content.clear();
9574        for index in 0..700 {
9575            let mut paragraph = CT_P::new();
9576            paragraph.add_run(&format!("편집 paragraph {index:03} stable line"));
9577            input.document.body.add_paragraph(paragraph);
9578            if index % 50 == 49 {
9579                input
9580                    .document
9581                    .body
9582                    .add_table(safe_table(&format!("table {:02}", index / 50)));
9583            }
9584        }
9585        input
9586    }
9587
9588    fn mixed_editor_paragraph_mut(input: &mut LayoutInput, target: usize) -> &mut CT_P {
9589        input
9590            .document
9591            .body
9592            .content
9593            .iter_mut()
9594            .filter_map(|content| match content {
9595                BodyContent::Paragraph(paragraph) => Some(paragraph),
9596                BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
9597                    None
9598                }
9599            })
9600            .nth(target)
9601            .expect("mixed editor paragraph exists")
9602    }
9603
9604    #[test]
9605    fn mixed_editor_relayout_reuses_every_safe_unchanged_block_and_page() {
9606        let mut input = mixed_editor_input();
9607        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9608        let initial = engine.layout(&input).expect("mixed cold layout");
9609        assert_eq!(engine.paragraph_cache_counts(), (0, 700));
9610        assert_eq!(engine.table_cache_counts(), (0, 14));
9611        assert_eq!(engine.hot_path_work_counts(), (0, 0));
9612
9613        mixed_editor_paragraph_mut(&mut input, 350).runs[0].content = vec![RunContent::Text(
9614            rdocx_oxml::text::CT_Text::new("편집 paragraph 350 changed line"),
9615        )];
9616        let warm = engine.layout(&input).expect("mixed warm layout");
9617        let fresh = Engine::new_deterministic()
9618            .expect("bundled fonts load")
9619            .layout(&input)
9620            .expect("mixed fresh layout");
9621        assert_layout_results_equal(&warm, &fresh);
9622        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9623        assert_eq!(engine.table_cache_counts(), (14, 14));
9624        assert_eq!(engine.hot_path_work_counts(), (0, 0));
9625        assert_eq!(engine.owned_context_build_count(), 1);
9626        let restart = engine.restart_cache.as_ref().unwrap_or_else(|| {
9627            panic!(
9628                "mixed restart retained for {} pages, candidate {} bytes",
9629                initial.pages.len(),
9630                engine.last_restart_candidate_bytes
9631            )
9632        });
9633        assert_restart_cache_within_aggregate(&engine);
9634        assert!(restart.checkpoints.len() <= RESTART_CACHE_MAX_ENTRIES);
9635        let rebuilt = engine
9636            .last_rebuilt_page_range
9637            .clone()
9638            .expect("mixed rebuilt range recorded");
9639        assert!(
9640            warm.pages
9641                .iter()
9642                .zip(&initial.pages)
9643                .take(rebuilt.start)
9644                .all(|(current, previous)| Arc::ptr_eq(current, previous))
9645        );
9646        assert!(
9647            warm.pages
9648                .iter()
9649                .zip(&initial.pages)
9650                .skip(rebuilt.end)
9651                .all(|(current, previous)| Arc::ptr_eq(current, previous))
9652        );
9653    }
9654
9655    #[test]
9656    fn mixed_editor_table_mutation_rebuilds_only_the_changed_table() {
9657        let mut input = mixed_editor_input();
9658        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9659        engine.layout(&input).expect("mixed cold layout");
9660        let BodyContent::Table(changed) = input
9661            .document
9662            .body
9663            .content
9664            .iter_mut()
9665            .filter(|content| matches!(content, BodyContent::Table(_)))
9666            .nth(7)
9667            .expect("eighth mixed table")
9668        else {
9669            panic!("mixed body item is a table");
9670        };
9671        changed.rows[0].cells[0].paragraphs_mut()[0].runs[0].content = vec![RunContent::Text(
9672            rdocx_oxml::text::CT_Text::new("changed table 07"),
9673        )];
9674
9675        let warm = engine.layout(&input).expect("mixed warm table layout");
9676        let fresh = Engine::new_deterministic()
9677            .expect("bundled fonts load")
9678            .layout(&input)
9679            .expect("mixed fresh table layout");
9680        assert_layout_results_equal(&warm, &fresh);
9681        assert_eq!(engine.paragraph_cache_counts(), (700, 700));
9682        assert_eq!(engine.table_cache_counts(), (13, 15));
9683        assert_eq!(engine.hot_path_work_counts(), (0, 0));
9684    }
9685
9686    #[test]
9687    fn unsafe_prefix_still_disables_later_paragraph_hits() {
9688        let mut field = CT_P::new();
9689        let mut field_run = CT_R::new("");
9690        field_run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
9691        field.runs.push(field_run);
9692
9693        let mut numbered = CT_P::new();
9694        numbered.add_run("numbered prefix");
9695        numbered.properties.get_or_insert_default().num_id = Some(1);
9696
9697        let mut drawing = CT_P::new();
9698        let mut drawing_run = CT_R::new("");
9699        drawing_run.content = vec![RunContent::Drawing(rdocx_oxml::drawing::CT_Drawing {
9700            inline: None,
9701            anchor: None,
9702        })];
9703        drawing.runs.push(drawing_run);
9704
9705        let mut raw = CT_P::new();
9706        raw.extra_xml.push((
9707            0,
9708            br#"<w:unknown xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>"#
9709                .to_vec(),
9710        ));
9711
9712        for (name, unsafe_paragraph) in [
9713            ("field", field),
9714            ("numbering", numbered),
9715            ("drawing", drawing),
9716            ("raw child", raw),
9717        ] {
9718            let mut input = make_input_with_text("safe cached suffix");
9719            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9720            engine.layout(&input).expect("prime safe suffix");
9721            input
9722                .document
9723                .body
9724                .content
9725                .insert(0, BodyContent::Paragraph(unsafe_paragraph));
9726
9727            let warm = engine.layout(&input).expect("warm unsafe-prefix layout");
9728            let cold = Engine::new_deterministic()
9729                .expect("bundled fonts load")
9730                .layout(&input)
9731                .expect("cold unsafe-prefix layout");
9732            assert_layout_results_equal(&warm, &cold);
9733            assert_eq!(engine.paragraph_cache_counts(), (0, 2), "{name}");
9734        }
9735    }
9736
9737    #[test]
9738    fn scaled_paragraph_cache_warm_equals_cold() {
9739        let mut input = make_input_with_text("warm-cold paragraph 000");
9740        for index in 1..700 {
9741            let mut paragraph = CT_P::new();
9742            paragraph.add_run(&format!("warm-cold paragraph {index:03}"));
9743            input.document.body.add_paragraph(paragraph);
9744        }
9745        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9746        warm_engine
9747            .layout_with_provenance(&input)
9748            .expect("prime warm state");
9749        set_body_paragraph_text(&mut input, 349, "warm-cold paragraph 349 changed");
9750
9751        let warm = warm_engine
9752            .layout_with_provenance(&input)
9753            .expect("warm edited layout");
9754        let cold = Engine::new_deterministic()
9755            .expect("bundled fonts load")
9756            .layout_with_provenance(&input)
9757            .expect("cold edited layout");
9758        assert_layout_results_equal(&warm.0, &cold.0);
9759        assert_eq!(warm.1, cold.1);
9760        assert_eq!(format!("{:?}", warm.0), format!("{:?}", cold.0));
9761    }
9762
9763    #[test]
9764    fn warm_relayout_rebinds_font_tables_and_ids_to_the_current_result() {
9765        let mut input = make_input_with_text("font identity changes");
9766        {
9767            let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9768                panic!("body paragraph");
9769            };
9770            paragraph.runs[0]
9771                .properties
9772                .get_or_insert_default()
9773                .font_ascii = Some("Carlito".to_owned());
9774        }
9775
9776        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9777        warm_engine.layout(&input).expect("prime warm font state");
9778        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9779            panic!("body paragraph");
9780        };
9781        paragraph.runs[0]
9782            .properties
9783            .get_or_insert_default()
9784            .font_ascii = Some("Caladea".to_owned());
9785
9786        let warm = warm_engine.layout(&input).expect("warm relayout succeeds");
9787        let cold = Engine::new_deterministic()
9788            .expect("bundled fonts load")
9789            .layout(&input)
9790            .expect("cold relayout succeeds");
9791        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9792        assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", cold.fonts));
9793    }
9794
9795    #[test]
9796    fn warm_relayout_canonicalizes_the_same_fonts_in_new_resolution_order() {
9797        let mut input = make_input_with_text("first family");
9798        let BodyContent::Paragraph(first) = &mut input.document.body.content[0] else {
9799            panic!("body paragraph");
9800        };
9801        first.runs[0].properties.get_or_insert_default().font_ascii = Some("Carlito".to_owned());
9802        let mut second = CT_P::new();
9803        second
9804            .add_run("second family")
9805            .properties
9806            .get_or_insert_default()
9807            .font_ascii = Some("Caladea".to_owned());
9808        input.document.body.add_paragraph(second);
9809
9810        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9811        warm_engine.layout(&input).expect("prime original order");
9812        input.document.body.content.swap(0, 1);
9813
9814        let warm = warm_engine.layout(&input).expect("warm reordered layout");
9815        let cold = Engine::new_deterministic()
9816            .expect("bundled fonts load")
9817            .layout(&input)
9818            .expect("cold reordered layout");
9819        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9820        assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", cold.fonts));
9821    }
9822
9823    #[test]
9824    fn shared_layout_context_changes_cannot_serve_stale_blocks() {
9825        let mut input = make_input_with_text("context-sensitive cache identity");
9826        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9827        warm_engine.layout(&input).expect("prime context cache");
9828
9829        let normal = input
9830            .styles
9831            .styles
9832            .iter_mut()
9833            .find(|style| style.is_default)
9834            .expect("default style");
9835        normal.rpr.get_or_insert_default().font_ascii = Some("Caladea".to_owned());
9836        let warm = warm_engine.layout(&input).expect("warm style mutation");
9837        let cold = Engine::new_deterministic()
9838            .expect("bundled fonts load")
9839            .layout(&input)
9840            .expect("cold style mutation");
9841        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9842
9843        input.numbering = Some(rdocx_oxml::numbering::CT_Numbering::new());
9844        let warm = warm_engine.layout(&input).expect("warm numbering mutation");
9845        let cold = Engine::new_deterministic()
9846            .expect("bundled fonts load")
9847            .layout(&input)
9848            .expect("cold numbering mutation");
9849        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9850
9851        input.theme = Some(rdocx_oxml::theme::Theme::default());
9852        let warm = warm_engine.layout(&input).expect("warm theme mutation");
9853        let cold = Engine::new_deterministic()
9854            .expect("bundled fonts load")
9855            .layout(&input)
9856            .expect("cold theme mutation");
9857        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9858
9859        input
9860            .hyperlink_urls
9861            .insert("rIdLink".to_owned(), "https://example.com".to_owned());
9862        input.images.insert(
9863            "rIdImage".to_owned(),
9864            crate::input::ImageData {
9865                data: vec![1, 2, 3],
9866                content_type: "image/png".to_owned(),
9867            },
9868        );
9869        let warm = warm_engine
9870            .layout(&input)
9871            .expect("warm relationship and image mutation");
9872        let cold = Engine::new_deterministic()
9873            .expect("bundled fonts load")
9874            .layout(&input)
9875            .expect("cold relationship and image mutation");
9876        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9877
9878        input.fonts.push(oxml_layout::FontFile {
9879            family: "Embedded".to_owned(),
9880            data: oxml_layout::bundled_fonts::bundled_font_data()[0]
9881                .1
9882                .to_vec(),
9883        });
9884        let warm = warm_engine.layout(&input).expect("warm font mutation");
9885        let cold = Engine::new_deterministic()
9886            .expect("bundled fonts load")
9887            .layout(&input)
9888            .expect("cold font mutation");
9889        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9890
9891        let contextual = rdocx_oxml::CT_Document::from_xml(
9892            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>"#,
9893        )
9894        .expect("contextual paragraphs parse");
9895        for content in &contextual.body.content {
9896            let BodyContent::Paragraph(paragraph) = content else {
9897                continue;
9898            };
9899            assert!(!paragraph_is_cache_safe(paragraph, &input.styles));
9900        }
9901    }
9902
9903    #[test]
9904    fn compatible_engine_take_reuses_normal_layout_work() {
9905        let mut source_input = make_input_with_text("unchanged paragraph");
9906        let mut changed = CT_P::new();
9907        changed.add_run("old second paragraph");
9908        source_input.document.body.add_paragraph(changed);
9909
9910        let mut source_engine = Engine::new_deterministic().expect("bundled fonts load");
9911        source_engine
9912            .layout(&source_input)
9913            .expect("prime reusable engine");
9914        assert_eq!(source_engine.paragraph_cache_counts(), (0, 2));
9915
9916        let mut receiver_input = source_input.clone();
9917        let BodyContent::Paragraph(second) = &mut receiver_input.document.body.content[1] else {
9918            panic!("second body paragraph");
9919        };
9920        second.runs[0].content[0] =
9921            RunContent::Text(rdocx_oxml::text::CT_Text::new("new second paragraph"));
9922
9923        let mut source = Some(source_engine);
9924        let mut transferred = Engine::take_if_compatible(&mut source, &receiver_input)
9925            .expect("matching context transfers");
9926        assert!(source.is_none());
9927        transferred
9928            .layout(&receiver_input)
9929            .expect("transferred layout succeeds");
9930        assert_eq!(transferred.paragraph_cache_counts(), (1, 3));
9931    }
9932
9933    #[test]
9934    fn incompatible_or_failed_engine_take_preserves_the_source() {
9935        fn assert_rejected(label: &str, mut receiver: LayoutInput) {
9936            let source_input = make_input_with_text("retained paragraph");
9937            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9938            engine.layout(&source_input).expect("prime reusable engine");
9939            let mut source = Some(engine);
9940            assert!(
9941                Engine::take_if_compatible(&mut source, &receiver).is_none(),
9942                "{label} must reject transfer"
9943            );
9944            assert!(source.is_some());
9945            receiver.document.body.content.clear();
9946        }
9947
9948        let base = make_input_with_text("retained paragraph");
9949        let mut changed = base.clone();
9950        changed.revision_view = RevisionView::Tracked;
9951        assert_rejected("revision view", changed);
9952
9953        let wrapping = make_wrapping_document(
9954            WrapType::Square,
9955            Some(rdocx_oxml::drawing::AnchorAlignH::Left),
9956            100.0,
9957            40.0,
9958            5.0,
9959        );
9960        let mut changed = base.clone();
9961        changed.document = wrapping.document;
9962        assert_rejected("document wrapping state", changed);
9963
9964        let mut changed = base.clone();
9965        changed.styles = CT_Styles::new();
9966        assert_rejected("styles", changed);
9967
9968        let mut changed = base.clone();
9969        changed.numbering = Some(rdocx_oxml::numbering::CT_Numbering::new());
9970        assert_rejected("numbering", changed);
9971
9972        let mut changed = base.clone();
9973        changed.headers.insert(
9974            "rIdHeader".to_owned(),
9975            rdocx_oxml::header_footer::CT_HdrFtr::new(),
9976        );
9977        assert_rejected("headers", changed);
9978
9979        let mut changed = base.clone();
9980        changed.footers.insert(
9981            "rIdFooter".to_owned(),
9982            rdocx_oxml::header_footer::CT_HdrFtr::new(),
9983        );
9984        assert_rejected("footers", changed);
9985
9986        let mut changed = base.clone();
9987        changed.images.insert(
9988            "rIdImage".to_owned(),
9989            crate::input::ImageData {
9990                data: vec![1, 2, 3],
9991                content_type: "image/png".to_owned(),
9992            },
9993        );
9994        assert_rejected("images", changed);
9995
9996        let mut changed = base.clone();
9997        changed
9998            .charts
9999            .insert("rIdChart".to_owned(), Err("missing chart".to_owned()));
10000        assert_rejected("charts", changed);
10001
10002        let mut changed = base.clone();
10003        changed.chart_theme.name = Some("Changed".to_owned());
10004        assert_rejected("chart theme", changed);
10005
10006        let mut changed = base.clone();
10007        changed.core_properties = Some(rdocx_oxml::core_properties::CoreProperties {
10008            title: Some("Changed".to_owned()),
10009            ..Default::default()
10010        });
10011        assert_rejected("core properties", changed);
10012
10013        let mut changed = base.clone();
10014        changed
10015            .hyperlink_urls
10016            .insert("rIdLink".to_owned(), "https://example.com".to_owned());
10017        assert_rejected("hyperlinks", changed);
10018
10019        let mut changed = base.clone();
10020        changed.footnotes = Some(rdocx_oxml::footnotes::CT_Footnotes::new());
10021        assert_rejected("footnotes", changed);
10022
10023        let mut changed = base.clone();
10024        changed.endnotes = Some(rdocx_oxml::footnotes::CT_Footnotes::new());
10025        assert_rejected("endnotes", changed);
10026
10027        let mut changed = base.clone();
10028        changed.theme = Some(rdocx_oxml::theme::Theme::default());
10029        assert_rejected("theme", changed);
10030
10031        let mut changed = base.clone();
10032        changed.fonts.push(oxml_layout::FontFile {
10033            family: "Changed".to_owned(),
10034            data: vec![1, 2, 3],
10035        });
10036        assert_rejected("fonts", changed);
10037
10038        let mut changed = base;
10039        changed
10040            .document
10041            .body
10042            .sect_pr
10043            .get_or_insert_with(CT_SectPr::default_letter)
10044            .page_width = Some(rdocx_oxml::units::Twips(10_000));
10045        assert_rejected("sections", changed);
10046    }
10047
10048    #[test]
10049    fn alternate_content_drawings_bypass_paragraph_reuse() {
10050        let document = rdocx_oxml::CT_Document::from_xml(
10051            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>"#,
10052        )
10053        .expect("AlternateContent drawing parses");
10054        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
10055            panic!("body paragraph");
10056        };
10057        assert!(!paragraph.runs[1].alt_drawings.is_empty());
10058        assert!(!paragraph_is_cache_safe(
10059            paragraph,
10060            &CT_Styles::new_default()
10061        ));
10062    }
10063
10064    #[test]
10065    fn warm_provenance_rebinds_to_current_word_source_nodes() {
10066        let mut input = make_input_with_text("first paragraph");
10067        for text in ["second paragraph", "third paragraph"] {
10068            let mut paragraph = CT_P::new();
10069            paragraph.add_run(text);
10070            input.document.body.add_paragraph(paragraph);
10071        }
10072        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10073        engine
10074            .layout_with_provenance(&input)
10075            .expect("prime paragraph cache");
10076
10077        let moved = input.document.body.content.remove(2);
10078        input.document.body.content.insert(0, moved);
10079        let mut inserted = CT_P::new();
10080        inserted.add_run("new paragraph");
10081        input
10082            .document
10083            .body
10084            .content
10085            .insert(1, BodyContent::Paragraph(inserted));
10086        let (layout, sources) = engine
10087            .layout_with_provenance(&input)
10088            .expect("warm provenance layout");
10089
10090        for page in &layout.pages {
10091            oxml_layout::walk(&page.elements, &mut |element, _| {
10092                let PositionedElement::Text(run) = element else {
10093                    return;
10094                };
10095                let Some(span) = run.source else {
10096                    return;
10097                };
10098                let path = &sources[span.node.get() as usize - 1];
10099                assert_eq!(path.story, WordStory::Document);
10100                let BodyContent::Paragraph(paragraph) =
10101                    &input.document.body.content[path.children[0]]
10102                else {
10103                    panic!("source path resolves to a body paragraph");
10104                };
10105                let text = paragraph.text();
10106                let resolved = text
10107                    .chars()
10108                    .skip(span.char_start as usize)
10109                    .take((span.char_end - span.char_start) as usize)
10110                    .collect::<String>();
10111                assert_eq!(resolved, run.text);
10112            });
10113        }
10114        assert_eq!(engine.paragraph_cache_counts(), (3, 4));
10115    }
10116
10117    #[test]
10118    fn cached_heading_keeps_result_local_provenance() {
10119        fn heading_path(layout: &LayoutResult, sources: &[WordSourcePath]) -> Vec<usize> {
10120            layout
10121                .pages
10122                .iter()
10123                .flat_map(|page| compatibility_page_elements(page))
10124                .find_map(|element| match element {
10125                    PositionedElement::Text(run) if run.text.contains("cached") => run
10126                        .source
10127                        .map(|source| sources[source.node.get() as usize - 1].children.clone()),
10128                    _ => None,
10129                })
10130                .expect("cached heading keeps a source path")
10131        }
10132
10133        let mut input = make_input_with_text("ordinary first paragraph");
10134        let mut heading = CT_P::new();
10135        heading.properties = Some(CT_PPr {
10136            style_id: Some("Heading1".to_owned()),
10137            ..CT_PPr::default()
10138        });
10139        heading.add_run("cached heading");
10140        input.document.body.add_paragraph(heading);
10141
10142        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10143        let cold = engine
10144            .layout_with_provenance(&input)
10145            .expect("cold heading layout");
10146        assert_eq!(heading_path(&cold.0, &cold.1), vec![1]);
10147
10148        let mut inserted = CT_P::new();
10149        inserted.add_run("inserted before heading");
10150        input
10151            .document
10152            .body
10153            .content
10154            .insert(0, BodyContent::Paragraph(inserted));
10155        let warm = engine
10156            .layout_with_provenance(&input)
10157            .expect("warm heading layout");
10158        let fresh = Engine::new_deterministic()
10159            .expect("bundled fonts load")
10160            .layout_with_provenance(&input)
10161            .expect("fresh heading layout");
10162
10163        assert_layout_results_equal(&warm.0, &fresh.0);
10164        assert_eq!(warm.1, fresh.1);
10165        assert_eq!(heading_path(&warm.0, &warm.1), vec![2]);
10166        assert_eq!(engine.paragraph_cache_counts(), (2, 3));
10167    }
10168
10169    #[test]
10170    fn complex_heading_rebinds_rich_line_and_reflow_sources() {
10171        let mut input = make_input_with_text("العنوان المخزن");
10172        if let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] {
10173            paragraph.properties = Some(CT_PPr {
10174                style_id: Some("Heading1".to_owned()),
10175                ..CT_PPr::default()
10176            });
10177        } else {
10178            panic!("expected paragraph")
10179        }
10180        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
10181            unreachable!("paragraph checked above")
10182        };
10183        let media = MediaRegistry::new(&input.images);
10184        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
10185        let mut numbering = NumberingState::new();
10186        let mut diagnostics = Vec::new();
10187        let mut block = layout_paragraph_with_source(
10188            paragraph,
10189            468.0,
10190            &input.styles,
10191            &input,
10192            &media,
10193            &mut fonts,
10194            &mut numbering,
10195            &mut diagnostics,
10196            Some(CACHE_SOURCE_NODE),
10197        )
10198        .expect("complex heading lays out");
10199
10200        let rebound = SourceNodeId::new(2).expect("source ID");
10201        rebind_paragraph_source(&mut block, Some(rebound)).expect("source rebinding succeeds");
10202        let line_sources = block
10203            .lines
10204            .iter()
10205            .flat_map(|line| &line.items)
10206            .filter_map(|item| match item {
10207                LineItem::MultilingualText(segment) => Some(segment.base().source),
10208                _ => None,
10209            })
10210            .collect::<Vec<_>>();
10211        assert!(!line_sources.is_empty());
10212        assert!(
10213            line_sources
10214                .iter()
10215                .all(|source| source.is_some_and(|source| source.node == rebound))
10216        );
10217        assert!(block
10218            .reflow
10219            .as_ref()
10220            .expect("heading retains reflow inputs")
10221            .items
10222            .iter()
10223            .all(|item| {
10224                !matches!(item, InlineItem::MultilingualText(segment) if !segment.base().source.is_some_and(|source| source.node == rebound))
10225            }));
10226    }
10227
10228    #[test]
10229    fn cached_header_rebinds_hyphenated_reflow_sources() {
10230        let input = hyphenation_input(true, Some("en-US"), false);
10231        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
10232            panic!("expected paragraph")
10233        };
10234        let media = MediaRegistry::new(&input.images);
10235        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
10236        let mut numbering = NumberingState::new();
10237        let mut diagnostics = Vec::new();
10238        let mut block = layout_paragraph_with_source(
10239            paragraph,
10240            468.0,
10241            &input.styles,
10242            &input,
10243            &media,
10244            &mut fonts,
10245            &mut numbering,
10246            &mut diagnostics,
10247            Some(CACHE_SOURCE_NODE),
10248        )
10249        .expect("hyphenated header paragraph lays out");
10250        assert!(
10251            block
10252                .reflow
10253                .as_ref()
10254                .expect("header retains reflow inputs")
10255                .items
10256                .iter()
10257                .any(|item| matches!(item, InlineItem::HyphenatedText { .. }))
10258        );
10259
10260        let rebound = SourceNodeId::new(74).expect("header source ID");
10261        rebind_paragraph_source(&mut block, Some(rebound)).expect("source rebinding succeeds");
10262        assert!(
10263            block
10264                .reflow
10265                .as_ref()
10266                .expect("header retains reflow inputs")
10267                .items
10268                .iter()
10269                .all(|item| {
10270                    !matches!(
10271                        item,
10272                        InlineItem::HyphenatedText { segment, .. }
10273                            if !segment.source.is_some_and(|source| source.node == rebound)
10274                    )
10275                })
10276        );
10277    }
10278
10279    #[test]
10280    fn overflowed_table_font_trace_keeps_result_local_provenance() {
10281        let mut input = make_input_with_text("before overflow table");
10282        let mut table = safe_table("");
10283        let paragraph = &mut table.rows[0].cells[0].paragraphs_mut()[0];
10284        paragraph.runs.clear();
10285        for _ in 0..4_100 {
10286            paragraph.runs.push(CT_R::new("x"));
10287        }
10288        input.document.body.add_table(table);
10289
10290        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10291        let (layout, sources) = engine
10292            .layout_with_provenance(&input)
10293            .expect("overflowed table trace still lays out");
10294
10295        assert!(engine.table_cache.is_empty());
10296        let source = layout
10297            .pages
10298            .iter()
10299            .flat_map(|page| compatibility_page_elements(page))
10300            .find_map(|element| match element {
10301                PositionedElement::Text(run) if run.text.contains('x') => run.source,
10302                _ => None,
10303            })
10304            .expect("table glyph keeps provenance after trace overflow");
10305        assert_eq!(
10306            sources[source.node.get() as usize - 1].children,
10307            vec![1, 0, 0, 0]
10308        );
10309    }
10310
10311    #[test]
10312    fn restart_body_accounting_charges_cache_safe_property_payloads() {
10313        let mut paragraph = CT_P::new();
10314        paragraph.properties = Some(CT_PPr {
10315            style_id: Some("p".repeat(LEGACY_RESTART_CACHE_MAX_BYTES + 1)),
10316            ..CT_PPr::default()
10317        });
10318        let paragraph_entry = RestartBodyEntry::for_content(
10319            &BodyContent::Paragraph(paragraph),
10320            RevisionView::Accepted,
10321        )
10322        .expect("paragraph has restart identity");
10323        assert!(paragraph_entry.bytes() > LEGACY_RESTART_CACHE_MAX_BYTES);
10324
10325        let mut table = safe_table("property accounting");
10326        table.properties = Some(rdocx_oxml::table::CT_TblPr {
10327            style_id: Some("t".repeat(4_096)),
10328            ..rdocx_oxml::table::CT_TblPr::default()
10329        });
10330        table.rows[0].properties = Some(rdocx_oxml::table::CT_TrPr {
10331            height: Some(rdocx_oxml::units::Twips(1)),
10332            height_rule: Some("r".repeat(4_096)),
10333            ..rdocx_oxml::table::CT_TrPr::default()
10334        });
10335        table.rows[0].cells[0].properties = Some(rdocx_oxml::table::CT_TcPr {
10336            text_direction: Some("c".repeat(4_096)),
10337            ..rdocx_oxml::table::CT_TcPr::default()
10338        });
10339        let table_entry =
10340            RestartBodyEntry::for_content(&BodyContent::Table(table), RevisionView::Accepted)
10341                .expect("table has restart identity");
10342        assert!(table_entry.bytes() >= 3 * 4_096);
10343    }
10344
10345    #[test]
10346    fn restart_body_identity_is_exact_for_all_run_language_state() {
10347        let mut paragraph = CT_P::new();
10348        let mut run = CT_R::new("representation");
10349        run.properties = Some(CT_RPr {
10350            language: Some("en-US".to_owned()),
10351            language_east_asia: Some("ja-JP".to_owned()),
10352            language_bidi: Some("ar-SA".to_owned()),
10353            language_extra_attributes: vec![("data".to_owned(), "one".to_owned())],
10354            ..CT_RPr::default()
10355        });
10356        paragraph.runs.push(run);
10357        let retained = RestartBodyEntry::for_content(
10358            &BodyContent::Paragraph(paragraph.clone()),
10359            RevisionView::Accepted,
10360        )
10361        .expect("paragraph has restart identity");
10362
10363        let mut changed = Vec::new();
10364        for field in 0..4 {
10365            let mut candidate = paragraph.clone();
10366            let properties = candidate.runs[0]
10367                .properties
10368                .as_mut()
10369                .expect("run properties exist");
10370            match field {
10371                0 => properties.language = Some("en-GB".to_owned()),
10372                1 => properties.language_east_asia = Some("zh-CN".to_owned()),
10373                2 => properties.language_bidi = Some("he-IL".to_owned()),
10374                3 => properties.language_extra_attributes[0].1 = "two".to_owned(),
10375                _ => unreachable!(),
10376            }
10377            changed.push(candidate);
10378        }
10379
10380        for candidate in changed {
10381            assert_eq!(
10382                paragraph_fingerprint(&paragraph),
10383                paragraph_fingerprint(&candidate)
10384            );
10385            assert!(!retained.matches(&BodyContent::Paragraph(candidate)));
10386        }
10387    }
10388
10389    #[test]
10390    fn shared_cached_blocks_keep_result_local_semantics_exact() {
10391        fn has_semantic_mark(elements: &[PositionedElement]) -> bool {
10392            elements.iter().any(|element| match element {
10393                PositionedElement::MarkedContent {
10394                    structure: Some(_), ..
10395                } => true,
10396                PositionedElement::MarkedContent { children, .. } => has_semantic_mark(children),
10397                PositionedElement::Group(group) => has_semantic_mark(&group.children),
10398                _ => false,
10399            })
10400        }
10401
10402        let mut input = make_input_with_text("before shared table");
10403        input
10404            .document
10405            .body
10406            .add_table(safe_nested_table("outer cell", "nested cell"));
10407        let mut after = CT_P::new();
10408        after.add_run("after shared table");
10409        input.document.body.add_paragraph(after);
10410
10411        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10412        engine
10413            .layout_with_provenance(&input)
10414            .expect("prime shared cache payloads");
10415        assert_eq!(engine.last_shared_block_counts, (2, 1));
10416
10417        let mut inserted = CT_P::new();
10418        inserted.add_run("inserted before shared payloads");
10419        input
10420            .document
10421            .body
10422            .content
10423            .insert(0, BodyContent::Paragraph(inserted));
10424        let warm = engine
10425            .layout_with_provenance(&input)
10426            .expect("warm shared layout");
10427        let fresh = Engine::new_deterministic()
10428            .expect("bundled fonts load")
10429            .layout_with_provenance(&input)
10430            .expect("fresh shared comparison");
10431
10432        assert_layout_results_equal(&warm.0, &fresh.0);
10433        assert_eq!(warm.0.structure, fresh.0.structure);
10434        assert_eq!(warm.1, fresh.1);
10435        assert_eq!(format!("{:?}", warm.0), format!("{:?}", fresh.0));
10436        assert_eq!(engine.last_shared_block_counts, (3, 1));
10437        assert_eq!(engine.paragraph_cache_counts(), (2, 3));
10438        assert_eq!(engine.table_cache_counts(), (1, 1));
10439
10440        let sourced_runs = warm
10441            .0
10442            .pages
10443            .iter()
10444            .flat_map(|page| compatibility_page_elements(page))
10445            .filter_map(|element| match element {
10446                PositionedElement::Text(run) => Some((run.text.as_str(), run.source)),
10447                _ => None,
10448            })
10449            .collect::<Vec<_>>();
10450        for (text, expected_path) in [
10451            ("outer ", vec![2, 0, 0, 0]),
10452            ("nested ", vec![2, 0, 0, 1, 0, 0, 0]),
10453        ] {
10454            let source = sourced_runs
10455                .iter()
10456                .find_map(|(run_text, source)| (*run_text == text).then_some(*source).flatten())
10457                .unwrap_or_else(|| panic!("{text:?} keeps result-local provenance"));
10458            assert_eq!(
10459                warm.1[source.node.get() as usize - 1].children,
10460                expected_path
10461            );
10462        }
10463        assert!(
10464            warm.0
10465                .pages
10466                .iter()
10467                .any(|page| has_semantic_mark(&page.elements))
10468        );
10469    }
10470
10471    fn safe_table(text: &str) -> CT_Tbl {
10472        let mut table = CT_Tbl::new();
10473        let mut row = CT_Row::new();
10474        let mut cell = CT_Tc::new();
10475        cell.paragraphs_mut()[0].add_run(text);
10476        row.cells.push(cell);
10477        table.rows.push(row);
10478        table
10479    }
10480
10481    fn safe_nested_table(outer_text: &str, nested_text: &str) -> CT_Tbl {
10482        let mut table = safe_table(outer_text);
10483        table.rows[0].cells[0]
10484            .content
10485            .push(CellContent::Table(safe_table(nested_text)));
10486        table
10487    }
10488
10489    fn rtl_multi_leader_paragraph(parts: [&str; 4]) -> CT_P {
10490        use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
10491        use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
10492        use rdocx_oxml::units::Twips;
10493
10494        let mut paragraph = CT_P::new();
10495        paragraph.properties = Some(CT_PPr {
10496            bidi: Some(true),
10497            tabs: Some(CT_Tabs {
10498                tabs: vec![
10499                    CT_TabStop {
10500                        val: ST_TabJc::Left,
10501                        pos: Twips(1_200),
10502                        leader: None,
10503                        source_occurrence: None,
10504                    },
10505                    CT_TabStop {
10506                        val: ST_TabJc::Left,
10507                        pos: Twips(2_400),
10508                        leader: Some(ST_TabLeader::Dot),
10509                        source_occurrence: None,
10510                    },
10511                    CT_TabStop {
10512                        val: ST_TabJc::Left,
10513                        pos: Twips(3_600),
10514                        leader: Some(ST_TabLeader::Hyphen),
10515                        source_occurrence: None,
10516                    },
10517                ],
10518            }),
10519            ..Default::default()
10520        });
10521        let mut run = CT_R::new("");
10522        run.content = vec![
10523            RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[0])),
10524            RunContent::Tab,
10525            RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[1])),
10526            RunContent::Tab,
10527            RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[2])),
10528            RunContent::Tab,
10529            RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[3])),
10530        ];
10531        paragraph.runs = vec![run];
10532        paragraph
10533    }
10534
10535    #[test]
10536    fn cached_table_and_header_keep_resolved_rtl_for_logical_extraction() {
10537        let mut table = CT_Tbl::new();
10538        let mut row = CT_Row::new();
10539        let mut cell = CT_Tc::new();
10540        cell.content = vec![CellContent::Paragraph(rtl_multi_leader_paragraph([
10541            "TA", "TB", "TC", "TD",
10542        ]))];
10543        row.cells.push(cell);
10544        table.rows.push(row);
10545        let mut table_input = make_input_with_text("body");
10546        let media = MediaRegistry::new(&table_input.images);
10547        let mut direction_engine = Engine::new_deterministic().expect("bundled fonts load");
10548        let mut numbering = NumberingState::new();
10549        let mut diagnostics = Vec::new();
10550        let shared = direction_engine
10551            .layout_body_table(
10552                &table,
10553                468.0,
10554                &table_input.styles,
10555                &table_input,
10556                &media,
10557                &mut numbering,
10558                &mut diagnostics,
10559                None,
10560                &WordStory::Document,
10561                &[0],
10562            )
10563            .expect("real table container lays out");
10564        let SharedLayoutBlock::Table { semantics, .. } = shared else {
10565            panic!("cache-safe table uses the shared table container")
10566        };
10567        let CellBlockSemantics::Paragraph(table_paragraph) = &semantics.rows[0].cells[0].blocks[0]
10568        else {
10569            panic!("table paragraph semantics")
10570        };
10571        assert_eq!(
10572            table_paragraph.reflow_direction,
10573            TextDirection::RightToLeft,
10574            "the production table container retains its resolved paragraph base"
10575        );
10576        table_input
10577            .document
10578            .body
10579            .content
10580            .insert(0, BodyContent::Table(table));
10581        let mut table_engine = Engine::new_deterministic().expect("bundled fonts load");
10582        table_engine
10583            .layout_with_provenance(&table_input)
10584            .expect("cold table layout");
10585        let (table_warm, table_sources) = table_engine
10586            .layout_with_provenance(&table_input)
10587            .expect("warm table layout");
10588        assert_eq!(table_engine.table_cache_counts().1, 2);
10589
10590        let mut header_input = cacheable_header_footer_input(&"body line ".repeat(4_000));
10591        for header in header_input.headers.values_mut() {
10592            header.paragraphs = vec![rtl_multi_leader_paragraph(["HA", "HB", "HC", "HD"])];
10593        }
10594        let section = header_input
10595            .document
10596            .body
10597            .sect_pr
10598            .as_ref()
10599            .expect("header section")
10600            .clone();
10601        let media = MediaRegistry::new(&header_input.images);
10602        let mut direction_header_engine = Engine::new_deterministic().expect("bundled fonts load");
10603        let mut numbering = NumberingState::new();
10604        let mut diagnostics = Vec::new();
10605        let (_, semantics) = layout_header_footer(
10606            &mut direction_header_engine,
10607            &section,
10608            &header_input,
10609            &header_input.styles,
10610            &media,
10611            &mut numbering,
10612            &mut diagnostics,
10613            None,
10614        )
10615        .expect("real header cache container lays out")
10616        .expect("header content exists");
10617        assert_eq!(
10618            semantics.first_header_directions,
10619            [TextDirection::RightToLeft],
10620            "the production header cache container retains its resolved paragraph base"
10621        );
10622        let mut header_engine = Engine::new_deterministic().expect("bundled fonts load");
10623        header_engine
10624            .layout_with_provenance(&header_input)
10625            .expect("cold header layout");
10626        let (header_warm, header_sources) = header_engine
10627            .layout_with_provenance(&header_input)
10628            .expect("warm header layout");
10629        let header_counts = header_engine.header_footer_cache_counts();
10630        assert!(
10631            header_counts.0 > 0,
10632            "header cache is traversed: {header_counts:?}"
10633        );
10634
10635        let assert_line = |warm: &LayoutResult,
10636                           sources: &[WordSourcePath],
10637                           story: &WordStory,
10638                           children: &[usize],
10639                           parts: [&str; 4]| {
10640            let prefix = parts[0];
10641            let mut located = None;
10642            for (page_index, page) in warm.pages.iter().enumerate() {
10643                for element in compatibility_page_elements(page) {
10644                    let (text, source, y) = match element {
10645                        PositionedElement::Text(run) => {
10646                            (run.text.as_str(), run.source, run.origin.y)
10647                        }
10648                        PositionedElement::MultilingualText(run) => {
10649                            (run.logical_text.as_str(), run.source, run.origin.y)
10650                        }
10651                        _ => continue,
10652                    };
10653                    if text == prefix {
10654                        located = source.map(|source| (page_index, source.node, y));
10655                        break;
10656                    }
10657                }
10658                if located.is_some() {
10659                    break;
10660                }
10661            }
10662            let (page_index, node, line_y) = located.unwrap_or_else(|| {
10663                let text = warm
10664                    .pages
10665                    .iter()
10666                    .flat_map(|page| compatibility_page_elements(page))
10667                    .filter_map(|element| match element {
10668                        PositionedElement::Text(run) => Some(run.text.clone()),
10669                        PositionedElement::MultilingualText(run) => Some(run.logical_text.clone()),
10670                        _ => None,
10671                    })
10672                    .collect::<Vec<_>>();
10673                panic!("missing {prefix}: {text:?}")
10674            });
10675            let path = &sources[node.get() as usize - 1];
10676            assert_eq!(&path.story, story);
10677            assert_eq!(path.children, children);
10678            let runs = compatibility_page_elements(&warm.pages[page_index])
10679                .into_iter()
10680                .filter_map(|element| match element {
10681                    PositionedElement::Text(run) if (run.origin.y - line_y).abs() < 0.01 => {
10682                        Some((run.text.clone(), run.origin.x))
10683                    }
10684                    PositionedElement::MultilingualText(run)
10685                        if (run.origin.y - line_y).abs() < 0.01 =>
10686                    {
10687                        Some((run.logical_text.clone(), run.origin.x))
10688                    }
10689                    _ => None,
10690                })
10691                .filter(|(text, _)| {
10692                    parts.contains(&text.as_str())
10693                        || (!text.is_empty()
10694                            && text.chars().all(|character| matches!(character, '.' | '-')))
10695                })
10696                .collect::<Vec<_>>();
10697            let text = runs
10698                .iter()
10699                .map(|(text, _)| text.as_str())
10700                .collect::<String>();
10701            let positions = parts.map(|part| {
10702                text.find(part)
10703                    .unwrap_or_else(|| panic!("missing {part} in {text:?}"))
10704            });
10705            assert!(
10706                positions.windows(2).all(|pair| pair[0] < pair[1]),
10707                "logical extraction for {story:?}: {runs:?}"
10708            );
10709            let dots = text.find('.').expect("dot leader");
10710            let dashes = text.find('-').expect("hyphen leader");
10711            assert!(
10712                positions[1] < dots
10713                    && dots < positions[2]
10714                    && positions[2] < dashes
10715                    && dashes < positions[3],
10716                "leaders keep their logical tabs for {story:?}: {runs:?}"
10717            );
10718            assert!(
10719                runs.iter().find(|run| run.0 == parts[0]).unwrap().1
10720                    > runs.iter().find(|run| run.0 == parts[3]).unwrap().1,
10721                "RTL visual origins survive for {story:?}: {runs:?}"
10722            );
10723        };
10724
10725        assert_line(
10726            &table_warm,
10727            &table_sources,
10728            &WordStory::Document,
10729            &[0, 0, 0, 0],
10730            ["TA", "TB", "TC", "TD"],
10731        );
10732        assert_line(
10733            &header_warm,
10734            &header_sources,
10735            &WordStory::Header {
10736                relationship_id: "rId-first-header".to_owned(),
10737            },
10738            &[0],
10739            ["HA", "HB", "HC", "HD"],
10740        );
10741    }
10742
10743    fn assert_layout_results_equal(left: &LayoutResult, right: &LayoutResult) {
10744        assert_eq!(left.pages.len(), right.pages.len());
10745        for (left, right) in left.pages.iter().zip(&right.pages) {
10746            assert_eq!(left.page_number, right.page_number);
10747            assert_eq!(left.width, right.width);
10748            assert_eq!(left.height, right.height);
10749            assert_eq!(left.elements, right.elements);
10750            assert_eq!(left.background, right.background);
10751        }
10752        assert_eq!(left.fonts.len(), right.fonts.len());
10753        for (left, right) in left.fonts.iter().zip(&right.fonts) {
10754            assert_eq!(left.id, right.id);
10755            assert_eq!(left.family, right.family);
10756            assert_eq!(left.data, right.data);
10757            assert_eq!(left.face_index, right.face_index);
10758            assert_eq!(left.bold, right.bold);
10759            assert_eq!(left.italic, right.italic);
10760        }
10761        match (&left.metadata, &right.metadata) {
10762            (Some(left), Some(right)) => {
10763                assert_eq!(left.title, right.title);
10764                assert_eq!(left.author, right.author);
10765                assert_eq!(left.subject, right.subject);
10766                assert_eq!(left.keywords, right.keywords);
10767                assert_eq!(left.creator, right.creator);
10768            }
10769            (None, None) => {}
10770            _ => panic!("layout metadata presence differs"),
10771        }
10772        assert_eq!(left.diagnostics, right.diagnostics);
10773        assert_eq!(left.outlines.len(), right.outlines.len());
10774        for (left, right) in left.outlines.iter().zip(&right.outlines) {
10775            assert_eq!(left.title, right.title);
10776            assert_eq!(left.level, right.level);
10777            assert_eq!(left.page_index, right.page_index);
10778            assert_eq!(left.y_position, right.y_position);
10779        }
10780        assert_eq!(left.structure, right.structure);
10781        assert_eq!(format!("{left:#?}"), format!("{right:#?}"));
10782    }
10783
10784    #[test]
10785    fn earlier_note_insertion_invalidates_later_cached_markers() {
10786        let mut input = make_input_with_text("safe prefix");
10787        let mut later = CT_P::new();
10788        later.add_run("safe suffix");
10789        input.document.body.add_paragraph(later);
10790        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10791        let original = engine.layout(&input).expect("initial layout");
10792
10793        let mut note_paragraph = CT_P::new();
10794        let mut note_run = CT_R::new("");
10795        note_run.content = vec![RunContent::FootnoteRef { id: 7 }];
10796        note_paragraph.runs.push(note_run);
10797        input
10798            .document
10799            .body
10800            .content
10801            .insert(1, BodyContent::Paragraph(note_paragraph));
10802        let warm_insert = engine.layout(&input).expect("warm insertion layout");
10803        let cold_insert = Engine::new_deterministic()
10804            .expect("bundled fonts load")
10805            .layout(&input)
10806            .expect("cold insertion layout");
10807        assert_layout_results_equal(&warm_insert, &cold_insert);
10808        assert_eq!(engine.paragraph_cache_counts(), (2, 3));
10809
10810        input.document.body.content.remove(1);
10811        let warm_delete = engine.layout(&input).expect("warm deletion layout");
10812        let cold_delete = Engine::new_deterministic()
10813            .expect("bundled fonts load")
10814            .layout(&input)
10815            .expect("cold deletion layout");
10816        assert_layout_results_equal(&warm_delete, &cold_delete);
10817        assert_layout_results_equal(&original, &warm_delete);
10818        assert_eq!(engine.paragraph_cache_counts(), (4, 3));
10819    }
10820
10821    #[test]
10822    fn dense_form_caches_are_transactional_bounded_and_exact() {
10823        let mut input = make_input_with_text("before table");
10824        input
10825            .document
10826            .body
10827            .content
10828            .push(BodyContent::Table(safe_nested_table(
10829                "cached outer cell",
10830                "cached nested cell",
10831            )));
10832        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10833        let cold = engine.layout(&input).expect("cold table layout");
10834        let warm = engine.layout(&input).expect("warm table layout");
10835        assert_layout_results_equal(&cold, &warm);
10836        assert_eq!(engine.table_cache_counts(), (1, 1));
10837
10838        let mut provenance_input = input.clone();
10839        let mut provenance_engine = Engine::new_deterministic().expect("bundled fonts load");
10840        provenance_engine
10841            .layout_with_provenance(&provenance_input)
10842            .expect("prime sourced table cache");
10843        let mut inserted = CT_P::new();
10844        inserted.add_run("inserted before table");
10845        provenance_input
10846            .document
10847            .body
10848            .content
10849            .insert(1, BodyContent::Paragraph(inserted));
10850        let (provenance_layout, sources) = provenance_engine
10851            .layout_with_provenance(&provenance_input)
10852            .expect("warm sourced table layout");
10853        let sourced_runs = provenance_layout
10854            .pages
10855            .iter()
10856            .flat_map(|page| compatibility_page_elements(page))
10857            .filter_map(|element| match element {
10858                PositionedElement::Text(run) => Some((run.text.clone(), run.source)),
10859                _ => None,
10860            })
10861            .collect::<Vec<_>>();
10862        let cached_outer_cell = sourced_runs
10863            .iter()
10864            .find_map(|(text, source)| (text == "outer ").then_some(*source).flatten())
10865            .unwrap_or_else(|| panic!("cached outer cell keeps provenance: {sourced_runs:?}"));
10866        assert_eq!(
10867            sources[cached_outer_cell.node.get() as usize - 1].children,
10868            [2, 0, 0, 0]
10869        );
10870        let cached_nested_cell = sourced_runs
10871            .iter()
10872            .find_map(|(text, source)| (text == "nested ").then_some(*source).flatten())
10873            .unwrap_or_else(|| panic!("cached nested cell keeps provenance: {sourced_runs:?}"));
10874        assert_eq!(
10875            sources[cached_nested_cell.node.get() as usize - 1].children,
10876            [2, 0, 0, 1, 0, 0, 0]
10877        );
10878        assert_eq!(provenance_engine.table_cache_counts(), (1, 1));
10879
10880        let mut bounded = make_input_with_text("bounded prefix");
10881        for index in 0..(TABLE_CACHE_MAX_ENTRIES + 8) {
10882            bounded
10883                .document
10884                .body
10885                .content
10886                .push(BodyContent::Table(safe_table(&format!("table {index}"))));
10887        }
10888        let mut bounded_engine = Engine::new_deterministic().expect("bundled fonts load");
10889        bounded_engine
10890            .layout(&bounded)
10891            .expect("bounded table layout");
10892        assert!(bounded_engine.table_cache.len() <= TABLE_CACHE_MAX_ENTRIES);
10893        assert!(bounded_engine.table_cache_bytes <= TABLE_CACHE_MAX_BYTES);
10894        assert!(bounded_engine.pending_table_cache_peak_entries <= TABLE_CACHE_MAX_ENTRIES);
10895        assert!(bounded_engine.pending_table_cache_peak_bytes <= TABLE_CACHE_MAX_BYTES);
10896
10897        let mut retained_border_block = engine
10898            .table_cache
10899            .back()
10900            .expect("safe table retained")
10901            .block
10902            .as_ref()
10903            .clone();
10904        let mut color = String::with_capacity(TABLE_CACHE_MAX_BYTES + 1);
10905        color.push_str("00");
10906        let mut edge =
10907            rdocx_oxml::borders::CT_BorderEdge::new(rdocx_oxml::shared::ST_Border::Single);
10908        edge.color = Some(color);
10909        let table::CellBlock::Table(nested_block) =
10910            &mut retained_border_block.rows[0].cells[0].blocks[1]
10911        else {
10912            panic!("cached form retains the nested table block");
10913        };
10914        nested_block.borders = Some(rdocx_oxml::table::CT_TblBorders {
10915            top: Some(edge),
10916            ..Default::default()
10917        });
10918        assert!(table_block_retained_bytes(&retained_border_block) > TABLE_CACHE_MAX_BYTES);
10919
10920        let mut unsafe_table = safe_table("numbered cell");
10921        unsafe_table.rows[0].cells[0].paragraphs_mut()[0]
10922            .properties
10923            .get_or_insert_default()
10924            .num_id = Some(1);
10925        assert!(!table_is_cache_safe(&unsafe_table, &input.styles));
10926
10927        let mut preserved_table = safe_table("preserved properties");
10928        preserved_table
10929            .properties
10930            .get_or_insert_default()
10931            .revision_xml
10932            .push(br#"<w:unknown/>"#.to_vec());
10933        assert!(!table_is_cache_safe(&preserved_table, &input.styles));
10934
10935        let mut preserved_cell = safe_table("preserved cell properties");
10936        preserved_cell.rows[0].cells[0]
10937            .properties
10938            .get_or_insert_default()
10939            .extra_xml
10940            .push((0, br#"<w:unknown/>"#.to_vec()));
10941        assert!(!table_is_cache_safe(&preserved_cell, &input.styles));
10942    }
10943
10944    fn restart_input() -> LayoutInput {
10945        let mut input = make_input_with_text("paragraph 000 stable line");
10946        for index in 1..140 {
10947            let mut paragraph = CT_P::new();
10948            paragraph.add_run(&format!("paragraph {index:03} stable line"));
10949            input.document.body.add_paragraph(paragraph);
10950        }
10951        input
10952    }
10953
10954    fn ordinary_prose_restart_input(paragraph_count: usize) -> LayoutInput {
10955        let mut input = make_input_with_text("");
10956        input.document.body.content.clear();
10957        for index in 0..paragraph_count {
10958            let mut paragraph = CT_P::new();
10959            if index == 10 {
10960                paragraph.properties = Some(CT_PPr {
10961                    style_id: Some("Heading1".to_owned()),
10962                    ..CT_PPr::default()
10963                });
10964            } else if index == 11 {
10965                paragraph.properties.get_or_insert_default().keep_next = Some(true);
10966            } else if index == 13 {
10967                paragraph.properties.get_or_insert_default().keep_lines = Some(true);
10968            }
10969            let text = if index == 20 {
10970                "ordinary multiline paragraph wraps without splitting across pages ".repeat(8)
10971            } else {
10972                format!("ordinary prose paragraph {index:03} stable line")
10973            };
10974            paragraph.add_run(&text);
10975            input.document.body.add_paragraph(paragraph);
10976        }
10977        input
10978    }
10979
10980    fn page_spanning_prose_paragraph(index: usize) -> CT_P {
10981        let mut paragraph = CT_P::new();
10982        paragraph.add_run(&format!(
10983            "Paragraph {index}: the quick brown fox jumps over the lazy dog, pack my box \
10984             with five dozen liquor jugs, and a mixed sentence that keeps going. \
10985             Sphinx of black quartz, judge my vow across line breaks and pages. \
10986             Waltz, bad nymph, for quick jigs vex. Glib jocks quiz nymph to vex dwarf. \
10987             Bright vixens jump, dozy fowl quack."
10988        ));
10989        paragraph
10990    }
10991
10992    fn page_spanning_prose_restart_input(paragraph_count: usize) -> LayoutInput {
10993        let mut input = make_input_with_text("");
10994        input.document.body.content = (0..paragraph_count)
10995            .map(|index| BodyContent::Paragraph(page_spanning_prose_paragraph(index)))
10996            .collect();
10997        input.core_properties = Some(rdocx_oxml::core_properties::CoreProperties {
10998            title: Some("Issue 67 page-spanning prose".to_owned()),
10999            creator: Some("rdocx-layout regression".to_owned()),
11000            subject: Some("restart pagination".to_owned()),
11001            keywords: Some("page-spanning,provenance".to_owned()),
11002            ..Default::default()
11003        });
11004        input
11005    }
11006
11007    fn change_page_spanning_paragraph(input: &mut LayoutInput, index: usize, revision: usize) {
11008        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[index] else {
11009            panic!("page-spanning body entry is a paragraph");
11010        };
11011        let RunContent::Text(text) = &mut paragraph.runs[0].content[0] else {
11012            panic!("page-spanning paragraph begins with text");
11013        };
11014        text.text.push_str(&format!(" edit{revision}"));
11015    }
11016
11017    #[test]
11018    fn page_spanning_prose_publishes_complete_boundary_restart_records() {
11019        let input = page_spanning_prose_restart_input(175);
11020        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11021        let result = engine.layout(&input).expect("page-spanning prose layout");
11022
11023        assert_eq!(result.pages.len(), 16, "Issue 67 source page count");
11024        assert_eq!(engine.paragraph_cache.len(), 175);
11025        assert!(
11026            engine
11027                .paragraph_cache
11028                .iter()
11029                .all(|entry| entry.block.lines.len() == 4),
11030            "every Issue 67 source paragraph must wrap to exactly four lines"
11031        );
11032        assert_eq!(
11033            engine.page_layout_invocation_count(),
11034            result.pages.len(),
11035            "the completed recorded pass must be the published pass"
11036        );
11037        let retained = engine
11038            .restart_cache
11039            .as_ref()
11040            .expect("page-spanning prose must retain restart state");
11041        let boundaries = retained
11042            .checkpoints
11043            .iter()
11044            .map(|checkpoint| (checkpoint.next_block_index, checkpoint.page_count))
11045            .collect::<Vec<_>>();
11046        assert_eq!(
11047            boundaries,
11048            [
11049                (0, 0),
11050                (23, 2),
11051                (46, 4),
11052                (69, 6),
11053                (92, 8),
11054                (115, 10),
11055                (138, 12),
11056                (161, 14),
11057            ],
11058            "the first page ends inside paragraph 11, so its first eligible complete boundary is block 23 after page 2"
11059        );
11060    }
11061
11062    #[test]
11063    fn page_spanning_prose_restarts_warm_edits_exactly() {
11064        let mut input = page_spanning_prose_restart_input(175);
11065        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11066        engine
11067            .layout_with_provenance(&input)
11068            .expect("prime sourced page-spanning prose");
11069
11070        for revision in 0..10 {
11071            let index = 80 + revision;
11072            change_page_spanning_paragraph(&mut input, index, revision);
11073            let before = engine.paragraph_cache_counts();
11074            let (warm, warm_sources) = engine
11075                .layout_with_provenance(&input)
11076                .expect("warm sourced middle edit");
11077            let after = engine.paragraph_cache_counts();
11078            assert_eq!(after.0 - before.0, 174, "warm hits for edit {revision}");
11079            assert_eq!(after.1 - before.1, 1, "warm build for edit {revision}");
11080            assert!(
11081                engine.page_layout_invocation_count() <= 2,
11082                "edit {revision} repaginated {} pages",
11083                engine.page_layout_invocation_count()
11084            );
11085            let rebuilt = engine
11086                .last_rebuilt_page_range
11087                .clone()
11088                .expect("warm edit reports its rebuilt range");
11089            assert!(
11090                rebuilt.end.saturating_sub(rebuilt.start) <= 2,
11091                "edit {revision}: {rebuilt:?}"
11092            );
11093            let (fresh, fresh_sources) = Engine::new_deterministic()
11094                .expect("bundled fonts load")
11095                .layout_with_provenance(&input)
11096                .expect("fresh sourced middle edit");
11097            assert_layout_results_equal(&warm, &fresh);
11098            assert_eq!(warm_sources, fresh_sources);
11099        }
11100    }
11101
11102    #[test]
11103    fn page_spanning_prose_edit_matrix_matches_fresh_layout() {
11104        let original_input = page_spanning_prose_restart_input(175);
11105        let mut input = original_input.clone();
11106        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11107        let original = engine.layout(&input).expect("prime page-spanning prose");
11108
11109        change_page_spanning_paragraph(&mut input, 170, 1);
11110        let warm_edit = engine.layout(&input).expect("warm late edit");
11111        let fresh_edit = Engine::new_deterministic()
11112            .expect("bundled fonts load")
11113            .layout(&input)
11114            .expect("fresh late edit");
11115        assert_layout_results_equal(&warm_edit, &fresh_edit);
11116        assert!(engine.page_layout_invocation_count() <= 2);
11117
11118        input.document.body.content.insert(
11119            160,
11120            BodyContent::Paragraph(page_spanning_prose_paragraph(999)),
11121        );
11122        let warm_insert = engine.layout(&input).expect("warm insertion");
11123        let fresh_insert = Engine::new_deterministic()
11124            .expect("bundled fonts load")
11125            .layout(&input)
11126            .expect("fresh insertion");
11127        assert_layout_results_equal(&warm_insert, &fresh_insert);
11128
11129        input.document.body.content.remove(160);
11130        let warm_delete = engine.layout(&input).expect("warm deletion");
11131        let fresh_delete = Engine::new_deterministic()
11132            .expect("bundled fonts load")
11133            .layout(&input)
11134            .expect("fresh deletion");
11135        assert_layout_results_equal(&warm_delete, &fresh_delete);
11136
11137        input = original_input;
11138        let warm_undo = engine.layout(&input).expect("warm undo");
11139        let fresh_undo = Engine::new_deterministic()
11140            .expect("bundled fonts load")
11141            .layout(&input)
11142            .expect("fresh undo");
11143        assert_layout_results_equal(&warm_undo, &fresh_undo);
11144        assert_layout_results_equal(&warm_undo, &original);
11145    }
11146
11147    #[test]
11148    fn page_spanning_note_and_page_footer_restart_only_at_clean_boundaries() {
11149        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
11150        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
11151
11152        let mut input = page_spanning_prose_restart_input(175);
11153        let BodyContent::Paragraph(split) = &mut input.document.body.content[11] else {
11154            panic!("split body entry is a paragraph");
11155        };
11156        let mut marker = CT_R::new("");
11157        marker.content = vec![RunContent::FootnoteRef { id: 1 }];
11158        split.runs.push(marker);
11159        let mut note = CT_P::new();
11160        note.add_run("page-spanning footnote");
11161        input.footnotes = Some(CT_Footnotes {
11162            footnotes: vec![CT_Footnote {
11163                id: 1,
11164                note_type: NoteType::Normal,
11165                paragraphs: vec![note],
11166            }],
11167        });
11168        let section = input
11169            .document
11170            .body
11171            .sect_pr
11172            .get_or_insert_with(CT_SectPr::default_letter);
11173        section.footer_refs.push(HdrFtrRef {
11174            hdr_ftr_type: HdrFtrType::Default,
11175            rel_id: "rIdPageFooter".to_owned(),
11176        });
11177        let mut footer = CT_HdrFtr::new();
11178        let mut footer_paragraph = CT_P::new();
11179        let mut page = CT_R::new("");
11180        page.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
11181        footer_paragraph.runs.push(page);
11182        footer.paragraphs.push(footer_paragraph);
11183        input.footers.insert("rIdPageFooter".to_owned(), footer);
11184
11185        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11186        let initial = engine.layout(&input).expect("prime note and footer layout");
11187        let retained = engine
11188            .restart_cache
11189            .as_ref()
11190            .expect("clean complete boundaries retain restart state");
11191        assert!(page_text(&initial.pages[0]).contains("Paragraph  11:"));
11192        assert!(
11193            page_text(&initial.pages[1]).starts_with("Waltz"),
11194            "page 2 must begin with paragraph 11's split continuation"
11195        );
11196        let first_complete = retained
11197            .checkpoints
11198            .iter()
11199            .find(|checkpoint| checkpoint.page_count > 0)
11200            .expect("a clean boundary follows the split paragraph");
11201        assert!(
11202            first_complete.page_count > 1 && first_complete.next_block_index > 11,
11203            "no boundary may be retained inside the split note-bearing paragraph"
11204        );
11205        for page in &initial.pages {
11206            let displayed = compatibility_page_elements(page)
11207                .into_iter()
11208                .find_map(|element| match element {
11209                    PositionedElement::Text(run)
11210                        if matches!(run.field_kind, Some(FieldKind::Page)) =>
11211                    {
11212                        Some(run.text.as_str())
11213                    }
11214                    _ => None,
11215                })
11216                .expect("each page has a displayed PAGE footer");
11217            assert_eq!(displayed, page.page_number.to_string());
11218        }
11219
11220        change_page_spanning_paragraph(&mut input, 80, 1);
11221        let warm = engine.layout(&input).expect("warm note and footer edit");
11222        let fresh = Engine::new_deterministic()
11223            .expect("bundled fonts load")
11224            .layout(&input)
11225            .expect("fresh note and footer edit");
11226        assert_layout_results_equal(&warm, &fresh);
11227        assert!(engine.page_layout_invocation_count() <= 2);
11228    }
11229
11230    #[test]
11231    fn ordinary_multiline_heading_and_keep_paragraphs_publish_restart_records() {
11232        let input = ordinary_prose_restart_input(140);
11233        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11234        let result = engine
11235            .layout(&input)
11236            .expect("ordinary prose layout succeeds");
11237
11238        let multiline = engine
11239            .paragraph_cache
11240            .iter()
11241            .find(|entry| entry.key.paragraph.text().starts_with("ordinary multiline"))
11242            .expect("multiline paragraph is cached");
11243        assert!(multiline.block.lines.len() > 2);
11244        assert_eq!(result.outlines.len(), 1);
11245        assert_eq!(result.outlines[0].level, 1);
11246        assert!(
11247            engine
11248                .restart_cache
11249                .as_ref()
11250                .is_some_and(|cache| !cache.checkpoints.is_empty()),
11251            "complete ordinary-prose block boundaries must publish restart checkpoints"
11252        );
11253    }
11254
11255    #[test]
11256    fn restart_candidate_uses_available_aggregate_cache_budget() {
11257        let mut input = make_input_with_text("aggregate candidate");
11258        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
11259            panic!("body entry is a paragraph");
11260        };
11261        paragraph.properties = Some(CT_PPr {
11262            style_id: Some("x".repeat(LEGACY_RESTART_CACHE_MAX_BYTES + 64 * 1024)),
11263            ..CT_PPr::default()
11264        });
11265
11266        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11267        engine
11268            .layout(&input)
11269            .expect("large candidate layout succeeds");
11270        assert!(engine.last_restart_candidate_bytes > LEGACY_RESTART_CACHE_MAX_BYTES);
11271        assert!(
11272            engine
11273                .paragraph_cache_bytes
11274                .checked_add(engine.table_cache_bytes)
11275                .and_then(|bytes| bytes.checked_add(engine.header_footer_cache_bytes))
11276                .and_then(|bytes| bytes.checked_add(engine.last_restart_candidate_bytes))
11277                .is_some_and(|bytes| bytes <= CACHE_MAX_BYTES)
11278        );
11279        assert!(
11280            engine.restart_cache.is_some(),
11281            "candidate above 8 MiB must use available aggregate capacity"
11282        );
11283    }
11284
11285    #[test]
11286    fn restart_candidate_over_aggregate_budget_fails_closed() {
11287        for occupied in [CACHE_MAX_BYTES, usize::MAX] {
11288            let input = restart_input();
11289            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11290            let original = engine.layout(&input).expect("prime restart state");
11291            engine.paragraph_cache_bytes = occupied;
11292
11293            let warm = engine.layout(&input).expect("pressured layout succeeds");
11294            let fresh = Engine::new_deterministic()
11295                .expect("bundled fonts load")
11296                .layout(&input)
11297                .expect("fresh pressured layout succeeds");
11298            assert_layout_results_equal(&warm, &fresh);
11299            assert_layout_results_equal(&warm, &original);
11300            assert!(
11301                engine.restart_cache.is_none(),
11302                "aggregate pressure {occupied} must reject the candidate"
11303            );
11304        }
11305    }
11306
11307    #[test]
11308    fn ordinary_prose_late_edit_insert_delete_and_undo_match_fresh_layout() {
11309        let mut input = ordinary_prose_restart_input(700);
11310        let original_input = input.clone();
11311        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11312        let original = engine.layout(&input).expect("prime ordinary prose state");
11313        assert!(engine.restart_cache.is_some());
11314
11315        set_body_paragraph_text(&mut input, 650, "ordinary prose paragraph 650 changed");
11316        let warm_edit = engine.layout(&input).expect("warm late edit");
11317        assert!(
11318            engine.page_layout_invocation_count() <= 2,
11319            "edit recomputed {} pages",
11320            engine.page_layout_invocation_count()
11321        );
11322        let fresh_edit = Engine::new_deterministic()
11323            .expect("bundled fonts load")
11324            .layout(&input)
11325            .expect("fresh late edit");
11326        assert_layout_results_equal(&warm_edit, &fresh_edit);
11327
11328        let mut inserted = CT_P::new();
11329        inserted.add_run("ordinary inserted paragraph");
11330        input
11331            .document
11332            .body
11333            .content
11334            .insert(640, BodyContent::Paragraph(inserted));
11335        let warm_insert = engine.layout(&input).expect("warm insertion");
11336        assert!(
11337            engine.page_layout_invocation_count() <= 3,
11338            "insertion recomputed {} pages",
11339            engine.page_layout_invocation_count()
11340        );
11341        let fresh_insert = Engine::new_deterministic()
11342            .expect("bundled fonts load")
11343            .layout(&input)
11344            .expect("fresh insertion");
11345        assert_layout_results_equal(&warm_insert, &fresh_insert);
11346
11347        input.document.body.content.remove(640);
11348        let warm_delete = engine.layout(&input).expect("warm deletion");
11349        assert!(
11350            engine.page_layout_invocation_count() <= 3,
11351            "deletion recomputed {} pages",
11352            engine.page_layout_invocation_count()
11353        );
11354        let fresh_delete = Engine::new_deterministic()
11355            .expect("bundled fonts load")
11356            .layout(&input)
11357            .expect("fresh deletion");
11358        assert_layout_results_equal(&warm_delete, &fresh_delete);
11359
11360        input = original_input;
11361        let warm_undo = engine.layout(&input).expect("warm undo");
11362        assert!(
11363            engine.page_layout_invocation_count() <= 3,
11364            "undo recomputed {} pages",
11365            engine.page_layout_invocation_count()
11366        );
11367        let fresh_undo = Engine::new_deterministic()
11368            .expect("bundled fonts load")
11369            .layout(&input)
11370            .expect("fresh undo");
11371        assert_layout_results_equal(&warm_undo, &fresh_undo);
11372        assert_layout_results_equal(&warm_undo, &original);
11373    }
11374
11375    #[test]
11376    fn ordinary_prose_restart_bounds_recomputed_page_work() {
11377        let mut input = ordinary_prose_restart_input(700);
11378        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11379        let initial = engine.layout(&input).expect("prime ordinary prose state");
11380        assert!(initial.pages.len() > 2);
11381        assert!(engine.restart_cache.is_some());
11382
11383        set_body_paragraph_text(&mut input, 650, "ordinary prose paragraph 650 changed");
11384        let warm = engine.layout(&input).expect("warm late edit");
11385        assert!(engine.page_layout_invocation_count() <= 2);
11386        let fresh = Engine::new_deterministic()
11387            .expect("bundled fonts load")
11388            .layout(&input)
11389            .expect("fresh late edit");
11390        assert_layout_results_equal(&warm, &fresh);
11391    }
11392
11393    #[test]
11394    fn unrepresented_restart_content_remains_rejected() {
11395        let assert_no_checkpoints = |label: &str, input: LayoutInput| {
11396            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11397            engine
11398                .layout(&input)
11399                .unwrap_or_else(|error| panic!("{label}: {error}"));
11400            assert!(
11401                engine
11402                    .restart_cache
11403                    .as_ref()
11404                    .is_none_or(|cache| cache.checkpoints.is_empty()),
11405                "{label}"
11406            );
11407        };
11408
11409        let mut numbered = restart_input();
11410        let BodyContent::Paragraph(paragraph) = &mut numbered.document.body.content[20] else {
11411            panic!("numbered body entry is a paragraph");
11412        };
11413        paragraph.properties.get_or_insert_default().num_id = Some(1);
11414        assert_no_checkpoints("numbering", numbered);
11415
11416        for instruction in ["PAGE", "DATE", "REF missing", "UNSUPPORTED"] {
11417            let mut field = restart_input();
11418            let BodyContent::Paragraph(paragraph) = &mut field.document.body.content[20] else {
11419                panic!("field body entry is a paragraph");
11420            };
11421            let mut run = CT_R::new("");
11422            run.content = vec![RunContent::Field(Field::new(instruction, "cached"))];
11423            paragraph.runs.push(run);
11424            assert_no_checkpoints(instruction, field);
11425        }
11426
11427        let mut drawing = restart_input();
11428        let BodyContent::Paragraph(paragraph) = &mut drawing.document.body.content[20] else {
11429            panic!("drawing body entry is a paragraph");
11430        };
11431        let mut run = CT_R::new("");
11432        run.content = vec![RunContent::Drawing(rdocx_oxml::drawing::CT_Drawing {
11433            inline: None,
11434            anchor: None,
11435        })];
11436        paragraph.runs.push(run);
11437        assert_no_checkpoints("drawing", drawing);
11438
11439        let mut raw = restart_input();
11440        let BodyContent::Paragraph(paragraph) = &mut raw.document.body.content[20] else {
11441            panic!("raw body entry is a paragraph");
11442        };
11443        paragraph.extra_xml.push((0, br#"<w:unknown/>"#.to_vec()));
11444        assert_no_checkpoints("raw child", raw);
11445
11446        let mut foreign_bookmark = restart_input();
11447        let BodyContent::Paragraph(paragraph) = &mut foreign_bookmark.document.body.content[20]
11448        else {
11449            panic!("bookmark body entry is a paragraph");
11450        };
11451        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11452        assert!(paragraph.insert_bookmark_end(1, 7));
11453        paragraph.extra_xml[0].1 =
11454            br#"<ext:bookmarkStart xmlns:ext="urn:foreign" ext:id="7"/>"#.to_vec();
11455        assert_no_checkpoints("same-count foreign bookmark raw", foreign_bookmark);
11456
11457        for (label, duplicate_raw) in [
11458            (
11459                "duplicate expanded bookmark id",
11460                br#"<w:bookmarkStart xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:id="7" x:id="7" w:name="target"/>"#.as_slice(),
11461            ),
11462            (
11463                "duplicate expanded bookmark name",
11464                br#"<w:bookmarkStart xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:id="7" w:name="target" x:name="target"/>"#.as_slice(),
11465            ),
11466        ] {
11467            let mut duplicate_bookmark = restart_input();
11468            let BodyContent::Paragraph(paragraph) =
11469                &mut duplicate_bookmark.document.body.content[20]
11470            else {
11471                panic!("bookmark body entry is a paragraph");
11472            };
11473            assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11474            assert!(paragraph.insert_bookmark_end(1, 7));
11475            paragraph.extra_xml[0].1 = duplicate_raw.to_vec();
11476            assert_no_checkpoints(label, duplicate_bookmark);
11477        }
11478
11479        let mut nested_bookmark = restart_input();
11480        let BodyContent::Paragraph(paragraph) = &mut nested_bookmark.document.body.content[20]
11481        else {
11482            panic!("bookmark body entry is a paragraph");
11483        };
11484        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11485        assert!(paragraph.insert_bookmark_end(1, 7));
11486        paragraph.extra_xml[0].1 =
11487            br#"<w:bookmarkStart w:id="7" w:name="target"><w:unknown/></w:bookmarkStart>"#.to_vec();
11488        assert_no_checkpoints("non-empty bookmark root", nested_bookmark);
11489
11490        let mut trailing_bookmark = restart_input();
11491        let BodyContent::Paragraph(paragraph) = &mut trailing_bookmark.document.body.content[20]
11492        else {
11493            panic!("bookmark body entry is a paragraph");
11494        };
11495        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11496        assert!(paragraph.insert_bookmark_end(1, 7));
11497        paragraph.extra_xml[0].1 =
11498            br#"<w:bookmarkStart w:id="7" w:name="target"/><w:unknown/>"#.to_vec();
11499        assert_no_checkpoints("trailing bookmark raw", trailing_bookmark);
11500
11501        let mut stale_raw_before = restart_input();
11502        let BodyContent::Paragraph(paragraph) = &mut stale_raw_before.document.body.content[20]
11503        else {
11504            panic!("bookmark body entry is a paragraph");
11505        };
11506        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11507        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11508        paragraph.bookmark_markers.swap(0, 1);
11509        assert_no_checkpoints("stale bookmark raw order", stale_raw_before);
11510
11511        let aliased = CT_Document::from_xml(
11512            br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><x:bookmarkStart xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" x:id="7" x:name="target"/><w:r><w:t>text</w:t></w:r><x:bookmarkEnd xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" x:id="7"/></w:p></w:body></w:document>"#,
11513        )
11514        .expect("locally aliased bookmarks parse");
11515        let BodyContent::Paragraph(aliased) = &aliased.body.content[0] else {
11516            panic!("aliased bookmark body entry is a paragraph");
11517        };
11518        assert!(paragraph_bookmark_raw_is_exact(aliased));
11519
11520        let mut multilingual = restart_input();
11521        let BodyContent::Paragraph(paragraph) = &mut multilingual.document.body.content[20] else {
11522            panic!("multilingual body entry is a paragraph");
11523        };
11524        paragraph.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
11525            "다국어 상태",
11526        ))];
11527        paragraph.runs[0]
11528            .properties
11529            .get_or_insert_default()
11530            .language = Some("ko-KR".to_owned());
11531        assert_no_checkpoints("multilingual state", multilingual);
11532
11533        assert_no_checkpoints(
11534            "anchored empty paragraph",
11535            make_wrapping_document(WrapType::Square, None, 120.0, 60.0, 5.0),
11536        );
11537    }
11538
11539    fn related_story_restart_input(paragraph_count: usize) -> LayoutInput {
11540        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
11541        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
11542
11543        let mut input = make_input_with_text("paragraph 000 stable line");
11544        for index in 1..paragraph_count {
11545            let mut paragraph = CT_P::new();
11546            paragraph.add_run(&format!("paragraph {index:03} stable line"));
11547            input.document.body.add_paragraph(paragraph);
11548        }
11549
11550        for (index, content) in [
11551            (20, RunContent::FootnoteRef { id: 1 }),
11552            (40, RunContent::EndnoteRef { id: 2 }),
11553        ] {
11554            let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[index] else {
11555                panic!("related story reference belongs to a paragraph");
11556            };
11557            let mut run = CT_R::new("");
11558            run.content = vec![content];
11559            paragraph.runs.push(run);
11560        }
11561
11562        let mut footnote = CT_P::new();
11563        footnote.add_run("stable footnote text");
11564        input.footnotes = Some(CT_Footnotes {
11565            footnotes: vec![CT_Footnote {
11566                id: 1,
11567                note_type: NoteType::Normal,
11568                paragraphs: vec![footnote],
11569            }],
11570        });
11571        let mut endnote = CT_P::new();
11572        endnote.add_run("stable endnote text");
11573        input.endnotes = Some(CT_Footnotes {
11574            footnotes: vec![CT_Footnote {
11575                id: 2,
11576                note_type: NoteType::Normal,
11577                paragraphs: vec![endnote],
11578            }],
11579        });
11580
11581        let mut section = CT_SectPr::default_letter();
11582        section.header_refs.push(HdrFtrRef {
11583            hdr_ftr_type: HdrFtrType::Default,
11584            rel_id: "rIdHeader".to_owned(),
11585        });
11586        section.footer_refs.push(HdrFtrRef {
11587            hdr_ftr_type: HdrFtrType::Default,
11588            rel_id: "rIdFooter".to_owned(),
11589        });
11590        input.document.body.sect_pr = Some(section);
11591
11592        let mut header = CT_HdrFtr::new();
11593        let mut header_paragraph = CT_P::new();
11594        header_paragraph.add_run("stable header text");
11595        header.paragraphs.push(header_paragraph);
11596        input.headers.insert("rIdHeader".to_owned(), header);
11597
11598        let mut footer = CT_HdrFtr::new();
11599        let mut footer_paragraph = CT_P::new();
11600        let mut page_run = CT_R::new("");
11601        page_run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
11602        footer_paragraph.runs.push(page_run);
11603        footer.paragraphs.push(footer_paragraph);
11604        input.footers.insert("rIdFooter".to_owned(), footer);
11605        input
11606    }
11607
11608    #[test]
11609    fn unchanged_footnote_and_endnote_context_restarts_only_at_note_clean_boundaries() {
11610        let mut input = related_story_restart_input(700);
11611        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11612        let initial = engine.layout(&input).expect("initial related-story layout");
11613        let initial_pages = initial.pages.clone();
11614        assert!(
11615            engine.restart_cache.is_some(),
11616            "unchanged note context must permit a restart record, candidate {} bytes",
11617            engine.last_restart_candidate_bytes
11618        );
11619
11620        set_body_paragraph_text(&mut input, 350, "paragraph 350 changed line");
11621        let warm = engine.layout(&input).expect("warm related-story layout");
11622        let fresh = Engine::new_deterministic()
11623            .expect("bundled fonts load")
11624            .layout(&input)
11625            .expect("fresh related-story layout");
11626        assert_layout_results_equal(&warm, &fresh);
11627        assert!((1..=2).contains(&engine.page_layout_invocation_count()));
11628        assert!(
11629            warm.pages
11630                .iter()
11631                .zip(&initial_pages)
11632                .filter(|(current, retained)| Arc::ptr_eq(current, retained))
11633                .count()
11634                >= warm.pages.len().saturating_sub(2)
11635        );
11636        let rendered_text = warm
11637            .pages
11638            .iter()
11639            .flat_map(|page| compatibility_page_elements(page))
11640            .filter_map(|element| match element {
11641                PositionedElement::Text(run) => Some(run.text.as_str()),
11642                _ => None,
11643            })
11644            .collect::<String>();
11645        assert_eq!(
11646            rendered_text.matches("stable endnote text").count(),
11647            1,
11648            "endnote pages append exactly once: {rendered_text}"
11649        );
11650    }
11651
11652    #[test]
11653    fn restarted_body_completion_appends_prefix_and_suffix_endnotes_with_final_page_numbers() {
11654        use rdocx_oxml::footnotes::{CT_Footnote, NoteType};
11655
11656        let mut input = related_story_restart_input(700);
11657        let BodyContent::Paragraph(last) = &mut input.document.body.content[699] else {
11658            panic!("last body entry is a paragraph");
11659        };
11660        let mut marker = CT_R::new("");
11661        marker.content = vec![RunContent::EndnoteRef { id: 3 }];
11662        last.runs.push(marker);
11663        let mut suffix_endnote = CT_P::new();
11664        suffix_endnote.add_run("suffix endnote text");
11665        input
11666            .endnotes
11667            .as_mut()
11668            .expect("endnote stream")
11669            .footnotes
11670            .push(CT_Footnote {
11671                id: 3,
11672                note_type: NoteType::Normal,
11673                paragraphs: vec![suffix_endnote],
11674            });
11675
11676        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11677        let initial = engine.layout(&input).expect("initial endnote layout");
11678        set_body_paragraph_text(&mut input, 699, "paragraph 699 changed line");
11679        let warm = engine.layout(&input).expect("completed warm body layout");
11680        let fresh = Engine::new_deterministic()
11681            .expect("bundled fonts load")
11682            .layout(&input)
11683            .expect("fresh completed body layout");
11684        assert_layout_results_equal(&warm, &fresh);
11685        assert!((1..=2).contains(&engine.page_layout_invocation_count()));
11686        assert_eq!(
11687            warm.pages.last().map(|page| page.page_number),
11688            Some(warm.pages.len()),
11689            "the final endnote page keeps its document-wide page number"
11690        );
11691        assert!(
11692            !Arc::ptr_eq(
11693                warm.pages.last().expect("warm final endnote page"),
11694                initial.pages.last().expect("initial final endnote page")
11695            ),
11696            "completion must append endnotes instead of attaching the cached tail"
11697        );
11698        let rendered_text = warm
11699            .pages
11700            .iter()
11701            .flat_map(|page| compatibility_page_elements(page))
11702            .filter_map(|element| match element {
11703                PositionedElement::Text(run) => Some(run.text.as_str()),
11704                _ => None,
11705            })
11706            .collect::<String>();
11707        assert_eq!(rendered_text.matches("stable endnote text").count(), 1);
11708        assert_eq!(rendered_text.matches("suffix endnote text").count(), 1);
11709    }
11710
11711    #[test]
11712    fn unchanged_header_and_footer_context_keeps_restart_pagination_eligible() {
11713        let mut input = related_story_restart_input(700);
11714        input.footnotes = None;
11715        input.endnotes = None;
11716        for content in &mut input.document.body.content {
11717            let BodyContent::Paragraph(paragraph) = content else {
11718                continue;
11719            };
11720            for run in &mut paragraph.runs {
11721                run.content.retain(|content| {
11722                    !matches!(
11723                        content,
11724                        RunContent::FootnoteRef { .. } | RunContent::EndnoteRef { .. }
11725                    )
11726                });
11727            }
11728        }
11729        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11730        engine.layout(&input).expect("initial header-footer layout");
11731        assert!(
11732            engine.restart_cache.is_some(),
11733            "default headers and footers must permit a restart record, candidate {} bytes",
11734            engine.last_restart_candidate_bytes
11735        );
11736
11737        set_body_paragraph_text(&mut input, 350, "paragraph 350 changed line");
11738        let warm = engine.layout(&input).expect("warm header-footer layout");
11739        let fresh = Engine::new_deterministic()
11740            .expect("bundled fonts load")
11741            .layout(&input)
11742            .expect("fresh header-footer layout");
11743        assert_layout_results_equal(&warm, &fresh);
11744        assert!((1..=2).contains(&engine.page_layout_invocation_count()));
11745    }
11746
11747    #[test]
11748    fn changed_related_story_context_invalidates_restart_state() {
11749        fn assert_invalidated(label: &str, mutate: impl FnOnce(&mut LayoutInput)) {
11750            let mut input = related_story_restart_input(700);
11751            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11752            engine
11753                .layout(&input)
11754                .unwrap_or_else(|error| panic!("prime {label}: {error}"));
11755            assert!(
11756                engine.restart_cache.is_some(),
11757                "prime {label}, candidate {} bytes",
11758                engine.last_restart_candidate_bytes
11759            );
11760            mutate(&mut input);
11761            let warm = engine
11762                .layout(&input)
11763                .unwrap_or_else(|error| panic!("warm {label}: {error}"));
11764            let fresh = Engine::new_deterministic()
11765                .expect("bundled fonts load")
11766                .layout(&input)
11767                .unwrap_or_else(|error| panic!("fresh {label}: {error}"));
11768            assert_layout_results_equal(&warm, &fresh);
11769            assert!(
11770                engine.page_layout_invocation_count() > 2,
11771                "changed {label} must force full pagination"
11772            );
11773        }
11774
11775        assert_invalidated("footnote", |input| {
11776            set_body_paragraph_text_in_story(
11777                &mut input.footnotes.as_mut().expect("footnote stream").footnotes[0].paragraphs[0],
11778                "changed footnote text",
11779            );
11780        });
11781        assert_invalidated("endnote", |input| {
11782            set_body_paragraph_text_in_story(
11783                &mut input.endnotes.as_mut().expect("endnote stream").footnotes[0].paragraphs[0],
11784                "changed endnote text",
11785            );
11786        });
11787        assert_invalidated("header", |input| {
11788            set_body_paragraph_text_in_story(
11789                &mut input
11790                    .headers
11791                    .get_mut("rIdHeader")
11792                    .expect("header")
11793                    .paragraphs[0],
11794                "changed header text",
11795            );
11796        });
11797        assert_invalidated("footer", |input| {
11798            let footer = input.footers.get_mut("rIdFooter").expect("footer");
11799            footer.paragraphs[0].add_run("changed footer text");
11800        });
11801    }
11802
11803    fn set_body_paragraph_text_in_story(paragraph: &mut CT_P, text: &str) {
11804        paragraph.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text {
11805            text: text.to_owned(),
11806            preserve_space: false,
11807        })];
11808    }
11809
11810    #[test]
11811    fn a_footnote_continuation_never_creates_a_dirty_restart_boundary() {
11812        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
11813
11814        let mut input = make_input_with_text("body carrying a long note");
11815        let BodyContent::Paragraph(first) = &mut input.document.body.content[0] else {
11816            panic!("first body entry is a paragraph");
11817        };
11818        let mut marker = CT_R::new("");
11819        marker.content = vec![RunContent::FootnoteRef { id: 1 }];
11820        first.runs.push(marker);
11821        for index in 1..8 {
11822            let mut paragraph = CT_P::new();
11823            paragraph.properties = Some(CT_PPr {
11824                page_break_before: Some(true),
11825                ..Default::default()
11826            });
11827            paragraph.add_run(&format!("body page {index}"));
11828            input.document.body.add_paragraph(paragraph);
11829        }
11830        let mut long_note = CT_P::new();
11831        long_note.add_run(&"continuing footnote text ".repeat(2_000));
11832        input.footnotes = Some(CT_Footnotes {
11833            footnotes: vec![CT_Footnote {
11834                id: 1,
11835                note_type: NoteType::Normal,
11836                paragraphs: vec![long_note],
11837            }],
11838        });
11839
11840        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11841        let output = engine.layout(&input).expect("continued footnote layout");
11842        assert!(output.pages.len() > 2, "fixture must continue the footnote");
11843        let retained = engine
11844            .restart_cache
11845            .as_ref()
11846            .expect("continued notes retain only clean boundaries");
11847        assert!(
11848            retained
11849                .checkpoints
11850                .iter()
11851                .all(|checkpoint| checkpoint.next_block_index != 1),
11852            "the boundary carrying pending note state must not be retained"
11853        );
11854    }
11855
11856    fn set_body_paragraph_text(input: &mut LayoutInput, index: usize, text: &str) {
11857        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[index] else {
11858            panic!("body entry is a paragraph");
11859        };
11860        paragraph.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text {
11861            text: text.to_owned(),
11862            preserve_space: false,
11863        })];
11864    }
11865
11866    fn substituted_restart_input() -> LayoutInput {
11867        let mut input = restart_input();
11868        let BodyContent::Paragraph(fields) = &mut input.document.body.content[0] else {
11869            panic!("body entry is a paragraph");
11870        };
11871        for (instruction, display) in [
11872            ("PAGE", "page"),
11873            ("NUMPAGES", "pages"),
11874            ("PAGEREF destination", "target"),
11875        ] {
11876            let mut run = CT_R::new("");
11877            run.content = vec![RunContent::Field(Field::new(instruction, display))];
11878            fields.runs.push(run);
11879        }
11880        let BodyContent::Paragraph(target) = &mut input.document.body.content[100] else {
11881            panic!("body entry is a paragraph");
11882        };
11883        assert!(target.insert_bookmark_start(0, 46, "destination"));
11884        assert!(target.insert_bookmark_end(1, 46));
11885        input
11886    }
11887
11888    fn substituted_page_index(engine: &Engine) -> usize {
11889        engine
11890            .restart_cache
11891            .as_ref()
11892            .expect("restart record retained")
11893            .substitution_inputs
11894            .iter()
11895            .position(Option::is_some)
11896            .expect("field page retained")
11897    }
11898
11899    #[test]
11900    fn unchanged_page_fields_reuse_substituted_frames() {
11901        let input = substituted_restart_input();
11902        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11903        let first = engine.layout(&input).expect("initial field layout");
11904        let field_page = substituted_page_index(&engine);
11905        let retained = engine
11906            .restart_cache
11907            .as_ref()
11908            .expect("restart record retained");
11909        assert!(
11910            retained.checkpoints.is_empty(),
11911            "field pages remain excluded from pagination restart"
11912        );
11913        assert!(!Arc::ptr_eq(
11914            &retained.raw_pages[field_page],
11915            &retained.pages[field_page]
11916        ));
11917        assert!(
11918            retained
11919                .raw_pages
11920                .iter()
11921                .zip(&retained.pages)
11922                .zip(&retained.substitution_inputs)
11923                .filter(|(_, inputs)| inputs.is_none())
11924                .all(|((pristine, substituted), _)| Arc::ptr_eq(pristine, substituted))
11925        );
11926
11927        let warm = engine.layout(&input).expect("warm field layout");
11928        assert!(Arc::ptr_eq(
11929            &first.pages[field_page],
11930            &warm.pages[field_page]
11931        ));
11932    }
11933
11934    #[test]
11935    fn changed_substitution_context_reshapes_pages() {
11936        fn assert_retained_key_miss(
11937            label: &str,
11938            mutate: impl FnOnce(&mut FieldSubstitutionInputs),
11939        ) {
11940            let input = substituted_restart_input();
11941            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11942            let first = engine.layout(&input).expect("initial field layout");
11943            let field_page = substituted_page_index(&engine);
11944            let retained = engine
11945                .restart_cache
11946                .as_mut()
11947                .expect("restart record retained");
11948            mutate(
11949                retained.substitution_inputs[field_page]
11950                    .as_mut()
11951                    .expect("field inputs retained"),
11952            );
11953            let warm = engine.layout(&input).expect("warm field layout");
11954            let cold = Engine::new_deterministic()
11955                .expect("bundled fonts load")
11956                .layout(&input)
11957                .expect("cold field layout");
11958            assert_layout_results_equal(&warm, &cold);
11959            assert!(
11960                !Arc::ptr_eq(&first.pages[field_page], &warm.pages[field_page]),
11961                "{label}"
11962            );
11963        }
11964
11965        assert_retained_key_miss("page index must miss", |inputs| inputs.page_index += 1);
11966        assert_retained_key_miss("displayed page number must miss", |inputs| {
11967            inputs.page_number += 1;
11968        });
11969        assert_retained_key_miss("page count must miss", |inputs| inputs.total_pages += 1);
11970        assert_retained_key_miss("bookmark targets must miss", |inputs| {
11971            inputs.bookmark_pages.push((usize::MAX, usize::MAX));
11972        });
11973        assert_retained_key_miss("font identity must miss", |inputs| {
11974            inputs.font_identity.reverse();
11975            inputs.font_identity.push(FontId(u32::MAX));
11976        });
11977        assert_retained_key_miss("revision view must miss", |inputs| {
11978            inputs.revision_view = RevisionView::Tracked;
11979        });
11980
11981        let mut input = substituted_restart_input();
11982        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11983        let first = engine.layout(&input).expect("initial field layout");
11984        let field_page = substituted_page_index(&engine);
11985        set_body_paragraph_text(&mut input, 0, "changed pristine field page");
11986        let warm = engine.layout(&input).expect("changed pristine layout");
11987        let cold = Engine::new_deterministic()
11988            .expect("bundled fonts load")
11989            .layout(&input)
11990            .expect("cold changed pristine layout");
11991        assert_layout_results_equal(&warm, &cold);
11992        assert!(!Arc::ptr_eq(
11993            &first.pages[field_page],
11994            &warm.pages[field_page]
11995        ));
11996
11997        fn set_field_page_family(input: &mut LayoutInput, family: &str) {
11998            let BodyContent::Paragraph(fields) = &mut input.document.body.content[0] else {
11999                panic!("body entry is a paragraph");
12000            };
12001            for run in &mut fields.runs {
12002                run.properties = Some(rdocx_oxml::properties::CT_RPr {
12003                    font_ascii: Some(family.to_owned()),
12004                    font_hansi: Some(family.to_owned()),
12005                    ..Default::default()
12006                });
12007            }
12008        }
12009
12010        let mut input = substituted_restart_input();
12011        set_field_page_family(&mut input, "Caladea");
12012        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12013        engine.layout(&input).expect("first bundled-family layout");
12014        set_field_page_family(&mut input, "Carlito");
12015        let transitioned = engine.layout(&input).expect("font-transition field layout");
12016        let field_page = substituted_page_index(&engine);
12017        let field_free_page = engine
12018            .restart_cache
12019            .as_ref()
12020            .expect("restart record retained")
12021            .substitution_inputs
12022            .iter()
12023            .position(Option::is_none)
12024            .expect("field-free page retained");
12025        let warm = engine.layout(&input).expect("post-transition warm layout");
12026        let cold = Engine::new_deterministic()
12027            .expect("bundled fonts load")
12028            .layout(&input)
12029            .expect("post-transition cold layout");
12030        assert_layout_results_equal(&warm, &cold);
12031        assert!(Arc::ptr_eq(
12032            &transitioned.pages[field_page],
12033            &warm.pages[field_page]
12034        ));
12035        assert!(Arc::ptr_eq(
12036            &transitioned.pages[field_free_page],
12037            &warm.pages[field_free_page]
12038        ));
12039    }
12040
12041    #[test]
12042    fn substituted_page_reuse_is_bounded_and_complete_equal() {
12043        let input = substituted_restart_input();
12044        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12045        engine.layout(&input).expect("initial field layout");
12046        let warm = engine.layout(&input).expect("warm field layout");
12047        let cold = Engine::new_deterministic()
12048            .expect("bundled fonts load")
12049            .layout(&input)
12050            .expect("cold field layout");
12051        assert_layout_results_equal(&warm, &cold);
12052        let retained = engine
12053            .restart_cache
12054            .as_ref()
12055            .expect("restart record retained");
12056        assert!(
12057            retained.raw_pages.len().max(retained.checkpoints.len()) <= RESTART_CACHE_MAX_ENTRIES
12058        );
12059        assert_restart_cache_within_aggregate(&engine);
12060
12061        let mut bounded_input = make_input_with_text("");
12062        bounded_input.document.body.content.clear();
12063        for page in 0..1_024 {
12064            let mut paragraph = CT_P::new();
12065            paragraph.properties = Some(CT_PPr {
12066                page_break_before: (page > 0).then_some(true),
12067                ..Default::default()
12068            });
12069            let mut run = CT_R::new("");
12070            run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
12071            paragraph.runs.push(run);
12072            bounded_input.document.body.add_paragraph(paragraph);
12073        }
12074        let mut bounded = Engine::new_deterministic().expect("bundled fonts load");
12075        let result = bounded
12076            .layout(&bounded_input)
12077            .expect("bounded layout succeeds");
12078        assert_eq!(result.pages.len(), 1_024);
12079        bounded
12080            .restart_cache
12081            .as_ref()
12082            .expect("1,024 substituted page slots remain reusable");
12083        assert_restart_cache_within_aggregate(&bounded);
12084
12085        let mut oversized = bounded_input;
12086        let mut paragraph = CT_P::new();
12087        paragraph.properties = Some(CT_PPr {
12088            page_break_before: Some(true),
12089            ..Default::default()
12090        });
12091        let mut run = CT_R::new("");
12092        run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
12093        paragraph.runs.push(run);
12094        oversized.document.body.add_paragraph(paragraph);
12095        let result = bounded
12096            .layout(&oversized)
12097            .expect("oversized layout succeeds");
12098        assert_eq!(result.pages.len(), 1_025);
12099        assert!(
12100            bounded.restart_cache.is_none(),
12101            "an oversized pair set drops the optimization"
12102        );
12103
12104        fn over_limit<T>() -> usize {
12105            LEGACY_RESTART_CACHE_MAX_BYTES / std::mem::size_of::<T>() + 1
12106        }
12107        fn assert_capacity_rejected(label: &str, mutate: impl FnOnce(&mut RestartCache)) {
12108            let mut candidate = RestartCache {
12109                body: Vec::new(),
12110                with_provenance: false,
12111                raw_pages: Vec::new(),
12112                pages: Vec::new(),
12113                substitution_inputs: Vec::new(),
12114                outlines: Vec::new(),
12115                checkpoints: Vec::new(),
12116                font_trace: Vec::new(),
12117                bytes: 0,
12118            };
12119            mutate(&mut candidate);
12120            assert!(
12121                restart_cache_bytes(&candidate) > LEGACY_RESTART_CACHE_MAX_BYTES,
12122                "{label}"
12123            );
12124        }
12125
12126        assert_capacity_rejected("body vector capacity is charged", |cache| {
12127            cache.body = Vec::with_capacity(over_limit::<RestartBodyEntry>());
12128        });
12129        assert_capacity_rejected("pristine page vector capacity is charged", |cache| {
12130            cache.raw_pages = Vec::with_capacity(over_limit::<Arc<PageFrame>>());
12131        });
12132        assert_capacity_rejected("substituted page vector capacity is charged", |cache| {
12133            cache.pages = Vec::with_capacity(over_limit::<Arc<PageFrame>>());
12134        });
12135        assert_capacity_rejected("substitution vector capacity is charged", |cache| {
12136            cache.substitution_inputs =
12137                Vec::with_capacity(over_limit::<Option<FieldSubstitutionInputs>>());
12138        });
12139        assert_capacity_rejected("outline vector capacity is charged", |cache| {
12140            cache.outlines = Vec::with_capacity(over_limit::<oxml_layout::OutlineEntry>());
12141        });
12142        assert_capacity_rejected("checkpoint vector capacity is charged", |cache| {
12143            cache.checkpoints = Vec::with_capacity(over_limit::<paginator::PaginationCheckpoint>());
12144        });
12145        assert_capacity_rejected("font trace vector capacity is charged", |cache| {
12146            cache.font_trace = Vec::with_capacity(over_limit::<FontId>());
12147        });
12148        assert_capacity_rejected("body payload capacity is charged", |cache| {
12149            cache.body.push(RestartBodyEntry::Paragraph {
12150                fingerprint: 0,
12151                identity: Vec::new(),
12152                note_references: Vec::new(),
12153                bytes: LEGACY_RESTART_CACHE_MAX_BYTES + 1,
12154            });
12155        });
12156        assert_capacity_rejected("outline title capacity is charged", |cache| {
12157            cache.outlines.push(oxml_layout::OutlineEntry {
12158                title: String::with_capacity(LEGACY_RESTART_CACHE_MAX_BYTES + 1),
12159                level: 1,
12160                page_index: 0,
12161                y_position: 0.0,
12162            });
12163        });
12164        for nested in ["bookmark targets", "font identity"] {
12165            assert_capacity_rejected(&format!("{nested} capacity is charged"), |cache| {
12166                cache
12167                    .substitution_inputs
12168                    .push(Some(FieldSubstitutionInputs {
12169                        page_index: 0,
12170                        page_number: 1,
12171                        total_pages: 1,
12172                        bookmark_pages: if nested == "bookmark targets" {
12173                            Vec::with_capacity(over_limit::<(usize, usize)>())
12174                        } else {
12175                            Vec::new()
12176                        },
12177                        font_identity: if nested == "font identity" {
12178                            Vec::with_capacity(over_limit::<FontId>())
12179                        } else {
12180                            Vec::new()
12181                        },
12182                        revision_view: RevisionView::Accepted,
12183                    }));
12184            });
12185        }
12186    }
12187
12188    #[test]
12189    fn thousand_page_restart_records_at_most_two_page_layout_invocations() {
12190        let mut input = make_input_with_text("");
12191        input.document.body.content.clear();
12192        for page in 0..1_000 {
12193            let mut paragraph = CT_P::new();
12194            paragraph.properties = Some(CT_PPr {
12195                page_break_before: (page > 0).then_some(true),
12196                ..Default::default()
12197            });
12198            paragraph.add_run(&format!("Incremental page {}", page + 1));
12199            input.document.body.add_paragraph(paragraph);
12200        }
12201
12202        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12203        let initial = engine.layout(&input).expect("initial thousand-page layout");
12204        assert_eq!(initial.pages.len(), 1_000);
12205        assert_eq!(engine.page_layout_invocation_count(), 1_000);
12206        engine
12207            .restart_cache
12208            .as_ref()
12209            .expect("thousand-page restart record retained");
12210        let cache_counts = engine.paragraph_cache_counts();
12211
12212        set_body_paragraph_text(&mut input, 499, "Incremental page 500 changed");
12213        let warm = engine.layout(&input).expect("warm thousand-page layout");
12214        assert!((1..=2).contains(&engine.page_layout_invocation_count()));
12215        let fresh = Engine::new_deterministic()
12216            .expect("bundled fonts load")
12217            .layout(&input)
12218            .expect("fresh thousand-page layout");
12219        assert_layout_results_equal(&warm, &fresh);
12220        let rebuilt = engine
12221            .last_rebuilt_page_range
12222            .clone()
12223            .expect("rebuilt range recorded");
12224        assert!(
12225            rebuilt.end.saturating_sub(rebuilt.start) <= 2,
12226            "{rebuilt:?}"
12227        );
12228        let warm_cache_counts = engine.paragraph_cache_counts();
12229        assert_eq!(warm_cache_counts.0 - cache_counts.0, 999);
12230        assert_eq!(warm_cache_counts.1 - cache_counts.1, 1);
12231    }
12232
12233    #[test]
12234    fn warm_restart_rebuilds_only_the_bounded_changed_region() {
12235        let mut input = restart_input();
12236        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12237        let cold_initial = engine.layout(&input).expect("initial pagination");
12238        let initial_pages = cold_initial.pages.clone();
12239        let retained = engine
12240            .restart_cache
12241            .as_ref()
12242            .expect("restart state retained");
12243        assert!(retained.checkpoints.len() > 1);
12244        assert!(retained.checkpoints.len() <= RESTART_CACHE_MAX_ENTRIES);
12245        assert_restart_cache_within_aggregate(&engine);
12246
12247        set_body_paragraph_text(&mut input, 70, "paragraph 070 changed line");
12248        let warm = engine.layout(&input).expect("warm middle edit");
12249        let cold = Engine::new_deterministic()
12250            .expect("bundled fonts load")
12251            .layout(&input)
12252            .expect("cold middle edit");
12253        assert_layout_results_equal(&warm, &cold);
12254        let rebuilt = engine
12255            .last_rebuilt_page_range
12256            .clone()
12257            .expect("rebuilt range recorded");
12258        assert!(
12259            rebuilt.end.saturating_sub(rebuilt.start) <= 2,
12260            "{rebuilt:?}"
12261        );
12262        assert!(
12263            warm.pages
12264                .iter()
12265                .zip(&initial_pages)
12266                .take(rebuilt.start)
12267                .all(|(current, previous)| Arc::ptr_eq(current, previous))
12268        );
12269        assert!(
12270            warm.pages
12271                .iter()
12272                .zip(&initial_pages)
12273                .skip(rebuilt.end)
12274                .all(|(current, previous)| Arc::ptr_eq(current, previous))
12275        );
12276
12277        for (index, label) in [(0, "start"), (139, "tail")] {
12278            set_body_paragraph_text(
12279                &mut input,
12280                index,
12281                &format!("paragraph {index:03} {label:>7} line"),
12282            );
12283            let warm = engine.layout(&input).expect("warm boundary edit");
12284            let cold = Engine::new_deterministic()
12285                .expect("bundled fonts load")
12286                .layout(&input)
12287                .expect("cold boundary edit");
12288            assert_layout_results_equal(&warm, &cold);
12289        }
12290    }
12291
12292    #[test]
12293    fn unsafe_pagination_state_falls_back_to_full_layout() {
12294        let assert_fallback = |label: &str, input: LayoutInput| {
12295            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12296            engine
12297                .layout(&input)
12298                .unwrap_or_else(|error| panic!("{label}: {error}"));
12299            assert!(engine.restart_cache.is_none(), "{label}");
12300        };
12301
12302        let mut table = make_input_with_text("before unsafe table");
12303        let mut unsafe_table = safe_table("table");
12304        unsafe_table.rows[0].cells[0].paragraphs_mut()[0]
12305            .properties
12306            .get_or_insert_default()
12307            .num_id = Some(1);
12308        table.document.body.add_table(unsafe_table);
12309        assert_fallback("traversal-sensitive table", table);
12310
12311        assert_fallback(
12312            "floating drawing",
12313            make_wrapping_document(WrapType::Square, None, 120.0, 60.0, 5.0),
12314        );
12315
12316        let note_source = make_input_with_footnote(&["note in table"]);
12317        let BodyContent::Paragraph(note_paragraph) = &note_source.document.body.content[0] else {
12318            panic!("note source is a paragraph");
12319        };
12320        let mut note_table_input = make_input_with_text("before note table");
12321        let mut note_table = safe_table("table text");
12322        note_table.rows[0].cells[0].paragraphs_mut()[0]
12323            .runs
12324            .push(note_paragraph.runs[1].clone());
12325        note_table_input.document.body.add_table(note_table);
12326        note_table_input.footnotes = note_source.footnotes;
12327        assert_fallback("note-bearing table", note_table_input);
12328
12329        let mut sections = restart_input();
12330        let BodyContent::Paragraph(first) = &mut sections.document.body.content[20] else {
12331            panic!("paragraph");
12332        };
12333        first.properties.get_or_insert_default().sect_pr = Some(CT_SectPr::default_letter());
12334        assert_fallback("multiple sections", sections);
12335
12336        let mut background = restart_input();
12337        background.document.background_xml = Some(
12338            br#"<w:background xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:color="FFFFFF"/>"#
12339                .to_vec(),
12340        );
12341        assert_fallback("page background", background);
12342
12343        let mut field = restart_input();
12344        let BodyContent::Paragraph(paragraph) = &mut field.document.body.content[20] else {
12345            panic!("paragraph");
12346        };
12347        let mut field_run = CT_R::new("");
12348        field_run.content = vec![RunContent::Field(rdocx_oxml::text::Field::new("PAGE", "1"))];
12349        paragraph.runs.push(field_run);
12350        let mut field_engine = Engine::new_deterministic().expect("bundled fonts load");
12351        field_engine.layout(&field).expect("field layout");
12352        let retained = field_engine
12353            .restart_cache
12354            .as_ref()
12355            .expect("field substitution pairs retained");
12356        assert!(
12357            retained.checkpoints.is_empty(),
12358            "fields must not become pagination restart boundaries"
12359        );
12360
12361        let mut boundary = restart_input();
12362        let mut boundary_engine = Engine::new_deterministic().expect("bundled fonts load");
12363        boundary_engine
12364            .layout(&boundary)
12365            .expect("prime boundary state");
12366        let stale = boundary_engine
12367            .restart_cache
12368            .as_mut()
12369            .and_then(|cache| {
12370                cache
12371                    .checkpoints
12372                    .iter_mut()
12373                    .find(|checkpoint| checkpoint.next_block_index > 80)
12374            })
12375            .expect("later checkpoint exists");
12376        stale.next_header_page_number += 1;
12377        set_body_paragraph_text(&mut boundary, 70, "changed before stale boundary");
12378        let warm = boundary_engine
12379            .layout(&boundary)
12380            .expect("warm boundary mismatch");
12381        let cold = Engine::new_deterministic()
12382            .expect("bundled fonts load")
12383            .layout(&boundary)
12384            .expect("cold boundary mismatch");
12385        assert_layout_results_equal(&warm, &cold);
12386        assert!(
12387            boundary_engine
12388                .restart_cache
12389                .as_ref()
12390                .expect("correct state republished")
12391                .checkpoints
12392                .iter()
12393                .all(|checkpoint| {
12394                    checkpoint.next_header_page_number == checkpoint.page_count + 1
12395                })
12396        );
12397    }
12398
12399    #[test]
12400    fn warm_and_cold_outputs_are_complete_equals() {
12401        let mut input = restart_input();
12402        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12403        engine.layout(&input).expect("prime restart state");
12404
12405        let mut inserted = CT_P::new();
12406        inserted.add_run("inserted stable line");
12407        input
12408            .document
12409            .body
12410            .content
12411            .insert(60, BodyContent::Paragraph(inserted));
12412        let warm_insert = engine.layout(&input).expect("warm insertion");
12413        let cold_insert = Engine::new_deterministic()
12414            .expect("bundled fonts load")
12415            .layout(&input)
12416            .expect("cold insertion");
12417        assert_layout_results_equal(&warm_insert, &cold_insert);
12418
12419        input.document.body.content.remove(60);
12420        let warm_delete = engine.layout(&input).expect("warm deletion");
12421        let cold_delete = Engine::new_deterministic()
12422            .expect("bundled fonts load")
12423            .layout(&input)
12424            .expect("cold deletion");
12425        assert_layout_results_equal(&warm_delete, &cold_delete);
12426
12427        let mut sourced_engine = Engine::new_deterministic().expect("bundled fonts load");
12428        let (original, original_sources) = sourced_engine
12429            .layout_with_provenance(&input)
12430            .expect("prime sourced restart state");
12431        input.document.body.content.insert(
12432            60,
12433            BodyContent::Paragraph({
12434                let mut paragraph = CT_P::new();
12435                paragraph.add_run("inserted sourced line");
12436                paragraph
12437            }),
12438        );
12439        let (warm_insert, warm_insert_sources) = sourced_engine
12440            .layout_with_provenance(&input)
12441            .expect("warm sourced insertion");
12442        let (cold_insert, cold_insert_sources) = Engine::new_deterministic()
12443            .expect("bundled fonts load")
12444            .layout_with_provenance(&input)
12445            .expect("cold sourced insertion");
12446        assert_layout_results_equal(&warm_insert, &cold_insert);
12447        assert_eq!(warm_insert_sources, cold_insert_sources);
12448
12449        input.document.body.content.remove(60);
12450        let (warm_delete, warm_delete_sources) = sourced_engine
12451            .layout_with_provenance(&input)
12452            .expect("warm sourced deletion");
12453        assert_layout_results_equal(&warm_delete, &original);
12454        assert_eq!(warm_delete_sources, original_sources);
12455
12456        let mut truncate_engine = Engine::new_deterministic().expect("bundled fonts load");
12457        truncate_engine
12458            .layout(&input)
12459            .expect("prime whole-suffix deletion state");
12460        let checkpoint = truncate_engine
12461            .restart_cache
12462            .as_ref()
12463            .and_then(|cache| cache.checkpoints.iter().next_back().copied())
12464            .expect("restart cache has a final safe boundary");
12465        input
12466            .document
12467            .body
12468            .content
12469            .truncate(checkpoint.next_block_index);
12470        let warm_truncate = truncate_engine
12471            .layout(&input)
12472            .expect("warm whole-suffix deletion");
12473        let cold_truncate = Engine::new_deterministic()
12474            .expect("bundled fonts load")
12475            .layout(&input)
12476            .expect("cold whole-suffix deletion");
12477        assert_layout_results_equal(&warm_truncate, &cold_truncate);
12478    }
12479
12480    #[test]
12481    fn paragraph_cache_failure_and_eviction_remain_bounded() {
12482        let mut input = make_input_with_text("");
12483        input.document = rdocx_oxml::CT_Document::from_xml(
12484            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>"#,
12485        )
12486        .expect("diagnostic document parses");
12487        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12488        let cold = engine.layout(&input).expect("cold layout succeeds");
12489        let warm = engine.layout(&input).expect("warm layout succeeds");
12490        assert!(!cold.diagnostics.is_empty());
12491        assert_eq!(cold.diagnostics, warm.diagnostics);
12492        assert_eq!(engine.paragraph_cache_counts(), (1, 1));
12493
12494        let (valid_family, valid_bytes) = oxml_layout::bundled_fonts::bundled_font_data()[0];
12495        let (invalid_family, invalid_source) = oxml_layout::bundled_fonts::bundled_font_data()[4];
12496        let mut invalid_bytes = invalid_source.to_vec();
12497        let table_count = u16::from_be_bytes([invalid_bytes[4], invalid_bytes[5]]) as usize;
12498        let head_offset = (0..table_count)
12499            .find_map(|table| {
12500                let record = 12 + table * 16;
12501                (&invalid_bytes[record..record + 4] == b"head").then(|| {
12502                    u32::from_be_bytes(
12503                        invalid_bytes[record + 8..record + 12]
12504                            .try_into()
12505                            .expect("head offset"),
12506                    ) as usize
12507                })
12508            })
12509            .expect("font has head table");
12510        invalid_bytes[head_offset + 18..head_offset + 20].copy_from_slice(&0u16.to_be_bytes());
12511
12512        let mut failing = Engine::with_font_manager(FontManager::new_with_fonts(vec![(
12513            valid_family.to_owned(),
12514            valid_bytes.to_vec(),
12515        )]));
12516        let mut failing_input = make_input_with_text("cache-safe successful prefix");
12517        let BodyContent::Paragraph(prefix) = &mut failing_input.document.body.content[0] else {
12518            panic!("prefix paragraph");
12519        };
12520        prefix.runs[0].properties.get_or_insert_default().font_ascii =
12521            Some(valid_family.to_owned());
12522        failing_input
12523            .document
12524            .body
12525            .content
12526            .push(BodyContent::Table(safe_nested_table(
12527                "staged outer before failure",
12528                "staged nested before failure",
12529            )));
12530        let mut later = CT_P::new();
12531        later
12532            .add_run("late font failure")
12533            .properties
12534            .get_or_insert_default()
12535            .font_ascii = Some(invalid_family.to_owned());
12536        failing_input.document.body.add_paragraph(later);
12537        failing_input.fonts.push(oxml_layout::FontFile {
12538            family: invalid_family.to_owned(),
12539            data: invalid_bytes,
12540        });
12541        assert!(failing.layout(&failing_input).is_err());
12542        assert!(failing.paragraph_cache.is_empty());
12543        assert!(failing.table_cache.is_empty());
12544        assert_eq!(failing.paragraph_cache_counts(), (0, 1));
12545        assert_eq!(failing.table_cache_counts(), (0, 1));
12546
12547        let template_input = make_input_with_text("eviction template");
12548        let mut bounded = Engine::new_deterministic().expect("bundled fonts load");
12549        bounded
12550            .layout(&template_input)
12551            .expect("template layout succeeds");
12552        let template = bounded
12553            .paragraph_cache
12554            .pop_front()
12555            .expect("template paragraph is retained");
12556        bounded.paragraph_cache_bytes = 0;
12557        for index in 0..=PARAGRAPH_CACHE_MAX_ENTRIES {
12558            let mut paragraph = CT_P::new();
12559            paragraph.add_run(&format!("eviction paragraph {index}"));
12560            let bytes = paragraph_cache_entry_bytes(
12561                &paragraph,
12562                &template.block,
12563                &template.diagnostics,
12564                template.font_trace.len(),
12565            );
12566            bounded.publish_paragraph_cache_entry(ParagraphCacheEntry {
12567                fingerprint: paragraph_fingerprint(&paragraph),
12568                key: ParagraphCacheKey {
12569                    paragraph,
12570                    content_width_bits: PageGeometry::default().content_width().to_bits(),
12571                    revision_view: RevisionView::Accepted,
12572                },
12573                block: template.block.clone(),
12574                diagnostics: template.diagnostics.clone(),
12575                font_trace: template.font_trace.clone(),
12576                reflow_direction: template.reflow_direction,
12577                bytes,
12578            });
12579        }
12580        assert_eq!(bounded.paragraph_cache.len(), PARAGRAPH_CACHE_MAX_ENTRIES);
12581        assert!(bounded.paragraph_cache_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
12582        assert_eq!(
12583            bounded
12584                .paragraph_cache
12585                .front()
12586                .expect("FIFO cache has a front")
12587                .key
12588                .paragraph
12589                .text(),
12590            "eviction paragraph 1"
12591        );
12592    }
12593
12594    #[test]
12595    fn paragraph_relayout_cache_is_bounded() {
12596        let mut input = make_input_with_text("bounded paragraph 0");
12597        for index in 1..(PARAGRAPH_CACHE_MAX_ENTRIES + 20) {
12598            let mut paragraph = CT_P::new();
12599            paragraph.add_run(&format!("bounded paragraph {index}"));
12600            input.document.body.add_paragraph(paragraph);
12601        }
12602        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12603        engine.layout(&input).expect("bounded layout succeeds");
12604        assert!(engine.paragraph_cache.len() <= PARAGRAPH_CACHE_MAX_ENTRIES);
12605        assert!(engine.paragraph_cache_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
12606        assert!(engine.pending_paragraph_cache_peak_entries <= PARAGRAPH_CACHE_MAX_ENTRIES);
12607        assert!(engine.pending_paragraph_cache_peak_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
12608    }
12609
12610    #[test]
12611    fn transactional_paragraph_staging_is_bounded_before_publication() {
12612        let mut input = make_input_with_text("staged paragraph 0");
12613        for index in 1..(PARAGRAPH_CACHE_MAX_ENTRIES * 2) {
12614            let mut paragraph = CT_P::new();
12615            paragraph.add_run(&format!("staged paragraph {index}"));
12616            input.document.body.add_paragraph(paragraph);
12617        }
12618        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12619        engine
12620            .layout(&input)
12621            .expect("transactional layout succeeds");
12622        assert_eq!(
12623            engine.pending_paragraph_cache_peak_entries,
12624            PARAGRAPH_CACHE_MAX_ENTRIES
12625        );
12626        assert!(engine.pending_paragraph_cache_peak_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
12627    }
12628
12629    #[test]
12630    fn paragraph_relayout_cache_enforces_the_reflow_byte_ceiling() {
12631        let input = make_input_with_text("reflow accounting template");
12632        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12633        engine.layout(&input).expect("template layout succeeds");
12634        let template = engine
12635            .paragraph_cache
12636            .front()
12637            .expect("safe paragraph cached")
12638            .block
12639            .clone();
12640        let mut retained = match &template.lines[0].items[0] {
12641            LineItem::Text(text) => text.clone(),
12642            other => panic!("expected text line item, got {other:?}"),
12643        };
12644        retained.advances = vec![0.0; PARAGRAPH_CACHE_MAX_BYTES / 8 + 1];
12645
12646        let mut block = template.as_ref().clone();
12647        block.reflow = Some(Box::new(block::ParagraphReflow {
12648            items: vec![InlineItem::Text(retained)],
12649            params: oxml_layout::LineBreakParams::default(),
12650        }));
12651        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
12652            panic!("body paragraph");
12653        };
12654        let bytes = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
12655        assert!(bytes > PARAGRAPH_CACHE_MAX_BYTES);
12656
12657        engine.paragraph_cache.clear();
12658        engine.paragraph_cache_bytes = 0;
12659        engine.publish_paragraph_cache_entry(ParagraphCacheEntry {
12660            fingerprint: paragraph_fingerprint(paragraph),
12661            key: ParagraphCacheKey {
12662                paragraph: paragraph.clone(),
12663                content_width_bits: PageGeometry::default().content_width().to_bits(),
12664                revision_view: RevisionView::Accepted,
12665            },
12666            block: Arc::new(block),
12667            diagnostics: Vec::new(),
12668            font_trace: Vec::new(),
12669            reflow_direction: TextDirection::Auto,
12670            bytes,
12671        });
12672        assert!(engine.paragraph_cache.is_empty());
12673        assert_eq!(engine.paragraph_cache_bytes, 0);
12674    }
12675
12676    #[test]
12677    fn tab_heavy_paragraph_in_wrapping_document_counts_reflow_parameter_buffers() {
12678        use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
12679        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
12680        use rdocx_oxml::shared::ST_TabJc;
12681        use rdocx_oxml::units::Twips;
12682
12683        let mut input =
12684            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
12685        let retained_per_stop =
12686            std::mem::size_of::<CT_TabStop>() + std::mem::size_of::<oxml_layout::TabStop>();
12687        let stop_count = PARAGRAPH_CACHE_MAX_BYTES / retained_per_stop + 1;
12688        let mut paragraph = CT_P::new();
12689        paragraph.properties = Some(CT_PPr {
12690            tabs: Some(CT_Tabs {
12691                tabs: (0..stop_count)
12692                    .map(|_| CT_TabStop::new(ST_TabJc::Left, Twips(720)))
12693                    .collect(),
12694            }),
12695            ..CT_PPr::default()
12696        });
12697        paragraph.add_run("cache-safe paragraph with many owned tab definitions");
12698        assert!(paragraph_is_cache_safe(&paragraph, &input.styles));
12699        input.document.body.add_paragraph(paragraph);
12700
12701        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12702        engine.layout(&input).expect("tab-heavy layout succeeds");
12703        assert!(engine.paragraph_cache.is_empty());
12704        assert_eq!(engine.paragraph_cache_bytes, 0);
12705    }
12706
12707    #[test]
12708    fn paragraph_relayout_cache_counts_all_reflow_parameter_vectors() {
12709        let input = make_input_with_text("reflow parameter accounting template");
12710        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12711        engine.layout(&input).expect("template layout succeeds");
12712        let mut block = engine
12713            .paragraph_cache
12714            .front()
12715            .expect("safe paragraph cached")
12716            .block
12717            .as_ref()
12718            .clone();
12719        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
12720            panic!("body paragraph");
12721        };
12722        let baseline = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
12723        let reflow = block.reflow.as_mut().expect("cache retains reflow inputs");
12724        reflow.params.tab_stops = vec![
12725            oxml_layout::TabStop {
12726                pos_pt: 36.0,
12727                align: oxml_layout::TabAlign::Left,
12728                leader: None,
12729            };
12730            3
12731        ];
12732        reflow.params.line_prefix_widths = vec![0.0; 5];
12733        reflow.params.line_suffix_widths = vec![0.0; 7];
12734        let with_parameters = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
12735        let expected =
12736            3 * std::mem::size_of::<oxml_layout::TabStop>() + 12 * std::mem::size_of::<f64>();
12737        assert_eq!(with_parameters - baseline, expected);
12738    }
12739
12740    #[test]
12741    fn paragraph_relayout_cache_counts_fixed_storage_in_owned_keys() {
12742        let input = make_input_with_text("key accounting template");
12743        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12744        engine.layout(&input).expect("template layout succeeds");
12745        let block = engine
12746            .paragraph_cache
12747            .front()
12748            .expect("safe paragraph cached")
12749            .block
12750            .clone();
12751
12752        let mut paragraph = CT_P::new();
12753        let mut run = CT_R::new("");
12754        let content_count = PARAGRAPH_CACHE_MAX_BYTES / std::mem::size_of::<RunContent>() + 1;
12755        run.content = std::iter::repeat_n(RunContent::Tab, content_count).collect();
12756        paragraph.runs.push(run);
12757        assert!(paragraph_is_cache_safe(&paragraph, &input.styles));
12758        let bytes = paragraph_cache_entry_bytes(&paragraph, &block, &[], 0);
12759        assert!(bytes > PARAGRAPH_CACHE_MAX_BYTES);
12760
12761        engine.paragraph_cache.clear();
12762        engine.paragraph_cache_bytes = 0;
12763        engine.publish_paragraph_cache_entry(ParagraphCacheEntry {
12764            fingerprint: paragraph_fingerprint(&paragraph),
12765            key: ParagraphCacheKey {
12766                paragraph,
12767                content_width_bits: PageGeometry::default().content_width().to_bits(),
12768                revision_view: RevisionView::Accepted,
12769            },
12770            block,
12771            diagnostics: Vec::new(),
12772            font_trace: Vec::new(),
12773            reflow_direction: TextDirection::Auto,
12774            bytes,
12775        });
12776        assert!(engine.paragraph_cache.is_empty());
12777        assert_eq!(engine.paragraph_cache_bytes, 0);
12778    }
12779
12780    #[test]
12781    fn every_sourced_glyph_run_resolves_to_its_exact_word_text() {
12782        use rdocx_oxml::document::CT_SectPr;
12783        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
12784        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
12785
12786        let body_text = "Body ASCII 🚀 界 wraps across several exact source slices ".repeat(5);
12787        let mut input = make_input_with_text(&body_text);
12788
12789        let mut outer = CT_Tbl::new();
12790        let mut outer_row = CT_Row::new();
12791        let mut outer_cell = CT_Tc::new();
12792        outer_cell.paragraphs_mut()[0].add_run("outer cell");
12793        let mut nested = CT_Tbl::new();
12794        let mut nested_row = CT_Row::new();
12795        let mut nested_cell = CT_Tc::new();
12796        nested_cell.paragraphs_mut()[0].add_run("nested cell");
12797        nested_row.cells.push(nested_cell);
12798        nested.rows.push(nested_row);
12799        outer_cell.content.push(CellContent::Table(nested));
12800        outer_row.cells.push(outer_cell);
12801        outer.rows.push(outer_row);
12802        input.document.body.add_table(outer);
12803
12804        let mut references = CT_P::new();
12805        let mut reference_run = CT_R::new("");
12806        reference_run.content = vec![
12807            RunContent::FootnoteRef { id: 4 },
12808            RunContent::EndnoteRef { id: 9 },
12809        ];
12810        references.runs.push(reference_run);
12811        input.document.body.add_paragraph(references);
12812
12813        let mut header = CT_HdrFtr::new();
12814        let mut header_paragraph = CT_P::new();
12815        header_paragraph.add_run("رأس الصفحة");
12816        header.paragraphs.push(header_paragraph);
12817        input.headers.insert("rIdHeader".to_owned(), header);
12818
12819        let mut footer = CT_HdrFtr::new();
12820        let mut footer_paragraph = CT_P::new();
12821        footer_paragraph.add_run("تذييل الصفحة");
12822        footer.paragraphs.push(footer_paragraph);
12823        input.footers.insert("rIdFooter".to_owned(), footer);
12824
12825        let mut section = CT_SectPr::default_letter();
12826        section.header_refs.push(HdrFtrRef {
12827            hdr_ftr_type: HdrFtrType::Default,
12828            rel_id: "rIdHeader".to_owned(),
12829        });
12830        section.footer_refs.push(HdrFtrRef {
12831            hdr_ftr_type: HdrFtrType::Default,
12832            rel_id: "rIdFooter".to_owned(),
12833        });
12834        input.document.body.sect_pr = Some(section);
12835
12836        let mut footnote_paragraph = CT_P::new();
12837        footnote_paragraph.add_run("footnote text");
12838        input.footnotes = Some(CT_Footnotes {
12839            footnotes: vec![CT_Footnote {
12840                id: 4,
12841                note_type: NoteType::Normal,
12842                paragraphs: vec![footnote_paragraph],
12843            }],
12844        });
12845        let mut endnote_paragraph = CT_P::new();
12846        endnote_paragraph.add_run("endnote text");
12847        input.endnotes = Some(CT_Footnotes {
12848            footnotes: vec![CT_Footnote {
12849                id: 9,
12850                note_type: NoteType::Normal,
12851                paragraphs: vec![endnote_paragraph],
12852            }],
12853        });
12854
12855        let expected = HashMap::from([
12856            (
12857                WordSourcePath {
12858                    story: WordStory::Document,
12859                    children: vec![0],
12860                },
12861                body_text,
12862            ),
12863            (
12864                WordSourcePath {
12865                    story: WordStory::Document,
12866                    children: vec![1, 0, 0, 0],
12867                },
12868                "outer cell".to_owned(),
12869            ),
12870            (
12871                WordSourcePath {
12872                    story: WordStory::Document,
12873                    children: vec![1, 0, 0, 1, 0, 0, 0],
12874                },
12875                "nested cell".to_owned(),
12876            ),
12877            (
12878                WordSourcePath {
12879                    story: WordStory::Header {
12880                        relationship_id: "rIdHeader".to_owned(),
12881                    },
12882                    children: vec![0],
12883                },
12884                "رأس الصفحة".to_owned(),
12885            ),
12886            (
12887                WordSourcePath {
12888                    story: WordStory::Footer {
12889                        relationship_id: "rIdFooter".to_owned(),
12890                    },
12891                    children: vec![0],
12892                },
12893                "تذييل الصفحة".to_owned(),
12894            ),
12895            (
12896                WordSourcePath {
12897                    story: WordStory::Footnote { id: 4 },
12898                    children: vec![0],
12899                },
12900                "footnote text".to_owned(),
12901            ),
12902            (
12903                WordSourcePath {
12904                    story: WordStory::Endnote { id: 9 },
12905                    children: vec![0],
12906                },
12907                "endnote text".to_owned(),
12908            ),
12909        ]);
12910
12911        let result = crate::layout_document_deterministic_with_provenance(&input)
12912            .expect("layout with provenance");
12913        let mut seen = std::collections::HashSet::new();
12914        for (source, text) in result.layout.pages.iter().flat_map(|page| {
12915            compatibility_page_elements(page)
12916                .into_iter()
12917                .filter_map(|element| match element {
12918                    PositionedElement::Text(run) => Some((run.source, run.text.as_str())),
12919                    PositionedElement::MultilingualText(run) => {
12920                        Some((run.source, run.logical_text.as_str()))
12921                    }
12922                    _ => None,
12923                })
12924        }) {
12925            let Some(span) = source else {
12926                continue;
12927            };
12928            let path = result.source_node(span.node).expect("source node resolves");
12929            let source_text = expected.get(path).expect("source path belongs to fixture");
12930            let selected = source_text
12931                .chars()
12932                .skip(span.char_start as usize)
12933                .take((span.char_end - span.char_start) as usize)
12934                .collect::<String>();
12935            assert_eq!(selected, text, "mismatch at {path:?}");
12936            seen.insert(path.clone());
12937        }
12938        assert_eq!(
12939            seen.len(),
12940            expected.len(),
12941            "every supported story is sourced"
12942        );
12943        for path in expected.keys() {
12944            assert!(seen.contains(path), "missing source path {path:?}");
12945        }
12946
12947        let without_provenance = deterministic_layout(&input);
12948        let rich_header_footer = multilingual_runs(&without_provenance)
12949            .into_iter()
12950            .filter(|run| run.logical_text.contains("الصفحة"))
12951            .collect::<Vec<_>>();
12952        assert_eq!(rich_header_footer.len(), 2);
12953        assert!(
12954            rich_header_footer.iter().all(|run| run.source.is_none()),
12955            "cache-only source IDs must not escape without provenance"
12956        );
12957    }
12958
12959    #[test]
12960    fn repeated_text_and_repeated_stories_keep_distinct_source_nodes() {
12961        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
12962
12963        let repeated = "duplicate phrase ".repeat(220);
12964        let mut input = make_input_with_text(&repeated);
12965        let mut second = CT_P::new();
12966        second.add_run(&repeated);
12967        input.document.body.add_paragraph(second);
12968
12969        let mut header = CT_HdrFtr::new();
12970        let mut paragraph = CT_P::new();
12971        paragraph.add_run("repeated header");
12972        header.paragraphs.push(paragraph);
12973        input.headers.insert("rIdRepeated".to_owned(), header);
12974        input
12975            .document
12976            .body
12977            .sect_pr
12978            .as_mut()
12979            .expect("default section")
12980            .header_refs
12981            .push(HdrFtrRef {
12982                hdr_ftr_type: HdrFtrType::Default,
12983                rel_id: "rIdRepeated".to_owned(),
12984            });
12985
12986        let result = crate::layout_document_deterministic_with_provenance(&input)
12987            .expect("layout repeated stories");
12988        assert!(result.layout.pages.len() > 1, "header must be reused");
12989        let mut first_body = std::collections::HashSet::new();
12990        let mut second_body = std::collections::HashSet::new();
12991        let mut header_nodes = std::collections::HashSet::new();
12992        let mut header_runs = 0usize;
12993        for run in result.layout.pages.iter().flat_map(|page| {
12994            compatibility_page_elements(page)
12995                .into_iter()
12996                .filter_map(|element| match element {
12997                    PositionedElement::Text(run) => Some(run),
12998                    _ => None,
12999                })
13000        }) {
13001            let Some(source) = run.source else {
13002                continue;
13003            };
13004            match result.source_node(source.node).expect("source resolves") {
13005                WordSourcePath {
13006                    story: WordStory::Document,
13007                    children,
13008                } if children == &[0] => {
13009                    first_body.insert(source.node);
13010                }
13011                WordSourcePath {
13012                    story: WordStory::Document,
13013                    children,
13014                } if children == &[1] => {
13015                    second_body.insert(source.node);
13016                }
13017                WordSourcePath {
13018                    story: WordStory::Header { relationship_id },
13019                    children,
13020                } if relationship_id == "rIdRepeated" && children == &[0] => {
13021                    header_nodes.insert(source.node);
13022                    header_runs += 1;
13023                }
13024                _ => {}
13025            }
13026        }
13027        assert_eq!(first_body.len(), 1);
13028        assert_eq!(second_body.len(), 1);
13029        assert_ne!(
13030            first_body, second_body,
13031            "duplicate paragraphs must not alias"
13032        );
13033        assert_eq!(header_nodes.len(), 1, "repeated header reuses one node");
13034        assert!(header_runs > 1, "header must be emitted more than once");
13035    }
13036
13037    #[test]
13038    fn accepted_and_tracked_views_record_projection_local_ranges() {
13039        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>"#;
13040        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision XML parses");
13041        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
13042            panic!("expected paragraph");
13043        };
13044
13045        for (view, expected) in [
13046            (RevisionView::Accepted, "AC"),
13047            (RevisionView::Tracked, "ABC"),
13048        ] {
13049            assert_eq!(projected_paragraph_text(paragraph, view), expected);
13050            let mut input = make_input_with_text("");
13051            input.document = document.clone();
13052            input.revision_view = view;
13053            let result = crate::layout_document_deterministic_with_provenance(&input)
13054                .expect("revision layout with provenance");
13055            assert_eq!(result.revision_view, view);
13056            let mut selected = String::new();
13057            for run in result.layout.pages.iter().flat_map(|page| {
13058                compatibility_page_elements(page)
13059                    .into_iter()
13060                    .filter_map(|element| match element {
13061                        PositionedElement::Text(run) => Some(run),
13062                        _ => None,
13063                    })
13064            }) {
13065                let Some(span) = run.source else {
13066                    continue;
13067                };
13068                assert!(matches!(
13069                    result.source_node(span.node),
13070                    Some(WordSourcePath {
13071                        story: WordStory::Document,
13072                        children,
13073                    }) if children == &[0]
13074                ));
13075                let exact = expected
13076                    .chars()
13077                    .skip(span.char_start as usize)
13078                    .take((span.char_end - span.char_start) as usize)
13079                    .collect::<String>();
13080                assert_eq!(exact, run.text);
13081                selected.push_str(&run.text);
13082            }
13083            assert_eq!(selected, expected);
13084        }
13085    }
13086
13087    #[test]
13088    fn field_projection_ownership_disambiguates_repeated_literal_ranges() {
13089        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>"#;
13090        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("complex field parses");
13091        let BodyContent::Paragraph(parsed) = &document.body.content[0] else {
13092            panic!("expected paragraph");
13093        };
13094        let [RunContent::Field(complex)] = parsed.runs[0].content.as_slice() else {
13095            panic!("expected projected complex field");
13096        };
13097
13098        let cases = [
13099            (
13100                vec![
13101                    RunContent::Field(complex.clone()),
13102                    RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
13103                    RunContent::Field(Field::new("DATE", "a")),
13104                ],
13105                "aa",
13106                vec![("a", 1, 2)],
13107            ),
13108            (
13109                vec![
13110                    RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
13111                    RunContent::Field(complex.clone()),
13112                    RunContent::Text(rdocx_oxml::text::CT_Text::new("aa")),
13113                    RunContent::Field(Field::new("DATE", "a")),
13114                    RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
13115                ],
13116                "aaaaa",
13117                vec![("a", 0, 1), ("aa", 2, 4), ("a", 4, 5)],
13118            ),
13119        ];
13120
13121        for (content, expected_projection, expected_literals) in cases {
13122            let mut input = make_input_with_text("");
13123            let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
13124                panic!("expected paragraph");
13125            };
13126            let mut run = CT_R::new("");
13127            run.content = content;
13128            assert_eq!(run.text(), expected_projection);
13129            paragraph.runs = vec![run];
13130
13131            let result = crate::layout_document_deterministic_with_provenance(&input)
13132                .expect("mixed field layout");
13133            let sourced = result
13134                .layout
13135                .pages
13136                .iter()
13137                .flat_map(|page| compatibility_page_elements(page))
13138                .filter_map(|element| match element {
13139                    PositionedElement::Text(run) => run
13140                        .source
13141                        .map(|span| (run.text.as_str(), span.char_start, span.char_end)),
13142                    _ => None,
13143                })
13144                .collect::<Vec<_>>();
13145            assert_eq!(sourced, expected_literals);
13146        }
13147    }
13148
13149    #[test]
13150    fn generated_or_transformed_text_remains_unattributed() {
13151        use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
13152        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
13153        use rdocx_oxml::numbering::{
13154            CT_AbstractNum, CT_Lvl, CT_Num, CT_Numbering, ST_NumberFormat,
13155        };
13156        use rdocx_oxml::properties::CT_RPr;
13157        use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
13158        use rdocx_oxml::units::Twips;
13159
13160        let mut input = make_input_with_text("ordinary");
13161
13162        let mut transformed = CT_P::new();
13163        let mut caps = CT_R::new("straße");
13164        caps.properties = Some(CT_RPr {
13165            caps: Some(true),
13166            ..Default::default()
13167        });
13168        transformed.runs.push(caps);
13169        input.document.body.add_paragraph(transformed);
13170
13171        let mut generated = CT_P::new();
13172        generated.properties = Some(CT_PPr {
13173            tabs: Some(CT_Tabs {
13174                tabs: vec![CT_TabStop {
13175                    val: ST_TabJc::Left,
13176                    pos: Twips(3600),
13177                    leader: Some(ST_TabLeader::Dot),
13178                    source_occurrence: None,
13179                }],
13180            }),
13181            ..Default::default()
13182        });
13183        let mut generated_run = CT_R::new("");
13184        generated_run.content = vec![
13185            RunContent::Text(rdocx_oxml::text::CT_Text::new("left")),
13186            RunContent::Tab,
13187            RunContent::Text(rdocx_oxml::text::CT_Text::new("right")),
13188            RunContent::Field(Field::new("PAGE", "7")),
13189            RunContent::Text(rdocx_oxml::text::CT_Text::new("after")),
13190            RunContent::FootnoteRef { id: 4 },
13191        ];
13192        generated.runs.push(generated_run);
13193        input.document.body.add_paragraph(generated);
13194
13195        let mut list = CT_P::new();
13196        list.properties = Some(CT_PPr {
13197            num_id: Some(1),
13198            num_ilvl: Some(0),
13199            ..Default::default()
13200        });
13201        list.add_run("listed");
13202        input.document.body.add_paragraph(list);
13203        let mut level = CT_Lvl::new(0);
13204        level.start = Some(1);
13205        level.num_fmt = Some(ST_NumberFormat::Decimal);
13206        level.lvl_text = Some("%1.".to_owned());
13207        let mut abstract_num = CT_AbstractNum::new(1);
13208        abstract_num.levels.push(level);
13209        input.numbering = Some(CT_Numbering {
13210            abstract_nums: vec![abstract_num],
13211            nums: vec![CT_Num {
13212                num_id: 1,
13213                abstract_num_id: 1,
13214                extra_xml: Vec::new(),
13215                extra_attributes: Vec::new(),
13216            }],
13217            root_attributes: Vec::new(),
13218            extra_xml: Vec::new(),
13219        });
13220
13221        let mut note = CT_P::new();
13222        note.add_run("note body");
13223        input.footnotes = Some(CT_Footnotes {
13224            footnotes: vec![CT_Footnote {
13225                id: 4,
13226                note_type: NoteType::Normal,
13227                paragraphs: vec![note],
13228            }],
13229        });
13230
13231        let result = crate::layout_document_deterministic_with_provenance(&input)
13232            .expect("generated text layout");
13233        let runs = result
13234            .layout
13235            .pages
13236            .iter()
13237            .flat_map(|page| compatibility_page_elements(page))
13238            .filter_map(|element| match element {
13239                PositionedElement::Text(run) => Some(run),
13240                _ => None,
13241            })
13242            .collect::<Vec<_>>();
13243        assert!(
13244            runs.iter()
13245                .any(|run| run.text == "ordinary" && run.source.is_some())
13246        );
13247        assert!(
13248            runs.iter()
13249                .any(|run| run.text == "after" && run.source.is_some())
13250        );
13251        assert!(
13252            runs.iter()
13253                .any(|run| run.text == "STRASSE" && run.source.is_none())
13254        );
13255        assert!(
13256            runs.iter()
13257                .any(|run| run.text == "1." && run.source.is_none())
13258        );
13259        assert!(runs.iter().any(|run| {
13260            !run.text.is_empty()
13261                && run.text.chars().all(|character| character == '.')
13262                && run.source.is_none()
13263        }));
13264        assert!(
13265            runs.iter()
13266                .any(|run| run.field_kind == Some(FieldKind::Page) && run.source.is_none())
13267        );
13268        assert!(
13269            runs.iter()
13270                .any(|run| run.note.is_some() && run.source.is_none())
13271        );
13272    }
13273
13274    #[test]
13275    fn existing_low_level_layout_functions_keep_identical_output() {
13276        fn clear_sources(elements: &mut [PositionedElement]) {
13277            for element in elements {
13278                match element {
13279                    PositionedElement::Text(run) => run.source = None,
13280                    PositionedElement::MarkedContent { children, .. } => clear_sources(children),
13281                    _ => {}
13282                }
13283            }
13284        }
13285
13286        let input = make_input_with_text("compatibility 🚀 text that wraps ".repeat(30).as_str());
13287        let ordinary = crate::layout_document_deterministic(&input).expect("ordinary layout");
13288        let mut sourced = crate::layout_document_deterministic_with_provenance(&input)
13289            .expect("provenance layout")
13290            .into_layout_result();
13291        for page in &mut sourced.pages {
13292            clear_sources(&mut Arc::make_mut(page).elements);
13293        }
13294        assert_eq!(format!("{ordinary:?}"), format!("{sourced:?}"));
13295    }
13296
13297    #[test]
13298    fn caller_font_and_deterministic_provenance_variants_return_complete_maps() {
13299        let mut input = make_input_with_text("caller font provenance");
13300        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
13301            panic!("expected paragraph");
13302        };
13303        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
13304            font_ascii: Some("Caller Carlito".to_owned()),
13305            font_hansi: Some("Caller Carlito".to_owned()),
13306            ..Default::default()
13307        });
13308        input.fonts.push(oxml_layout::FontFile {
13309            family: "Caller Carlito".to_owned(),
13310            data: include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf").to_vec(),
13311        });
13312
13313        let normal = crate::layout_document_with_provenance(&input).expect("caller font layout");
13314        let deterministic = crate::layout_document_deterministic_with_provenance(&input)
13315            .expect("deterministic caller font layout");
13316        for result in [&normal, &deterministic] {
13317            assert!(
13318                result
13319                    .layout
13320                    .fonts
13321                    .iter()
13322                    .any(|font| font.data.as_ref() == input.fonts[0].data.as_slice()),
13323                "the caller-provided font bytes shaped the result"
13324            );
13325            let runs = result
13326                .layout
13327                .pages
13328                .iter()
13329                .flat_map(|page| compatibility_page_elements(page))
13330                .filter_map(|element| match element {
13331                    PositionedElement::Text(run) if run.source.is_some() => Some(run),
13332                    _ => None,
13333                })
13334                .collect::<Vec<_>>();
13335            assert!(!runs.is_empty(), "caller-font text is sourced");
13336            assert_eq!(
13337                runs.iter().map(|run| run.text.as_str()).collect::<String>(),
13338                "caller font provenance"
13339            );
13340            for run in runs {
13341                let source = run.source.expect("run is sourced");
13342                assert!(matches!(
13343                    result.source_node(source.node),
13344                    Some(WordSourcePath {
13345                        story: WordStory::Document,
13346                        children,
13347                    }) if children == &[0]
13348                ));
13349            }
13350        }
13351    }
13352
13353    #[test]
13354    fn layout_simple_document() {
13355        let input = make_input_with_text("Hello World");
13356        let result = Engine::new().layout(&input);
13357        // On systems without fonts, this may fail — that's OK
13358        if let Ok(result) = result {
13359            assert!(!result.pages.is_empty());
13360            assert_eq!(result.pages[0].page_number, 1);
13361            assert!((result.pages[0].width - 612.0).abs() < 0.01);
13362        }
13363    }
13364
13365    #[test]
13366    fn layout_empty_document() {
13367        let mut doc = rdocx_oxml::document::CT_Document::new();
13368        doc.body.add_paragraph(CT_P::new());
13369
13370        let input = LayoutInput {
13371            revision_view: crate::input::RevisionView::Accepted,
13372            automatic_hyphenation: false,
13373            document: doc,
13374            styles: CT_Styles::new_default(),
13375            numbering: None,
13376            headers: HashMap::new(),
13377            footers: HashMap::new(),
13378            images: HashMap::new(),
13379            charts: HashMap::new(),
13380            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
13381            chart_color_map: oxml_drawing::color::ColorMap::default(),
13382            core_properties: None,
13383            hyperlink_urls: HashMap::new(),
13384            footnotes: None,
13385            endnotes: None,
13386            theme: None,
13387            fonts: Vec::new(),
13388        };
13389
13390        let result = Engine::new().layout(&input);
13391        if let Ok(result) = result {
13392            assert_eq!(result.pages.len(), 1);
13393        }
13394    }
13395
13396    #[test]
13397    fn word_blocks_build_document_order_semantics_before_pagination() {
13398        fn paragraph() -> ParagraphBlock {
13399            block::build_paragraph_block(
13400                Vec::new(),
13401                0.0,
13402                0.0,
13403                None,
13404                None,
13405                0.0,
13406                0.0,
13407                None,
13408                false,
13409                false,
13410                false,
13411                true,
13412            )
13413        }
13414
13415        let mut heading = paragraph();
13416        heading.heading_level = Some(1);
13417        let mut list_item = paragraph();
13418        list_item.list = Some((7, 0));
13419        let mut nested_item = paragraph();
13420        nested_item.list = Some((7, 1));
13421        nested_item.lines = vec![oxml_layout::LayoutLine {
13422            items: vec![LineItem::Figure {
13423                item: Box::new(LineItem::Group {
13424                    width: 10.0,
13425                    height: 10.0,
13426                    group: GroupElement {
13427                        transform: oxml_layout::Transform::IDENTITY,
13428                        clip: None,
13429                        opacity: 1.0,
13430                        effects: Vec::new(),
13431                        children: vec![PositionedElement::FilledRect {
13432                            rect: Rect {
13433                                x: 0.0,
13434                                y: 0.0,
13435                                width: 10.0,
13436                                height: 10.0,
13437                            },
13438                            color: Color::BLACK,
13439                        }],
13440                    },
13441                }),
13442                alternate_text: "Revenue by quarter".to_owned(),
13443                structure_id: None,
13444            }],
13445            width: 10.0,
13446            ascent: 10.0,
13447            descent: 0.0,
13448            line_gap: 0.0,
13449            height: 10.0,
13450            indent_left: 0.0,
13451            available_width: 468.0,
13452            is_last: true,
13453        }];
13454        let cell = |is_first_row: bool| table::TableCell {
13455            structure_id: None,
13456            blocks: vec![table::CellBlock::Paragraph(paragraph())],
13457            width: 100.0,
13458            height: 12.0,
13459            grid_span: 1,
13460            is_vmerge_continue: false,
13461            starts_vmerge: false,
13462            merged_height: 12.0,
13463            merge_with_below: false,
13464            clip_content: false,
13465            col_index: 0,
13466            borders: None,
13467            shading: None,
13468            margin_left: 0.0,
13469            margin_right: 0.0,
13470            margin_top: 0.0,
13471            margin_bottom: 0.0,
13472            is_first_row,
13473            is_last_row: !is_first_row,
13474            v_align: None,
13475        };
13476        let table = table::TableBlock {
13477            structure_id: None,
13478            col_widths: vec![100.0],
13479            rows: vec![
13480                table::TableRow {
13481                    structure_id: None,
13482                    cells: vec![cell(true)],
13483                    height: 12.0,
13484                    is_header: true,
13485                },
13486                table::TableRow {
13487                    structure_id: None,
13488                    cells: vec![cell(false)],
13489                    height: 12.0,
13490                    is_header: false,
13491                },
13492            ],
13493            header_row_indices: vec![0],
13494            table_width: 100.0,
13495            table_indent: 0.0,
13496            borders: None,
13497        };
13498        let mut sections = [paginator::Section {
13499            blocks: vec![
13500                LayoutBlock::Paragraph(heading),
13501                LayoutBlock::Paragraph(list_item),
13502                LayoutBlock::Paragraph(nested_item),
13503                LayoutBlock::Table(table),
13504            ],
13505            geometry: PageGeometry::default(),
13506            header_footer: None,
13507            title_pg: false,
13508            page_number_start: None,
13509        }];
13510
13511        let structure = assign_document_structure(&mut sections);
13512        let roles = structure
13513            .nodes
13514            .iter()
13515            .map(|node| node.role)
13516            .collect::<Vec<_>>();
13517        assert_eq!(
13518            roles,
13519            [
13520                StructureRole::Document,
13521                StructureRole::Heading(1),
13522                StructureRole::List,
13523                StructureRole::ListItem,
13524                StructureRole::Paragraph,
13525                StructureRole::List,
13526                StructureRole::ListItem,
13527                StructureRole::Paragraph,
13528                StructureRole::Figure,
13529                StructureRole::Table,
13530                StructureRole::TableRow,
13531                StructureRole::TableHeaderCell,
13532                StructureRole::Paragraph,
13533                StructureRole::TableRow,
13534                StructureRole::TableCell,
13535                StructureRole::Paragraph,
13536            ]
13537        );
13538        assert_eq!(
13539            structure.nodes[8].alternate_text.as_deref(),
13540            Some("Revenue by quarter")
13541        );
13542        assert_eq!(structure.nodes[5].children, [structure.nodes[6].id]);
13543        assert_eq!(
13544            structure.nodes[9].children,
13545            [structure.nodes[10].id, structure.nodes[13].id]
13546        );
13547    }
13548
13549    #[test]
13550    fn behind_document_figure_follows_its_source_paragraph_in_structure() {
13551        let mut paragraph = block::build_paragraph_block(
13552            Vec::new(),
13553            0.0,
13554            0.0,
13555            None,
13556            None,
13557            0.0,
13558            0.0,
13559            None,
13560            false,
13561            false,
13562            false,
13563            true,
13564        );
13565        paragraph.anchored.push(block::AnchoredDrawing {
13566            behind_doc: true,
13567            rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
13568            off_h: 0.0,
13569            rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
13570            off_v: 0.0,
13571            width: 10.0,
13572            height: 10.0,
13573            wrap: rdocx_oxml::drawing::WrapType::None,
13574            dist_top: 0.0,
13575            dist_bottom: 0.0,
13576            dist_left: 0.0,
13577            dist_right: 0.0,
13578            align_h: None,
13579            align_v: None,
13580            content: block::AnchoredContent::Image {
13581                media_id: MediaId(1),
13582            },
13583            alternate_text: Some("Background diagram".to_owned()),
13584            structure_id: None,
13585        });
13586        let mut sections = [paginator::Section {
13587            blocks: vec![LayoutBlock::Paragraph(paragraph)],
13588            geometry: PageGeometry::default(),
13589            header_footer: None,
13590            title_pg: false,
13591            page_number_start: None,
13592        }];
13593
13594        let structure = assign_document_structure(&mut sections);
13595
13596        assert_eq!(
13597            structure
13598                .nodes
13599                .iter()
13600                .map(|node| node.role)
13601                .collect::<Vec<_>>(),
13602            [
13603                StructureRole::Document,
13604                StructureRole::Paragraph,
13605                StructureRole::Figure,
13606            ]
13607        );
13608        assert_eq!(
13609            structure.nodes[0].children,
13610            [structure.nodes[1].id, structure.nodes[2].id]
13611        );
13612        assert!(structure.nodes[1].children.is_empty());
13613        assert_eq!(
13614            structure.nodes[2].alternate_text.as_deref(),
13615            Some("Background diagram")
13616        );
13617    }
13618
13619    #[test]
13620    fn empty_shapeless_anchor_keeps_the_pre_cutover_omission() {
13621        let input = make_input_with_text("");
13622        let mut paragraph = CT_P::new();
13623        paragraph.add_run("").content = vec![RunContent::Drawing(
13624            rdocx_oxml::drawing::CT_Drawing::anchor(rdocx_oxml::drawing::CT_Anchor::background(
13625                "", 914_400, 914_400,
13626            )),
13627        )];
13628        let mut font_manager = FontManager::new();
13629        let mut numbering_state = NumberingState::new();
13630        let mut diagnostics = Vec::new();
13631        let media = MediaRegistry::new(&input.images);
13632
13633        let anchored = collect_anchored_drawings(
13634            &paragraph,
13635            &input.styles,
13636            &input,
13637            &media,
13638            &mut font_manager,
13639            &mut numbering_state,
13640            &mut diagnostics,
13641        )
13642        .expect("empty shapeless anchor collection should succeed");
13643
13644        assert!(anchored.is_empty());
13645    }
13646
13647    #[test]
13648    fn colliding_media_ids_keep_inline_and_anchored_image_bytes_distinct() {
13649        let mut input = make_input_with_text("");
13650        input.images.insert(
13651            "rIdInline".to_string(),
13652            ImageData {
13653                data: vec![1, 2, 3],
13654                content_type: "image/png".to_string(),
13655            },
13656        );
13657        input.images.insert(
13658            "rIdAnchor".to_string(),
13659            ImageData {
13660                data: vec![4, 5, 6],
13661                content_type: "image/jpeg".to_string(),
13662            },
13663        );
13664
13665        let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
13666        let inline_id = media.id_for_relationship("rIdInline");
13667        let anchor_id = media.id_for_relationship("rIdAnchor");
13668        assert_ne!(inline_id, anchor_id);
13669
13670        let line = oxml_layout::LayoutLine {
13671            items: vec![oxml_layout::LineItem::Image {
13672                width: 12.0,
13673                height: 10.0,
13674                media_id: inline_id,
13675            }],
13676            width: 12.0,
13677            ascent: 10.0,
13678            descent: 0.0,
13679            line_gap: 0.0,
13680            height: 10.0,
13681            indent_left: 0.0,
13682            available_width: 468.0,
13683            is_last: true,
13684        };
13685        let mut paragraph = block::build_paragraph_block(
13686            vec![line],
13687            0.0,
13688            0.0,
13689            None,
13690            None,
13691            0.0,
13692            0.0,
13693            None,
13694            false,
13695            false,
13696            false,
13697            true,
13698        );
13699        paragraph.anchored.push(block::AnchoredDrawing {
13700            behind_doc: false,
13701            rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
13702            off_h: 20.0,
13703            rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
13704            off_v: 20.0,
13705            width: 12.0,
13706            height: 10.0,
13707            wrap: rdocx_oxml::drawing::WrapType::None,
13708            dist_top: 0.0,
13709            dist_bottom: 0.0,
13710            dist_left: 0.0,
13711            dist_right: 0.0,
13712            align_h: None,
13713            align_v: None,
13714            content: block::AnchoredContent::Image {
13715                media_id: anchor_id,
13716            },
13717            alternate_text: None,
13718            structure_id: None,
13719        });
13720        let sections = [paginator::Section {
13721            blocks: vec![LayoutBlock::Paragraph(paragraph)],
13722            geometry: PageGeometry::default(),
13723            header_footer: None,
13724            title_pg: false,
13725            page_number_start: None,
13726        }];
13727
13728        let (pages, _) = paginator::paginate_sections(
13729            &sections,
13730            &FontManager::new(),
13731            &media,
13732            &NoteRegistry::default(),
13733        );
13734        let images = compatibility_page_elements(&pages[0])
13735            .into_iter()
13736            .filter_map(|element| match element {
13737                PositionedElement::Image {
13738                    data,
13739                    content_type,
13740                    media_id,
13741                    ..
13742                } => Some((data.as_slice(), content_type.as_str(), *media_id)),
13743                _ => None,
13744            })
13745            .collect::<Vec<_>>();
13746
13747        assert!(images.contains(&(b"\x01\x02\x03".as_slice(), "image/png", inline_id)));
13748        assert!(images.contains(&(b"\x04\x05\x06".as_slice(), "image/jpeg", anchor_id)));
13749    }
13750
13751    #[test]
13752    fn watermark_image_uses_the_collision_safe_media_registry_id() {
13753        let mut input = make_input_with_text("body");
13754        input.images.insert(
13755            "rIdHeader\0rIdOrdinary".to_owned(),
13756            ImageData {
13757                data: vec![1],
13758                content_type: "image/png".to_owned(),
13759            },
13760        );
13761        input.images.insert(
13762            "rIdHeader\0rIdWatermark".to_owned(),
13763            ImageData {
13764                data: vec![2],
13765                content_type: "image/png".to_owned(),
13766            },
13767        );
13768        let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
13769        let expected = media.id_for_relationship("rIdHeader\0rIdWatermark");
13770        let mut font_manager = FontManager::new();
13771        let mut diagnostics = Vec::new();
13772        let group = layout_watermark(
13773            &VmlWatermark::Image {
13774                relationship_id: "rIdWatermark".to_owned(),
13775                width_pt: 72.0,
13776                height_pt: 36.0,
13777                rotation_degrees: 0.0,
13778                opacity: 0.5,
13779            },
13780            "rIdHeader",
13781            &input,
13782            &media,
13783            &mut font_manager,
13784            PageGeometry::default(),
13785            &mut diagnostics,
13786        )
13787        .unwrap()
13788        .unwrap();
13789        let PositionedElement::Image { media_id, data, .. } = &group.children[0] else {
13790            panic!("expected watermark image");
13791        };
13792        assert_eq!(*media_id, expected);
13793        assert_eq!(data, &[2]);
13794        assert!(diagnostics.is_empty());
13795    }
13796
13797    #[test]
13798    fn group_inline_item_breaks_and_positions_like_an_image() {
13799        let child = PositionedElement::FilledRect {
13800            rect: Rect {
13801                x: 2.0,
13802                y: 3.0,
13803                width: 4.0,
13804                height: 5.0,
13805            },
13806            color: Color::BLACK,
13807        };
13808        let group = GroupElement {
13809            transform: oxml_layout::Transform::IDENTITY,
13810            clip: None,
13811            opacity: 1.0,
13812            effects: Vec::new(),
13813            children: vec![child.clone()],
13814        };
13815        let line = oxml_layout::LayoutLine {
13816            items: vec![oxml_layout::LineItem::Group {
13817                width: 80.0,
13818                height: 40.0,
13819                group,
13820            }],
13821            width: 80.0,
13822            ascent: 40.0,
13823            descent: 0.0,
13824            line_gap: 0.0,
13825            height: 40.0,
13826            indent_left: 0.0,
13827            available_width: 468.0,
13828            is_last: true,
13829        };
13830        let paragraph = block::build_paragraph_block(
13831            vec![line],
13832            0.0,
13833            0.0,
13834            None,
13835            None,
13836            0.0,
13837            0.0,
13838            None,
13839            false,
13840            false,
13841            false,
13842            true,
13843        );
13844        let sections = [paginator::Section {
13845            blocks: vec![LayoutBlock::Paragraph(paragraph)],
13846            geometry: PageGeometry::default(),
13847            header_footer: None,
13848            title_pg: false,
13849            page_number_start: None,
13850        }];
13851        let media = MediaRegistry::new(&HashMap::new());
13852        let (pages, _) = paginator::paginate_sections(
13853            &sections,
13854            &FontManager::new(),
13855            &media,
13856            &NoteRegistry::default(),
13857        );
13858
13859        let PositionedElement::Group(actual) = &pages[0].elements[0] else {
13860            panic!("group line item should become a positioned group");
13861        };
13862        assert_eq!((actual.transform.e, actual.transform.f), (72.0, 72.0));
13863        assert_eq!(actual.children, vec![child]);
13864    }
13865
13866    #[test]
13867    fn layout_with_heading_style() {
13868        let mut doc = rdocx_oxml::document::CT_Document::new();
13869        let mut p = CT_P::new();
13870        p.properties = Some(CT_PPr {
13871            style_id: Some("Heading1".to_string()),
13872            ..Default::default()
13873        });
13874        p.add_run("Chapter 1");
13875        doc.body.add_paragraph(p);
13876
13877        let input = LayoutInput {
13878            revision_view: crate::input::RevisionView::Accepted,
13879            automatic_hyphenation: false,
13880            document: doc,
13881            styles: CT_Styles::new_default(),
13882            numbering: None,
13883            headers: HashMap::new(),
13884            footers: HashMap::new(),
13885            images: HashMap::new(),
13886            charts: HashMap::new(),
13887            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
13888            chart_color_map: oxml_drawing::color::ColorMap::default(),
13889            core_properties: None,
13890            hyperlink_urls: HashMap::new(),
13891            footnotes: None,
13892            endnotes: None,
13893            theme: None,
13894            fonts: Vec::new(),
13895        };
13896
13897        let result = Engine::new().layout(&input);
13898        if let Ok(result) = result {
13899            assert!(!result.pages.is_empty());
13900            // Should produce one outline entry for Heading1
13901            assert_eq!(result.outlines.len(), 1);
13902            assert_eq!(result.outlines[0].title, "Chapter 1");
13903            assert_eq!(result.outlines[0].level, 1);
13904            assert_eq!(result.outlines[0].page_index, 0);
13905        }
13906    }
13907
13908    #[test]
13909    fn layout_nested_headings_produce_outlines() {
13910        let mut doc = rdocx_oxml::document::CT_Document::new();
13911
13912        // H1
13913        let mut h1 = CT_P::new();
13914        h1.properties = Some(CT_PPr {
13915            style_id: Some("Heading1".to_string()),
13916            ..Default::default()
13917        });
13918        h1.add_run("Chapter 1");
13919        doc.body.add_paragraph(h1);
13920
13921        // H2 under H1
13922        let mut h2 = CT_P::new();
13923        h2.properties = Some(CT_PPr {
13924            style_id: Some("Heading2".to_string()),
13925            ..Default::default()
13926        });
13927        h2.add_run("Section 1.1");
13928        doc.body.add_paragraph(h2);
13929
13930        // Another H1
13931        let mut h1b = CT_P::new();
13932        h1b.properties = Some(CT_PPr {
13933            style_id: Some("Heading1".to_string()),
13934            ..Default::default()
13935        });
13936        h1b.add_run("Chapter 2");
13937        doc.body.add_paragraph(h1b);
13938
13939        let input = LayoutInput {
13940            revision_view: crate::input::RevisionView::Accepted,
13941            automatic_hyphenation: false,
13942            document: doc,
13943            styles: CT_Styles::new_default(),
13944            numbering: None,
13945            headers: HashMap::new(),
13946            footers: HashMap::new(),
13947            images: HashMap::new(),
13948            charts: HashMap::new(),
13949            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
13950            chart_color_map: oxml_drawing::color::ColorMap::default(),
13951            core_properties: None,
13952            hyperlink_urls: HashMap::new(),
13953            footnotes: None,
13954            endnotes: None,
13955            theme: None,
13956            fonts: Vec::new(),
13957        };
13958
13959        let result = Engine::new().layout(&input);
13960        if let Ok(result) = result {
13961            assert_eq!(result.outlines.len(), 3);
13962            assert_eq!(result.outlines[0].level, 1);
13963            assert_eq!(result.outlines[0].title, "Chapter 1");
13964            assert_eq!(result.outlines[1].level, 2);
13965            assert_eq!(result.outlines[1].title, "Section 1.1");
13966            assert_eq!(result.outlines[2].level, 1);
13967            assert_eq!(result.outlines[2].title, "Chapter 2");
13968        }
13969    }
13970
13971    #[test]
13972    fn sect_pr_geometry_conversion() {
13973        let sect = CT_SectPr::default_letter();
13974        let geom = sect_pr_to_geometry(&sect);
13975        assert!((geom.page_width - 612.0).abs() < 0.01);
13976        assert!((geom.page_height - 792.0).abs() < 0.01);
13977        assert!((geom.margin_top - 72.0).abs() < 0.01);
13978        assert!((geom.content_width() - 468.0).abs() < 0.01);
13979    }
13980
13981    #[test]
13982    fn section_page_number_start_requires_a_direct_word_child_and_decodes_entities() {
13983        let mut section = CT_SectPr::default_letter();
13984        section.extra_xml = vec![
13985            br#"<x:pgNumType xmlns:x="urn:producer" x:start="2"/>"#.to_vec(),
13986            br#"<w:pgNumType xmlns:w="urn:producer" w:start="2"/>"#.to_vec(),
13987            format!(
13988                r#"<w:wrapper xmlns:w="{}"><w:pgNumType w:start="2"/></w:wrapper>"#,
13989                rdocx_oxml::namespace::W_NS
13990            )
13991            .into_bytes(),
13992            format!(
13993                r#"<q:pgNumType xmlns:q="{}" q:start="&#x31;"/>"#,
13994                rdocx_oxml::namespace::W_NS
13995            )
13996            .into_bytes(),
13997        ];
13998
13999        assert_eq!(section_page_number_start(&section), Some(1));
14000    }
14001
14002    #[test]
14003    fn sect_pr_a4_geometry() {
14004        let sect = CT_SectPr::default_a4();
14005        let geom = sect_pr_to_geometry(&sect);
14006        // A4: 210mm = 595.3pt, 297mm = 841.9pt
14007        assert!((geom.page_width - 595.3).abs() < 0.5);
14008        assert!((geom.page_height - 841.9).abs() < 0.5);
14009    }
14010
14011    // F-X013a, footnote line advance.
14012
14013    /// Build a document whose single body paragraph references footnote 1, and
14014    /// whose footnote 1 is one paragraph made of `note_runs` separate runs.
14015    fn make_input_with_footnote(note_runs: &[&str]) -> LayoutInput {
14016        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14017        use rdocx_oxml::text::CT_R;
14018
14019        let mut doc = rdocx_oxml::document::CT_Document::new();
14020        let mut body = CT_P::new();
14021        body.add_run("Body text carrying a note");
14022        let mut marker_run = CT_R::new("");
14023        marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
14024        body.runs.push(marker_run);
14025        doc.body.add_paragraph(body);
14026
14027        let mut note = CT_P::new();
14028        for text in note_runs {
14029            note.add_run(text);
14030        }
14031
14032        LayoutInput {
14033            revision_view: crate::input::RevisionView::Accepted,
14034            automatic_hyphenation: false,
14035            document: doc,
14036            styles: CT_Styles::new_default(),
14037            numbering: None,
14038            headers: HashMap::new(),
14039            footers: HashMap::new(),
14040            images: HashMap::new(),
14041            charts: HashMap::new(),
14042            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14043            chart_color_map: oxml_drawing::color::ColorMap::default(),
14044            core_properties: None,
14045            hyperlink_urls: HashMap::new(),
14046            footnotes: Some(CT_Footnotes {
14047                footnotes: vec![CT_Footnote {
14048                    id: 1,
14049                    note_type: NoteType::Normal,
14050                    paragraphs: vec![note],
14051                }],
14052            }),
14053            endnotes: None,
14054            theme: None,
14055            fonts: Vec::new(),
14056        }
14057    }
14058
14059    #[test]
14060    fn automatic_hyphenation_reaches_note_story_paragraphs() {
14061        let mut input = make_input_with_footnote(&["representation"]);
14062        input.automatic_hyphenation = true;
14063        let note = &mut input.footnotes.as_mut().unwrap().footnotes[0].paragraphs[0];
14064        note.properties = Some(CT_PPr {
14065            ind_right: Some(rdocx_oxml::units::Twips(8_500)),
14066            ..Default::default()
14067        });
14068        note.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
14069            language: Some("en-US".to_owned()),
14070            ..Default::default()
14071        });
14072
14073        let text = output_text(&deterministic_layout(&input));
14074        assert!(text.iter().any(|item| item == "-"), "{text:?}");
14075    }
14076
14077    /// The x origin of every glyph run sitting below the footnote separator,
14078    /// in the order the renderer emitted them. The first is the note marker.
14079    fn footnote_glyph_x(page: &oxml_layout::output::PageFrame) -> Vec<f64> {
14080        let elements = compatibility_page_elements(page);
14081        let separator_y = elements
14082            .iter()
14083            .find_map(|element| match element {
14084                PositionedElement::Line { start, .. } => Some(start.y),
14085                _ => None,
14086            })
14087            .expect("a page with a footnote draws a separator line");
14088
14089        elements
14090            .iter()
14091            .filter_map(|element| match element {
14092                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.x),
14093                _ => None,
14094            })
14095            .collect()
14096    }
14097
14098    #[test]
14099    fn a_multi_segment_footnote_does_not_stack_its_segments_at_one_x() {
14100        let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
14101        let mut engine = Engine::new();
14102        let output = engine.layout(&input).expect("layout succeeds");
14103        let xs = footnote_glyph_x(&output.pages[0]);
14104
14105        assert!(
14106            xs.len() >= 4,
14107            "expected a marker and three note segments, got {xs:?}"
14108        );
14109        for pair in xs.windows(2) {
14110            assert!(
14111                pair[1] > pair[0],
14112                "footnote segments must advance, got {xs:?}"
14113            );
14114        }
14115    }
14116
14117    #[test]
14118    fn a_single_segment_footnote_keeps_its_original_position() {
14119        let input = make_input_with_footnote(&["Solitary"]);
14120        let mut engine = Engine::new();
14121        let output = engine.layout(&input).expect("layout succeeds");
14122        let xs = footnote_glyph_x(&output.pages[0]);
14123        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14124
14125        // The marker sits at the left margin, the single segment one indent in.
14126        assert_eq!(xs.len(), 2, "expected a marker and one segment, got {xs:?}");
14127        assert!(
14128            (xs[0] - geometry.margin_left).abs() < 0.01,
14129            "marker at {xs:?}"
14130        );
14131        assert!(
14132            (xs[1] - (geometry.margin_left + 12.0)).abs() < 0.01,
14133            "segment at {xs:?}"
14134        );
14135    }
14136
14137    #[test]
14138    fn a_long_footnote_does_not_overrun_the_right_margin() {
14139        // Long enough to wrap, which is what exposes a break width that
14140        // disagrees with the indent the note is drawn at.
14141        let long = "In paged media, footnotes are usually displayed at the \
14142                    bottom of the text. However, in ebooks, a better paradigm \
14143                    is to make them clickable endnotes that the reader can \
14144                    browse at leisure, which this sentence exists to force.";
14145        let input = make_input_with_footnote(&[long]);
14146        let mut engine = Engine::new();
14147        let output = engine.layout(&input).expect("layout succeeds");
14148        let page = &output.pages[0];
14149        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14150        let right_margin = geometry.page_width - geometry.margin_right;
14151
14152        let elements = compatibility_page_elements(page);
14153        let separator_y = elements
14154            .iter()
14155            .find_map(|element| match element {
14156                PositionedElement::Line { start, .. } => Some(start.y),
14157                _ => None,
14158            })
14159            .expect("a page with a footnote draws a separator line");
14160
14161        let mut wrapped = false;
14162        let mut first_y = None;
14163        for element in elements {
14164            let PositionedElement::Text(run) = element else {
14165                continue;
14166            };
14167            if run.origin.y <= separator_y {
14168                continue;
14169            }
14170            let first = *first_y.get_or_insert(run.origin.y);
14171            if run.origin.y > first + 0.01 {
14172                wrapped = true;
14173            }
14174            let right_edge = run.origin.x + run.advances.iter().sum::<f64>();
14175            assert!(
14176                right_edge <= right_margin + 0.01,
14177                "note text reaches {right_edge}, past the right margin {right_margin}"
14178            );
14179        }
14180        assert!(wrapped, "the note must wrap for this test to mean anything");
14181    }
14182
14183    #[test]
14184    fn a_tab_inside_a_footnote_still_advances_the_text_after_it() {
14185        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14186        use rdocx_oxml::text::CT_R;
14187
14188        // Two notes differing only by a tab between their runs. The tab is not
14189        // drawn, but it occupies width, so the run after it must shift right.
14190        let build = |with_tab: bool| {
14191            let mut doc = rdocx_oxml::document::CT_Document::new();
14192            let mut body = CT_P::new();
14193            body.add_run("Body");
14194            let mut marker_run = CT_R::new("");
14195            marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
14196            body.runs.push(marker_run);
14197            doc.body.add_paragraph(body);
14198
14199            let mut note = CT_P::new();
14200            note.add_run("Alpha");
14201            if with_tab {
14202                let mut tab_run = CT_R::new("");
14203                tab_run.content = vec![RunContent::Tab];
14204                note.runs.push(tab_run);
14205            }
14206            note.add_run("Beta");
14207
14208            LayoutInput {
14209                revision_view: crate::input::RevisionView::Accepted,
14210                automatic_hyphenation: false,
14211                document: doc,
14212                styles: CT_Styles::new_default(),
14213                numbering: None,
14214                headers: HashMap::new(),
14215                footers: HashMap::new(),
14216                images: HashMap::new(),
14217                charts: HashMap::new(),
14218                chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14219                chart_color_map: oxml_drawing::color::ColorMap::default(),
14220                core_properties: None,
14221                hyperlink_urls: HashMap::new(),
14222                footnotes: Some(CT_Footnotes {
14223                    footnotes: vec![CT_Footnote {
14224                        id: 1,
14225                        note_type: NoteType::Normal,
14226                        paragraphs: vec![note],
14227                    }],
14228                }),
14229                endnotes: None,
14230                theme: None,
14231                fonts: Vec::new(),
14232            }
14233        };
14234
14235        let mut engine = Engine::new();
14236        let plain = engine.layout(&build(false)).expect("layout succeeds");
14237        let tabbed = engine.layout(&build(true)).expect("layout succeeds");
14238
14239        let plain_x = footnote_glyph_x(&plain.pages[0]);
14240        let tabbed_x = footnote_glyph_x(&tabbed.pages[0]);
14241
14242        // Marker and both runs are drawn in each case. The tab draws nothing.
14243        assert_eq!(plain_x.len(), 3, "plain note glyphs {plain_x:?}");
14244        assert_eq!(tabbed_x.len(), 3, "tabbed note glyphs {tabbed_x:?}");
14245        assert!(
14246            tabbed_x[2] > plain_x[2] + 1.0,
14247            "the run after a tab must shift right, plain {plain_x:?} tabbed {tabbed_x:?}"
14248        );
14249    }
14250
14251    #[test]
14252    fn footnote_segment_advance_matches_body_segment_advance() {
14253        let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
14254        let mut engine = Engine::new();
14255        let output = engine.layout(&input).expect("layout succeeds");
14256        let page = &output.pages[0];
14257
14258        let elements = compatibility_page_elements(page);
14259        let separator_y = elements
14260            .iter()
14261            .find_map(|element| match element {
14262                PositionedElement::Line { start, .. } => Some(start.y),
14263                _ => None,
14264            })
14265            .expect("a page with a footnote draws a separator line");
14266
14267        // Gaps between consecutive note segments must equal the width of the
14268        // segment that precedes them, which is what the body path advances by.
14269        let notes: Vec<&oxml_layout::GlyphRun> = elements
14270            .iter()
14271            .filter_map(|element| match element {
14272                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run),
14273                _ => None,
14274            })
14275            .skip(1) // the marker, which is positioned independently
14276            .collect();
14277
14278        assert_eq!(notes.len(), 3, "expected three note segments");
14279        for pair in notes.windows(2) {
14280            let advance: f64 = pair[0].advances.iter().sum();
14281            let gap = pair[1].origin.x - pair[0].origin.x;
14282            assert!(
14283                (gap - advance).abs() < 0.01,
14284                "gap {gap} should equal preceding segment advance {advance}"
14285            );
14286        }
14287    }
14288
14289    // F-X013b, reservation and splitting.
14290
14291    /// A document of `body_paras` paragraphs. The paragraph at
14292    /// `ref_positions` each carry a reference to note 1, whose content is
14293    /// `note_paras` paragraphs of `note_text`.
14294    fn make_noted_document(
14295        body_paras: usize,
14296        ref_positions: &[usize],
14297        note_paras: usize,
14298        note_text: &str,
14299        continuation_separator: bool,
14300    ) -> LayoutInput {
14301        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14302        use rdocx_oxml::text::CT_R;
14303
14304        let mut doc = rdocx_oxml::document::CT_Document::new();
14305        for index in 0..body_paras {
14306            let mut para = CT_P::new();
14307            para.add_run("Body paragraph text that occupies a line of the page.");
14308            if ref_positions.contains(&index) {
14309                let mut marker = CT_R::new("");
14310                marker.content = vec![RunContent::FootnoteRef { id: 1 }];
14311                para.runs.push(marker);
14312            }
14313            doc.body.add_paragraph(para);
14314        }
14315
14316        let mut entries = Vec::new();
14317        if continuation_separator {
14318            entries.push(CT_Footnote {
14319                id: 0,
14320                note_type: NoteType::ContinuationSeparator,
14321                paragraphs: vec![CT_P::new()],
14322            });
14323        }
14324        entries.push(CT_Footnote {
14325            id: 1,
14326            note_type: NoteType::Normal,
14327            paragraphs: (0..note_paras)
14328                .map(|_| {
14329                    let mut p = CT_P::new();
14330                    p.add_run(note_text);
14331                    p
14332                })
14333                .collect(),
14334        });
14335
14336        LayoutInput {
14337            revision_view: crate::input::RevisionView::Accepted,
14338            automatic_hyphenation: false,
14339            document: doc,
14340            styles: CT_Styles::new_default(),
14341            numbering: None,
14342            headers: HashMap::new(),
14343            footers: HashMap::new(),
14344            images: HashMap::new(),
14345            charts: HashMap::new(),
14346            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14347            chart_color_map: oxml_drawing::color::ColorMap::default(),
14348            core_properties: None,
14349            hyperlink_urls: HashMap::new(),
14350            footnotes: Some(CT_Footnotes { footnotes: entries }),
14351            endnotes: None,
14352            theme: None,
14353            fonts: Vec::new(),
14354        }
14355    }
14356
14357    /// Split a page into the glyphs drawn above the note separator and those
14358    /// drawn below it. Notes are emitted after body content, so the separator
14359    /// is the boundary.
14360    fn split_at_separator(
14361        page: &oxml_layout::output::PageFrame,
14362    ) -> Option<(f64, Vec<f64>, Vec<String>)> {
14363        let elements = compatibility_page_elements(page);
14364        let separator_index = elements.iter().position(|element| {
14365            matches!(element, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001)
14366        })?;
14367        let PositionedElement::Line { start, end, .. } = elements[separator_index] else {
14368            return None;
14369        };
14370        let separator_y = start.y;
14371        let separator_width = end.x - start.x;
14372
14373        let body_ys: Vec<f64> = elements[..separator_index]
14374            .iter()
14375            .filter_map(|element| match element {
14376                PositionedElement::Text(run) => Some(run.origin.y),
14377                _ => None,
14378            })
14379            .collect();
14380        let note_text: Vec<String> = elements[separator_index + 1..]
14381            .iter()
14382            .filter_map(|element| match element {
14383                PositionedElement::Text(run) => Some(run.text.clone()),
14384                _ => None,
14385            })
14386            .collect();
14387
14388        let _ = separator_y;
14389        Some((separator_width, body_ys, note_text))
14390    }
14391
14392    fn separator_y_of(page: &oxml_layout::output::PageFrame) -> Option<f64> {
14393        compatibility_page_elements(page)
14394            .into_iter()
14395            .find_map(|element| match element {
14396                PositionedElement::Line { start, width, .. } if (*width - 0.5).abs() < 0.001 => {
14397                    Some(start.y)
14398                }
14399                _ => None,
14400            })
14401    }
14402
14403    #[test]
14404    fn a_page_whose_body_fills_the_text_area_does_not_overlap_its_notes() {
14405        // Enough body to reach the bottom margin, with the reference early so
14406        // the note is owed by the first page.
14407        let input = make_noted_document(
14408            60,
14409            &[0],
14410            2,
14411            "A note long enough to wrap onto a second line of the note area.",
14412            false,
14413        );
14414        let mut engine = Engine::new();
14415        let output = engine.layout(&input).expect("layout succeeds");
14416        let page = &output.pages[0];
14417
14418        let separator_y = separator_y_of(page).expect("the page draws a separator");
14419        let (_, body_ys, note_text) = split_at_separator(page).unwrap();
14420
14421        assert!(!note_text.is_empty(), "the note must be drawn");
14422        let lowest_body = body_ys.iter().cloned().fold(f64::MIN, f64::max);
14423        assert!(
14424            lowest_body < separator_y,
14425            "body text reaches {lowest_body}, at or below the separator at {separator_y}"
14426        );
14427    }
14428
14429    #[test]
14430    fn a_page_referencing_one_note_twice_reserves_it_once() {
14431        let input = make_noted_document(4, &[0, 1], 1, "Referenced twice from one page.", false);
14432        let mut engine = Engine::new();
14433        let output = engine.layout(&input).expect("layout succeeds");
14434        let page = &output.pages[0];
14435
14436        let separators = compatibility_page_elements(page)
14437            .into_iter()
14438            .filter(|e| matches!(e, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001))
14439            .count();
14440        assert_eq!(separators, 1, "one note area, so one separator");
14441
14442        let (_, _, note_text) = split_at_separator(page).unwrap();
14443        let markers = note_text.iter().filter(|t| t.as_str() == "1").count();
14444        assert_eq!(markers, 1, "the note is drawn once, got {note_text:?}");
14445    }
14446
14447    #[test]
14448    fn a_note_taller_than_its_remaining_space_continues_on_the_next_page() {
14449        // 120 note paragraphs exceed a single page, so the note has to break.
14450        let input = make_noted_document(30, &[25], 120, "Note paragraph line.", true);
14451        let mut engine = Engine::new();
14452        let output = engine.layout(&input).expect("layout succeeds");
14453
14454        let note_pages: Vec<usize> = output
14455            .pages
14456            .iter()
14457            .enumerate()
14458            .filter(|(_, page)| separator_y_of(page).is_some())
14459            .map(|(index, _)| index)
14460            .collect();
14461
14462        assert!(
14463            note_pages.len() >= 2,
14464            "a note taller than a page must span pages, got {note_pages:?}"
14465        );
14466
14467        let first = split_at_separator(&output.pages[note_pages[0]]).unwrap().2;
14468        let second = split_at_separator(&output.pages[note_pages[1]]).unwrap().2;
14469
14470        assert!(!first.is_empty(), "the first page draws part of the note");
14471        assert!(!second.is_empty(), "the next page draws the rest");
14472        assert_eq!(
14473            first.iter().filter(|t| t.as_str() == "1").count(),
14474            1,
14475            "the marker is drawn on the page the note starts on"
14476        );
14477        assert_eq!(
14478            second.iter().filter(|t| t.as_str() == "1").count(),
14479            0,
14480            "a continuation does not repeat the marker, got {second:?}"
14481        );
14482    }
14483
14484    #[test]
14485    fn a_continued_note_draws_the_continuation_separator() {
14486        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14487
14488        let widths = |continuation: bool| {
14489            let input = make_noted_document(30, &[25], 120, "Note paragraph line.", continuation);
14490            let mut engine = Engine::new();
14491            let output = engine.layout(&input).expect("layout succeeds");
14492            let pages: Vec<usize> = output
14493                .pages
14494                .iter()
14495                .enumerate()
14496                .filter(|(_, page)| separator_y_of(page).is_some())
14497                .map(|(index, _)| index)
14498                .collect();
14499            assert!(pages.len() >= 2, "the note must span pages");
14500            (
14501                split_at_separator(&output.pages[pages[0]]).unwrap().0,
14502                split_at_separator(&output.pages[pages[1]]).unwrap().0,
14503            )
14504        };
14505
14506        let (first, second) = widths(true);
14507        assert!(
14508            (first - geometry.content_width() * 0.33).abs() < 0.5,
14509            "a note starting on its page gets the short rule, got {first}"
14510        );
14511        assert!(
14512            (second - geometry.content_width()).abs() < 0.5,
14513            "a continued note gets the full-width rule, got {second}"
14514        );
14515
14516        // A document defining no continuation separator keeps the short rule.
14517        let (_, second) = widths(false);
14518        assert!(
14519            (second - geometry.content_width() * 0.33).abs() < 0.5,
14520            "without a continuation separator the short rule is kept, got {second}"
14521        );
14522    }
14523
14524    #[test]
14525    fn an_oversized_note_still_leaves_room_for_body_text() {
14526        // A note several pages tall, referenced from the first paragraph.
14527        let input = make_noted_document(3, &[0], 200, "A line of an enormous note.", true);
14528        let mut engine = Engine::new();
14529        let output = engine.layout(&input).expect("layout terminates");
14530
14531        let (_, body_ys, _) = split_at_separator(&output.pages[0]).unwrap();
14532        assert!(
14533            !body_ys.is_empty(),
14534            "an oversized note must not starve the page of body text"
14535        );
14536        assert!(
14537            output.pages.len() > 1 && output.pages.len() < 100,
14538            "the note spills over a bounded number of pages, got {}",
14539            output.pages.len()
14540        );
14541
14542        // The note area has to stay on the page. Placing an oversized note
14543        // whole would push its separator off the top of the sheet.
14544        for (index, page) in output.pages.iter().enumerate() {
14545            let Some(separator_y) = separator_y_of(page) else {
14546                continue;
14547            };
14548            assert!(
14549                separator_y >= 0.0,
14550                "page {} draws its separator at {separator_y}, off the sheet",
14551                index + 1
14552            );
14553        }
14554    }
14555
14556    #[test]
14557    fn a_note_is_drawn_on_the_page_that_carries_its_reference() {
14558        // Sweeping the reference across the document is what catches the two
14559        // ways a note drifts off its own page: notes claimed for a paragraph
14560        // that then moves, and a note area measured from a cursor that still
14561        // holds the previous paragraph's trailing space.
14562        let mut mismatches = Vec::new();
14563        for position in 0..60 {
14564            let input = make_noted_document(60, &[position], 1, "Note text.", false);
14565            let mut engine = Engine::new();
14566            let output = engine.layout(&input).expect("layout succeeds");
14567
14568            let reference_page = output.pages.iter().position(|page| {
14569                compatibility_page_elements(page)
14570                    .into_iter()
14571                    .any(|element| {
14572                        matches!(element, PositionedElement::Text(run)
14573                        if run.note == Some(oxml_layout::NoteRef {
14574                            stream: oxml_layout::NoteStream::Footnote,
14575                            id: 1,
14576                        }))
14577                    })
14578            });
14579            let note_page = output
14580                .pages
14581                .iter()
14582                .position(|page| separator_y_of(page).is_some());
14583
14584            if reference_page != note_page {
14585                mismatches.push((position, reference_page, note_page));
14586            }
14587        }
14588
14589        assert!(
14590            mismatches.is_empty(),
14591            "note and reference landed on different pages for (position, ref, note): {mismatches:?}"
14592        );
14593    }
14594
14595    // F-X013c, endnotes at the document end.
14596
14597    /// A document whose single body paragraph references footnote `id` and
14598    /// endnote `id`, with each stream giving that number different text.
14599    fn make_document_with_both_streams(id: i32, body_paras: usize) -> LayoutInput {
14600        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14601        use rdocx_oxml::text::CT_R;
14602
14603        let mut doc = rdocx_oxml::document::CT_Document::new();
14604        for index in 0..body_paras {
14605            let mut para = CT_P::new();
14606            para.add_run("Body paragraph text that occupies a line of the page.");
14607            if index == 0 {
14608                let mut foot = CT_R::new("");
14609                foot.content = vec![RunContent::FootnoteRef { id }];
14610                para.runs.push(foot);
14611                let mut end = CT_R::new("");
14612                end.content = vec![RunContent::EndnoteRef { id }];
14613                para.runs.push(end);
14614            }
14615            doc.body.add_paragraph(para);
14616        }
14617
14618        let note = |text: &str| {
14619            let mut p = CT_P::new();
14620            p.add_run(text);
14621            CT_Footnote {
14622                id,
14623                note_type: NoteType::Normal,
14624                paragraphs: vec![p],
14625            }
14626        };
14627
14628        LayoutInput {
14629            revision_view: crate::input::RevisionView::Accepted,
14630            automatic_hyphenation: false,
14631            document: doc,
14632            styles: CT_Styles::new_default(),
14633            numbering: None,
14634            headers: HashMap::new(),
14635            footers: HashMap::new(),
14636            images: HashMap::new(),
14637            charts: HashMap::new(),
14638            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14639            chart_color_map: oxml_drawing::color::ColorMap::default(),
14640            core_properties: None,
14641            hyperlink_urls: HashMap::new(),
14642            footnotes: Some(CT_Footnotes {
14643                footnotes: vec![note("FOOTNOTETEXT")],
14644            }),
14645            endnotes: Some(CT_Footnotes {
14646                footnotes: vec![note("ENDNOTETEXT")],
14647            }),
14648            theme: None,
14649            fonts: Vec::new(),
14650        }
14651    }
14652
14653    fn page_text(page: &oxml_layout::output::PageFrame) -> String {
14654        compatibility_page_elements(page)
14655            .into_iter()
14656            .filter_map(|element| match element {
14657                PositionedElement::Text(run) => Some(run.text.as_str()),
14658                _ => None,
14659            })
14660            .collect::<Vec<_>>()
14661            .join(" ")
14662    }
14663
14664    #[test]
14665    fn a_footnote_and_an_endnote_sharing_a_number_render_their_own_text() {
14666        let input = make_document_with_both_streams(2, 3);
14667        let mut engine = Engine::new();
14668        let output = engine.layout(&input).expect("layout succeeds");
14669
14670        let all: String = output
14671            .pages
14672            .iter()
14673            .map(|page| page_text(page))
14674            .collect::<Vec<_>>()
14675            .join(" | ");
14676        assert!(
14677            all.contains("FOOTNOTETEXT"),
14678            "the footnote must render its own text, got {all}"
14679        );
14680        assert!(
14681            all.contains("ENDNOTETEXT"),
14682            "the endnote must render its own text, got {all}"
14683        );
14684    }
14685
14686    #[test]
14687    fn endnotes_render_after_the_last_body_page() {
14688        let input = make_document_with_both_streams(2, 3);
14689        let mut engine = Engine::new();
14690        let output = engine.layout(&input).expect("layout succeeds");
14691
14692        let endnote_page = output
14693            .pages
14694            .iter()
14695            .position(|page| page_text(page).contains("ENDNOTETEXT"))
14696            .expect("the endnote is rendered somewhere");
14697        let last_body_page = output
14698            .pages
14699            .iter()
14700            .rposition(|page| page_text(page).contains("occupies"))
14701            .expect("the body is rendered somewhere");
14702
14703        assert!(
14704            endnote_page > last_body_page,
14705            "endnotes come after every body page, endnote on {endnote_page} and body to {last_body_page}"
14706        );
14707        assert!(
14708            !page_text(&output.pages[endnote_page]).contains("occupies"),
14709            "an endnote page carries no body text"
14710        );
14711    }
14712
14713    #[test]
14714    fn footnotes_and_endnotes_keep_their_own_regions() {
14715        let input = make_document_with_both_streams(2, 3);
14716        let mut engine = Engine::new();
14717        let output = engine.layout(&input).expect("layout succeeds");
14718
14719        let footnote_page = output
14720            .pages
14721            .iter()
14722            .position(|page| page_text(page).contains("FOOTNOTETEXT"))
14723            .expect("the footnote is rendered");
14724
14725        // The footnote shares the page that carries its reference.
14726        assert!(
14727            page_text(&output.pages[footnote_page]).contains("occupies"),
14728            "a footnote sits on the page carrying its reference"
14729        );
14730        assert!(
14731            separator_y_of(&output.pages[footnote_page]).is_some(),
14732            "the footnote page draws a separator"
14733        );
14734
14735        // The endnote page is a different page, and draws no separator,
14736        // because there is no body text there to divide it from.
14737        let endnote_page = output
14738            .pages
14739            .iter()
14740            .position(|page| page_text(page).contains("ENDNOTETEXT"))
14741            .expect("the endnote is rendered");
14742        assert_ne!(footnote_page, endnote_page, "the two regions are distinct");
14743        assert!(
14744            separator_y_of(&output.pages[endnote_page]).is_none(),
14745            "an endnote page draws no separator rule"
14746        );
14747    }
14748
14749    #[test]
14750    fn an_endnote_reference_does_not_reserve_space_at_the_page_foot() {
14751        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14752        use rdocx_oxml::text::CT_R;
14753
14754        // The same document twice, once with an endnote reference and once
14755        // with none. An endnote costs its page nothing, so the body must
14756        // paginate identically.
14757        let build = |with_endnote: bool| {
14758            let mut doc = rdocx_oxml::document::CT_Document::new();
14759            for index in 0..60 {
14760                let mut para = CT_P::new();
14761                para.add_run("Body paragraph text that occupies a line of the page.");
14762                if index == 0 && with_endnote {
14763                    let mut end = CT_R::new("");
14764                    end.content = vec![RunContent::EndnoteRef { id: 1 }];
14765                    para.runs.push(end);
14766                }
14767                doc.body.add_paragraph(para);
14768            }
14769            let mut note = CT_P::new();
14770            note.add_run("An endnote that would be tall in the margin.");
14771            LayoutInput {
14772                revision_view: crate::input::RevisionView::Accepted,
14773                automatic_hyphenation: false,
14774                document: doc,
14775                styles: CT_Styles::new_default(),
14776                numbering: None,
14777                headers: HashMap::new(),
14778                footers: HashMap::new(),
14779                images: HashMap::new(),
14780                charts: HashMap::new(),
14781                chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14782                chart_color_map: oxml_drawing::color::ColorMap::default(),
14783                core_properties: None,
14784                hyperlink_urls: HashMap::new(),
14785                footnotes: None,
14786                endnotes: Some(CT_Footnotes {
14787                    footnotes: vec![CT_Footnote {
14788                        id: 1,
14789                        note_type: NoteType::Normal,
14790                        paragraphs: vec![note],
14791                    }],
14792                }),
14793                theme: None,
14794                fonts: Vec::new(),
14795            }
14796        };
14797
14798        let mut engine = Engine::new();
14799        let plain = engine.layout(&build(false)).expect("layout succeeds");
14800        let noted = engine.layout(&build(true)).expect("layout succeeds");
14801
14802        // One extra page for the endnote itself, and no separator anywhere.
14803        assert_eq!(
14804            noted.pages.len(),
14805            plain.pages.len() + 1,
14806            "an endnote adds its own page and takes none from the body"
14807        );
14808        for (index, page) in noted.pages.iter().enumerate() {
14809            if index < plain.pages.len() {
14810                assert!(
14811                    separator_y_of(page).is_none(),
14812                    "page {} reserved foot space for an endnote",
14813                    index + 1
14814                );
14815            }
14816        }
14817
14818        // Body pagination is untouched.
14819        for (index, plain_page) in plain.pages.iter().enumerate() {
14820            let body_lines = |page: &oxml_layout::output::PageFrame| {
14821                compatibility_page_elements(page)
14822                    .into_iter()
14823                    .filter(|element| {
14824                        matches!(element, PositionedElement::Text(run)
14825                            if run.text.starts_with("occupies"))
14826                    })
14827                    .count()
14828            };
14829            assert_eq!(
14830                body_lines(plain_page),
14831                body_lines(&noted.pages[index]),
14832                "page {} holds a different amount of body text",
14833                index + 1
14834            );
14835        }
14836    }
14837
14838    // F-X016, text wrapping around a floating drawing.
14839
14840    /// A document of one long paragraph, with a floating drawing anchored to
14841    /// it. `align` places the drawing, `wrap` says how text should treat it.
14842    fn make_wrapping_document(
14843        wrap: rdocx_oxml::drawing::WrapType,
14844        align: Option<rdocx_oxml::drawing::AnchorAlignH>,
14845        width_pt: f64,
14846        height_pt: f64,
14847        dist_pt: f64,
14848    ) -> LayoutInput {
14849        use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV};
14850        use rdocx_oxml::text::CT_R;
14851        use rdocx_oxml::units::Emu;
14852
14853        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
14854
14855        let mut doc = rdocx_oxml::document::CT_Document::new();
14856        let mut para = CT_P::new();
14857        // Long enough that many lines sit below the drawing, which is what
14858        // makes "returns to the margin" a meaningful assertion.
14859        let mut body = String::new();
14860        for index in 0..40 {
14861            body.push_str(&format!(
14862                "Sentence {index} of running text that fills the paragraph out. "
14863            ));
14864        }
14865        para.add_run(&body);
14866
14867        let mut anchor = CT_Anchor::background("rId1", 0, 0);
14868        anchor.extent_cx = emu(width_pt);
14869        anchor.extent_cy = emu(height_pt);
14870        anchor.behind_doc = false;
14871        anchor.wrap = wrap;
14872        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
14873        anchor.pos_h_align = align;
14874        anchor.pos_v_relative_from = ST_RelativeFromV::Paragraph;
14875        anchor.pos_v_offset = Emu(0);
14876        anchor.dist_t = emu(dist_pt);
14877        anchor.dist_b = emu(dist_pt);
14878        anchor.dist_l = emu(dist_pt);
14879        anchor.dist_r = emu(dist_pt);
14880
14881        let mut drawing_run = CT_R::new("");
14882        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
14883            inline: None,
14884            anchor: Some(anchor),
14885        })];
14886        para.runs.push(drawing_run);
14887        doc.body.add_paragraph(para);
14888
14889        let mut images = HashMap::new();
14890        images.insert(
14891            "rId1".to_string(),
14892            ImageData {
14893                data: vec![0u8; 8],
14894                content_type: "image/png".to_string(),
14895            },
14896        );
14897
14898        LayoutInput {
14899            revision_view: crate::input::RevisionView::Accepted,
14900            automatic_hyphenation: false,
14901            document: doc,
14902            styles: CT_Styles::new_default(),
14903            numbering: None,
14904            headers: HashMap::new(),
14905            footers: HashMap::new(),
14906            images,
14907            charts: HashMap::new(),
14908            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14909            chart_color_map: oxml_drawing::color::ColorMap::default(),
14910            core_properties: None,
14911            hyperlink_urls: HashMap::new(),
14912            footnotes: None,
14913            endnotes: None,
14914            theme: None,
14915            fonts: Vec::new(),
14916        }
14917    }
14918
14919    /// The x origin and right edge of every body text run, by line.
14920    fn text_extents(page: &oxml_layout::output::PageFrame) -> Vec<(f64, f64)> {
14921        let mut by_line: Vec<(f64, f64, f64)> = Vec::new();
14922        for element in compatibility_page_elements(page) {
14923            let PositionedElement::Text(run) = element else {
14924                continue;
14925            };
14926            let right = run.origin.x + run.advances.iter().sum::<f64>();
14927            if let Some(entry) = by_line
14928                .iter_mut()
14929                .find(|(y, _, _)| (*y - run.origin.y).abs() < 0.01)
14930            {
14931                entry.1 = entry.1.min(run.origin.x);
14932                entry.2 = entry.2.max(right);
14933            } else {
14934                by_line.push((run.origin.y, run.origin.x, right));
14935            }
14936        }
14937        by_line.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
14938        by_line.into_iter().map(|(_, l, r)| (l, r)).collect()
14939    }
14940
14941    #[test]
14942    fn text_wraps_beside_a_left_aligned_square_drawing() {
14943        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
14944
14945        let input =
14946            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
14947        let mut engine = Engine::new();
14948        let output = engine.layout(&input).expect("layout succeeds");
14949        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14950        let extents = text_extents(&output.pages[0]);
14951
14952        assert!(
14953            extents.len() > 2,
14954            "the paragraph must wrap, got {extents:?}"
14955        );
14956
14957        // Lines beside the drawing start to its right, past width plus distR.
14958        let expected_left = geometry.margin_left + 100.0 + 5.0;
14959        assert!(
14960            (extents[0].0 - expected_left).abs() < 1.0,
14961            "first line should start at {expected_left}, got {:?}",
14962            extents[0]
14963        );
14964
14965        // A line below the drawing returns to the margin.
14966        let last = extents.last().unwrap();
14967        assert!(
14968            (last.0 - geometry.margin_left).abs() < 1.0,
14969            "the last line should return to the margin, got {last:?}"
14970        );
14971    }
14972
14973    #[test]
14974    fn drawing_reflow_can_select_a_conditional_hyphen() {
14975        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
14976
14977        let mut input =
14978            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 400.0, 40.0, 5.0);
14979        input.automatic_hyphenation = true;
14980        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
14981            panic!("expected paragraph")
14982        };
14983        paragraph.runs[0] = rdocx_oxml::text::CT_R::new("representation representation");
14984        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
14985            language: Some("en-US".to_owned()),
14986            ..Default::default()
14987        });
14988
14989        let text = output_text(&deterministic_layout(&input));
14990        assert!(text.iter().any(|item| item == "-"), "{text:?}");
14991    }
14992
14993    #[test]
14994    fn drawing_reflow_retains_the_exact_word_rich_baseline() {
14995        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
14996
14997        let mut input =
14998            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
14999        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
15000            panic!("expected paragraph")
15001        };
15002        paragraph.properties = Some(CT_PPr {
15003            line_spacing: Some(rdocx_oxml::units::Twips(480)),
15004            line_rule: Some("exact".to_owned()),
15005            ..Default::default()
15006        });
15007        paragraph.runs[0] = rdocx_oxml::text::CT_R::new(&"العربية مرحبا بالعالم ".repeat(12));
15008        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
15009            sz: Some(rdocx_oxml::units::HalfPoint(48)),
15010            language: Some("ar-SA".to_owned()),
15011            language_bidi: Some("ar-SA".to_owned()),
15012            ..Default::default()
15013        });
15014
15015        let output = deterministic_layout(&input);
15016        let first = multilingual_runs(&output)
15017            .into_iter()
15018            .min_by(|left, right| left.origin.y.total_cmp(&right.origin.y))
15019            .expect("wrapped paragraph emits rich text");
15020        assert!(
15021            (first.origin.y - 91.2).abs() < 0.001,
15022            "wrapped rich baseline was {}, expected 91.2",
15023            first.origin.y
15024        );
15025    }
15026
15027    #[test]
15028    fn drawing_reflow_retains_the_explicit_ltr_paragraph_base() {
15029        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
15030
15031        let mut input =
15032            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
15033        input.automatic_hyphenation = true;
15034        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
15035            panic!("expected paragraph")
15036        };
15037        paragraph.properties = Some(CT_PPr {
15038            bidi: Some(false),
15039            ..Default::default()
15040        });
15041        let drawing = paragraph.runs.pop().expect("wrapping drawing run");
15042        let mut arabic = CT_R::new("العربية ");
15043        arabic.properties = Some(CT_RPr {
15044            language_bidi: Some("ar-SA".to_owned()),
15045            ..Default::default()
15046        });
15047        let mut english = CT_R::new(&"representation ".repeat(12));
15048        english.properties = Some(CT_RPr {
15049            language: Some("en-US".to_owned()),
15050            ..Default::default()
15051        });
15052        paragraph.runs = vec![arabic, english, drawing];
15053
15054        let output = deterministic_layout(&input);
15055        let arabic = multilingual_runs(&output)
15056            .into_iter()
15057            .min_by(|left, right| left.origin.y.total_cmp(&right.origin.y))
15058            .expect("Arabic rich run");
15059        let english = output
15060            .pages
15061            .iter()
15062            .flat_map(|page| compatibility_page_elements(page))
15063            .filter_map(|element| match element {
15064                PositionedElement::Text(run) if run.text.contains("repre") => Some(run),
15065                _ => None,
15066            })
15067            .filter(|run| (run.origin.y - arabic.origin.y).abs() < 0.001)
15068            .min_by(|left, right| left.origin.x.total_cmp(&right.origin.x))
15069            .expect("hyphenatable English shares the first line");
15070        assert!(
15071            arabic.origin.x < english.origin.x,
15072            "explicit LTR must survive drawing reflow: Arabic {}, English {}",
15073            arabic.origin.x,
15074            english.origin.x
15075        );
15076    }
15077
15078    #[test]
15079    fn text_wraps_beside_a_right_aligned_square_drawing() {
15080        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
15081
15082        let input = make_wrapping_document(
15083            WrapType::Square,
15084            Some(AnchorAlignH::Right),
15085            100.0,
15086            40.0,
15087            5.0,
15088        );
15089        let mut engine = Engine::new();
15090        let output = engine.layout(&input).expect("layout succeeds");
15091        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15092        let extents = text_extents(&output.pages[0]);
15093
15094        assert!(
15095            extents.len() > 2,
15096            "the paragraph must wrap, got {extents:?}"
15097        );
15098
15099        // Lines beside the drawing still start at the margin but end early.
15100        let text_right = geometry.page_width - geometry.margin_right;
15101        let drawing_left = text_right - 100.0;
15102        assert!(
15103            (extents[0].0 - geometry.margin_left).abs() < 1.0,
15104            "a right-aligned drawing does not move the line start, got {:?}",
15105            extents[0]
15106        );
15107        assert!(
15108            extents[0].1 <= drawing_left - 5.0 + 1.0,
15109            "the first line should stop before the drawing at {}, got {:?}",
15110            drawing_left - 5.0,
15111            extents[0]
15112        );
15113
15114        // Some line below the drawing runs past where the drawing sat, which
15115        // is only possible once the reservation stops applying. The final line
15116        // of a paragraph is naturally short, so the widest is the fair test.
15117        let widest = extents
15118            .iter()
15119            .map(|(_, right)| *right)
15120            .fold(f64::MIN, f64::max);
15121        assert!(
15122            widest > drawing_left,
15123            "a line below the drawing should reach past {drawing_left}, got {extents:?}"
15124        );
15125    }
15126
15127    #[test]
15128    fn a_top_and_bottom_drawing_pushes_text_below_it() {
15129        use rdocx_oxml::drawing::WrapType;
15130
15131        let input = make_wrapping_document(WrapType::TopAndBottom, None, 100.0, 40.0, 5.0);
15132        let mut engine = Engine::new();
15133        let output = engine.layout(&input).expect("layout succeeds");
15134        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15135        let extents = text_extents(&output.pages[0]);
15136
15137        assert!(!extents.is_empty(), "the paragraph renders");
15138
15139        // The drawing sits at the paragraph top, so text starts below its
15140        // bottom edge plus distB.
15141        let first_baseline = compatibility_page_elements(&output.pages[0])
15142            .into_iter()
15143            .find_map(|element| match element {
15144                PositionedElement::Text(run) => Some(run.origin.y),
15145                _ => None,
15146            })
15147            .expect("text is rendered");
15148        let drawing_bottom = geometry.margin_top + 40.0 + 5.0;
15149        assert!(
15150            first_baseline >= drawing_bottom,
15151            "the first line at {first_baseline} should sit below {drawing_bottom}"
15152        );
15153    }
15154
15155    #[test]
15156    fn a_wrap_none_drawing_leaves_text_untouched() {
15157        use rdocx_oxml::drawing::WrapType;
15158
15159        // The identity case. A drawing that does not wrap must not move a
15160        // single glyph, which is what keeps every recorded baseline still.
15161        let with = make_wrapping_document(WrapType::None, None, 100.0, 40.0, 5.0);
15162        let mut engine = Engine::new();
15163        let output = engine.layout(&with).expect("layout succeeds");
15164        let wrapped_extents = text_extents(&output.pages[0]);
15165
15166        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15167        for (left, _) in &wrapped_extents {
15168            assert!(
15169                (left - geometry.margin_left).abs() < 0.01,
15170                "a wrapNone drawing must not indent any line, got {wrapped_extents:?}"
15171            );
15172        }
15173    }
15174
15175    #[test]
15176    fn a_drawing_anchored_to_a_later_paragraph_still_pushes_text_aside() {
15177        use rdocx_oxml::drawing::{
15178            AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
15179            WrapType,
15180        };
15181        use rdocx_oxml::text::CT_R;
15182        use rdocx_oxml::units::Emu;
15183
15184        // Word routinely anchors the arrow beside a paragraph to the paragraph
15185        // after it, which is what the external contribution's own sample does.
15186        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
15187        let mut doc = rdocx_oxml::document::CT_Document::new();
15188
15189        let mut first = CT_P::new();
15190        let mut body = String::new();
15191        for index in 0..40 {
15192            body.push_str(&format!("Sentence {index} of running text to fill lines. "));
15193        }
15194        first.add_run(&body);
15195        doc.body.add_paragraph(first);
15196
15197        let mut second = CT_P::new();
15198        second.add_run("A later paragraph that owns the drawing.");
15199        let mut anchor = CT_Anchor::background("rId1", 0, 0);
15200        anchor.extent_cx = emu(100.0);
15201        anchor.extent_cy = emu(40.0);
15202        anchor.behind_doc = false;
15203        anchor.wrap = WrapType::Square;
15204        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
15205        anchor.pos_h_align = Some(AnchorAlignH::Left);
15206        // Margin-relative, so its position does not depend on where the
15207        // paragraph that owns it lands.
15208        anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
15209        anchor.pos_v_align = Some(AnchorAlignV::Top);
15210        let mut drawing_run = CT_R::new("");
15211        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
15212            inline: None,
15213            anchor: Some(anchor),
15214        })];
15215        second.runs.push(drawing_run);
15216        doc.body.add_paragraph(second);
15217
15218        let mut images = HashMap::new();
15219        images.insert(
15220            "rId1".to_string(),
15221            ImageData {
15222                data: vec![0u8; 8],
15223                content_type: "image/png".to_string(),
15224            },
15225        );
15226
15227        let input = LayoutInput {
15228            revision_view: crate::input::RevisionView::Accepted,
15229            automatic_hyphenation: false,
15230            document: doc,
15231            styles: CT_Styles::new_default(),
15232            numbering: None,
15233            headers: HashMap::new(),
15234            footers: HashMap::new(),
15235            images,
15236            charts: HashMap::new(),
15237            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15238            chart_color_map: oxml_drawing::color::ColorMap::default(),
15239            core_properties: None,
15240            hyperlink_urls: HashMap::new(),
15241            footnotes: None,
15242            endnotes: None,
15243            theme: None,
15244            fonts: Vec::new(),
15245        };
15246
15247        let mut engine = Engine::new();
15248        let output = engine.layout(&input).expect("layout succeeds");
15249        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15250        let extents = text_extents(&output.pages[0]);
15251
15252        assert!(!extents.is_empty(), "text renders");
15253        let expected_left = geometry.margin_left + 100.0;
15254        assert!(
15255            extents[0].0 >= expected_left - 1.0,
15256            "the first line of the earlier paragraph should clear the drawing at \
15257             {expected_left}, got {:?}",
15258            extents[0]
15259        );
15260    }
15261
15262    #[test]
15263    fn a_split_paragraph_clearing_a_drawing_stays_inside_the_page() {
15264        use rdocx_oxml::drawing::{
15265            AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
15266            WrapType,
15267        };
15268        use rdocx_oxml::text::CT_R;
15269        use rdocx_oxml::units::Emu;
15270
15271        // A top-and-bottom drawing pushes the paragraph's content down, and the
15272        // paragraph is long enough to split. The offset has to be counted where
15273        // the split point is decided, or the last lines run off the page.
15274        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
15275        let mut doc = rdocx_oxml::document::CT_Document::new();
15276        let mut para = CT_P::new();
15277        let mut body = String::new();
15278        for index in 0..300 {
15279            body.push_str(&format!("Sentence {index} of a very long paragraph. "));
15280        }
15281        para.add_run(&body);
15282
15283        let mut anchor = CT_Anchor::background("rId1", 0, 0);
15284        anchor.extent_cx = emu(200.0);
15285        anchor.extent_cy = emu(120.0);
15286        anchor.behind_doc = false;
15287        anchor.wrap = WrapType::TopAndBottom;
15288        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
15289        anchor.pos_h_align = Some(AnchorAlignH::Center);
15290        anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
15291        anchor.pos_v_align = Some(AnchorAlignV::Top);
15292        anchor.dist_b = emu(10.0);
15293        let mut drawing_run = CT_R::new("");
15294        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
15295            inline: None,
15296            anchor: Some(anchor),
15297        })];
15298        para.runs.push(drawing_run);
15299        doc.body.add_paragraph(para);
15300
15301        let mut images = HashMap::new();
15302        images.insert(
15303            "rId1".to_string(),
15304            ImageData {
15305                data: vec![0u8; 8],
15306                content_type: "image/png".to_string(),
15307            },
15308        );
15309
15310        let input = LayoutInput {
15311            revision_view: crate::input::RevisionView::Accepted,
15312            automatic_hyphenation: false,
15313            document: doc,
15314            styles: CT_Styles::new_default(),
15315            numbering: None,
15316            headers: HashMap::new(),
15317            footers: HashMap::new(),
15318            images,
15319            charts: HashMap::new(),
15320            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15321            chart_color_map: oxml_drawing::color::ColorMap::default(),
15322            core_properties: None,
15323            hyperlink_urls: HashMap::new(),
15324            footnotes: None,
15325            endnotes: None,
15326            theme: None,
15327            fonts: Vec::new(),
15328        };
15329
15330        let mut engine = Engine::new();
15331        let output = engine.layout(&input).expect("layout succeeds");
15332        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15333        let bottom = geometry.page_height - geometry.margin_bottom;
15334
15335        assert!(output.pages.len() > 1, "the paragraph must split");
15336        for (index, page) in output.pages.iter().enumerate() {
15337            for element in compatibility_page_elements(page) {
15338                let PositionedElement::Text(run) = element else {
15339                    continue;
15340                };
15341                assert!(
15342                    run.origin.y <= bottom + 0.5,
15343                    "page {} draws text at {}, past the bottom margin at {bottom}",
15344                    index + 1,
15345                    run.origin.y
15346                );
15347            }
15348        }
15349    }
15350
15351    // F-X017, notes broken to their own section's width.
15352
15353    /// Text long enough to wrap at either measure under test, so a change of
15354    /// measure changes the number of lines rather than nothing at all.
15355    const NOTE_PROSE: &str = "A note long enough that the measure it is broken \
15356        to decides how many lines it occupies, which is the whole point of \
15357        breaking it to the width of the section that references it rather than \
15358        to the width of whichever section happens to come last in the document.";
15359
15360    /// A document whose first section is `first_page_width` twips wide and
15361    /// whose body-level final section is letter portrait. The first section
15362    /// references note 1 and the second references note 2, and both notes carry
15363    /// the same text, so a difference in their line counts is a difference in
15364    /// the measure each was broken to.
15365    fn make_two_section_input(first_page_width: i32, endnotes_instead: bool) -> LayoutInput {
15366        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
15367        use rdocx_oxml::text::CT_R;
15368        use rdocx_oxml::units::Twips;
15369
15370        let note_of = |id: i32| {
15371            let mut note = CT_P::new();
15372            note.add_run(NOTE_PROSE);
15373            CT_Footnote {
15374                id,
15375                note_type: NoteType::Normal,
15376                paragraphs: vec![note],
15377            }
15378        };
15379        let reference = |id: i32| {
15380            let mut run = CT_R::new("");
15381            run.content = vec![if endnotes_instead {
15382                RunContent::EndnoteRef { id }
15383            } else {
15384                RunContent::FootnoteRef { id }
15385            }];
15386            run
15387        };
15388
15389        let mut first_sect = CT_SectPr::default_letter();
15390        first_sect.page_width = Some(Twips(first_page_width));
15391
15392        let mut doc = rdocx_oxml::document::CT_Document::new();
15393
15394        // The paragraph carrying a sectPr is the one that ends its section.
15395        let mut first = CT_P::new();
15396        first.add_run("Body text in the first section");
15397        first.runs.push(reference(1));
15398        first.properties = Some(rdocx_oxml::properties::CT_PPr {
15399            sect_pr: Some(first_sect),
15400            ..Default::default()
15401        });
15402        doc.body.add_paragraph(first);
15403
15404        let mut second = CT_P::new();
15405        second.add_run("Body text in the second section");
15406        second.runs.push(reference(2));
15407        doc.body.add_paragraph(second);
15408        doc.body.sect_pr = Some(CT_SectPr::default_letter());
15409
15410        let stream = CT_Footnotes {
15411            footnotes: vec![note_of(1), note_of(2)],
15412        };
15413        LayoutInput {
15414            revision_view: crate::input::RevisionView::Accepted,
15415            automatic_hyphenation: false,
15416            document: doc,
15417            styles: CT_Styles::new_default(),
15418            numbering: None,
15419            headers: HashMap::new(),
15420            footers: HashMap::new(),
15421            images: HashMap::new(),
15422            charts: HashMap::new(),
15423            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15424            chart_color_map: oxml_drawing::color::ColorMap::default(),
15425            core_properties: None,
15426            hyperlink_urls: HashMap::new(),
15427            footnotes: (!endnotes_instead).then(|| stream.clone()),
15428            endnotes: endnotes_instead.then_some(stream),
15429            theme: None,
15430            fonts: Vec::new(),
15431        }
15432    }
15433
15434    /// How many distinct baselines a page drew below its separator rule. A page
15435    /// without notes gives zero.
15436    ///
15437    /// This is the note's line count plus one for each note drawn, because a
15438    /// marker sits a rise above the line it belongs to and so has a baseline of
15439    /// its own. Every use below compares two of these counts over documents
15440    /// drawing the same number of notes, where the offset cancels.
15441    fn note_baseline_count(page: &oxml_layout::output::PageFrame) -> usize {
15442        let Some(separator_y) = separator_y_of(page) else {
15443            return 0;
15444        };
15445        let mut baselines: Vec<f64> = compatibility_page_elements(page)
15446            .into_iter()
15447            .filter_map(|element| match element {
15448                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.y),
15449                _ => None,
15450            })
15451            .collect();
15452        baselines.sort_by(|a, b| a.partial_cmp(b).expect("baselines are finite"));
15453        baselines.dedup_by(|a, b| (*a - *b).abs() < 0.01);
15454        baselines.len()
15455    }
15456
15457    #[test]
15458    fn a_note_is_broken_to_the_width_of_its_own_section() {
15459        // 17 inches wide against letter's 8.5, so the wide section's measure is
15460        // unmistakably different rather than different by a rounding.
15461        let output = Engine::new()
15462            .layout(&make_two_section_input(24480, false))
15463            .expect("layout succeeds");
15464
15465        let wide = note_baseline_count(&output.pages[0]);
15466        let narrow = note_baseline_count(&output.pages[1]);
15467
15468        assert!(wide > 0 && narrow > 0, "both sections must draw their note");
15469        assert!(
15470            wide < narrow,
15471            "the same note took {wide} lines in the wide section and {narrow} \
15472             in the narrow one, so both were broken to one measure"
15473        );
15474    }
15475
15476    #[test]
15477    fn a_single_section_document_lays_notes_out_exactly_as_before() {
15478        // Two sections of identical geometry are the same document as one, so
15479        // the width key must collapse them. Any difference here is the fix
15480        // moving output it had no business moving.
15481        let two = Engine::new()
15482            .layout(&make_two_section_input(12240, false))
15483            .expect("layout succeeds");
15484
15485        let single = Engine::new()
15486            .layout(&make_input_with_footnote(&[NOTE_PROSE]))
15487            .expect("layout succeeds");
15488
15489        assert_eq!(
15490            note_baseline_count(&two.pages[0]),
15491            note_baseline_count(&single.pages[0]),
15492            "a note in a letter section stopped matching the same note in a \
15493             single-section letter document"
15494        );
15495
15496        // And the same document laid out twice is still the same document.
15497        let again = Engine::new()
15498            .layout(&make_input_with_footnote(&[NOTE_PROSE]))
15499            .expect("layout succeeds");
15500        assert_eq!(single.pages.len(), again.pages.len());
15501        assert_eq!(single.pages[0].elements, again.pages[0].elements);
15502    }
15503
15504    #[test]
15505    fn an_endnote_is_broken_to_the_final_sections_width() {
15506        // Endnotes are emitted after the last body page and drawn against the
15507        // final section's geometry, so that is the measure they must be broken
15508        // to even when the reference sits in a wider section.
15509        let wide_first = Engine::new()
15510            .layout(&make_two_section_input(24480, true))
15511            .expect("layout succeeds");
15512        let all_narrow = Engine::new()
15513            .layout(&make_two_section_input(12240, true))
15514            .expect("layout succeeds");
15515
15516        // Endnotes are emitted on their own pages after every body page, and
15517        // this document has one short paragraph per section, so everything
15518        // drawn after the second page is endnote content.
15519        let endnote_lines = |output: &LayoutResult| {
15520            output.pages[2..]
15521                .iter()
15522                .map(|page| {
15523                    compatibility_page_elements(page)
15524                        .into_iter()
15525                        .filter(|element| matches!(element, PositionedElement::Text(_)))
15526                        .count()
15527                })
15528                .sum::<usize>()
15529        };
15530
15531        assert_eq!(wide_first.pages.len(), all_narrow.pages.len());
15532        assert!(
15533            all_narrow.pages.len() > 2,
15534            "the endnotes must reach pages of their own"
15535        );
15536        assert!(endnote_lines(&all_narrow) > 0, "the endnotes must be drawn");
15537        assert_eq!(
15538            endnote_lines(&wide_first),
15539            endnote_lines(&all_narrow),
15540            "an endnote whose reference sits in a wide section was broken to \
15541             that section rather than to the final one it is drawn in"
15542        );
15543    }
15544
15545    // F-X019, paragraph-relative drawings in later blocks should wrap.
15546
15547    /// Two paragraphs, the second anchoring a wrapping drawing measured from
15548    /// `rel_v`. The first paragraph is the earlier text that should flow around
15549    /// it, which is the whole question: the drawing belongs to a block that has
15550    /// not been placed when the first paragraph is being laid out.
15551    fn make_lookahead_document(
15552        rel_v: rdocx_oxml::drawing::ST_RelativeFromV,
15553        wrap: rdocx_oxml::drawing::WrapType,
15554        off_v_pt: f64,
15555    ) -> LayoutInput {
15556        use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH};
15557        use rdocx_oxml::text::CT_R;
15558        use rdocx_oxml::units::Emu;
15559
15560        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
15561
15562        let mut doc = rdocx_oxml::document::CT_Document::new();
15563
15564        let mut first = CT_P::new();
15565        let mut body = String::new();
15566        for index in 0..30 {
15567            body.push_str(&format!(
15568                "Sentence {index} of running text that fills the paragraph out. "
15569            ));
15570        }
15571        first.add_run(&body);
15572        doc.body.add_paragraph(first);
15573
15574        let mut second = CT_P::new();
15575        second.add_run("The paragraph the drawing is anchored to.");
15576        let mut anchor = CT_Anchor::background("rId1", 0, 0);
15577        anchor.extent_cx = emu(200.0);
15578        anchor.extent_cy = emu(120.0);
15579        anchor.behind_doc = false;
15580        anchor.wrap = wrap;
15581        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
15582        anchor.pos_h_align = Some(rdocx_oxml::drawing::AnchorAlignH::Right);
15583        anchor.pos_v_relative_from = rel_v;
15584        // The offset is measured from `rel_v`, so the two cases need different
15585        // numbers to land in the same band of the page. Above its own
15586        // paragraph for the paragraph-relative case, and a fixed way down the
15587        // page for the page-relative one. A drawing that lands below every line
15588        // of the first paragraph pushes nothing aside and would prove nothing.
15589        anchor.pos_v_offset = emu(off_v_pt);
15590
15591        let mut drawing_run = CT_R::new("");
15592        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
15593            inline: None,
15594            anchor: Some(anchor),
15595        })];
15596        second.runs.push(drawing_run);
15597        doc.body.add_paragraph(second);
15598
15599        let mut images = HashMap::new();
15600        images.insert(
15601            "rId1".to_string(),
15602            ImageData {
15603                data: vec![0u8; 8],
15604                content_type: "image/png".to_string(),
15605            },
15606        );
15607
15608        LayoutInput {
15609            revision_view: crate::input::RevisionView::Accepted,
15610            automatic_hyphenation: false,
15611            document: doc,
15612            styles: CT_Styles::new_default(),
15613            numbering: None,
15614            headers: HashMap::new(),
15615            footers: HashMap::new(),
15616            images,
15617            charts: HashMap::new(),
15618            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15619            chart_color_map: oxml_drawing::color::ColorMap::default(),
15620            core_properties: None,
15621            hyperlink_urls: HashMap::new(),
15622            footnotes: None,
15623            endnotes: None,
15624            theme: None,
15625            fonts: Vec::new(),
15626        }
15627    }
15628
15629    /// How many lines of body text the document drew, across every page.
15630    fn body_line_count(output: &LayoutResult) -> usize {
15631        output
15632            .pages
15633            .iter()
15634            .map(|page| text_extents(page).len())
15635            .sum()
15636    }
15637
15638    #[test]
15639    fn a_paragraph_relative_wrapping_drawing_pushes_earlier_text_aside() {
15640        use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
15641
15642        // The same document twice, differing only in whether the drawing
15643        // wraps. Narrowed lines hold less text, so the paragraph needs more of
15644        // them, and that is visible without depending on where any one line
15645        // broke.
15646        let wrapping = Engine::new()
15647            .layout(&make_lookahead_document(
15648                ST_RelativeFromV::Paragraph,
15649                WrapType::Square,
15650                -120.0,
15651            ))
15652            .expect("layout succeeds");
15653        let ignoring = Engine::new()
15654            .layout(&make_lookahead_document(
15655                ST_RelativeFromV::Paragraph,
15656                WrapType::None,
15657                -120.0,
15658            ))
15659            .expect("layout succeeds");
15660
15661        assert!(
15662            body_line_count(&wrapping) > body_line_count(&ignoring),
15663            "the earlier paragraph took {} lines against {}, so it flowed \
15664             through the drawing rather than around it",
15665            body_line_count(&wrapping),
15666            body_line_count(&ignoring)
15667        );
15668    }
15669
15670    #[test]
15671    fn a_page_relative_drawing_in_a_later_block_still_wraps() {
15672        use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
15673
15674        // F-X016's case, which the second pass must not disturb. This document
15675        // has no paragraph-relative wrap, so it paginates in one pass.
15676        let wrapping = Engine::new()
15677            .layout(&make_lookahead_document(
15678                ST_RelativeFromV::Page,
15679                WrapType::Square,
15680                150.0,
15681            ))
15682            .expect("layout succeeds");
15683        let ignoring = Engine::new()
15684            .layout(&make_lookahead_document(
15685                ST_RelativeFromV::Page,
15686                WrapType::None,
15687                150.0,
15688            ))
15689            .expect("layout succeeds");
15690
15691        assert!(body_line_count(&wrapping) > body_line_count(&ignoring));
15692    }
15693
15694    #[test]
15695    fn a_second_pass_is_stable_for_the_document_that_earns_it() {
15696        use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
15697
15698        // Two passes, not a fixed point, so the guarantee is that the answer is
15699        // the same answer every time rather than that it has converged.
15700        let build = || {
15701            Engine::new()
15702                .layout(&make_lookahead_document(
15703                    ST_RelativeFromV::Paragraph,
15704                    WrapType::Square,
15705                    -120.0,
15706                ))
15707                .expect("layout succeeds")
15708        };
15709        let first = build();
15710        let second = build();
15711
15712        assert_eq!(first.pages.len(), second.pages.len());
15713        for (index, page) in first.pages.iter().enumerate() {
15714            assert_eq!(
15715                page.elements,
15716                second.pages[index].elements,
15717                "page {} differs between two runs",
15718                index + 1
15719            );
15720        }
15721    }
15722
15723    fn cross_reference_run(instruction: &str, display: &str) -> rdocx_oxml::text::CT_R {
15724        let mut run = rdocx_oxml::text::CT_R::new("");
15725        run.content = vec![RunContent::Field(Field::new(instruction, display))];
15726        run
15727    }
15728
15729    fn target_paragraph(targets: &[(i32, &str, usize, usize)], text: &str, hidden: bool) -> CT_P {
15730        let mut paragraph = CT_P::new();
15731        paragraph.properties = Some(CT_PPr {
15732            page_break_before: Some(true),
15733            ..Default::default()
15734        });
15735        let mut run = rdocx_oxml::text::CT_R::new(text);
15736        if hidden {
15737            run.properties = Some(rdocx_oxml::properties::CT_RPr {
15738                vanish: Some(true),
15739                ..Default::default()
15740            });
15741        }
15742        paragraph.runs.push(run);
15743        for (id, name, start, end) in targets {
15744            assert!(paragraph.insert_bookmark_start(*start, *id, name));
15745            assert!(paragraph.insert_bookmark_end(*end, *id));
15746        }
15747        paragraph
15748    }
15749
15750    fn output_text(output: &LayoutResult) -> Vec<String> {
15751        let mut text = Vec::new();
15752        for page in &output.pages {
15753            oxml_layout::walk(&page.elements, &mut |element, _| {
15754                if let PositionedElement::Text(run) = element {
15755                    text.push(run.text.clone());
15756                }
15757            });
15758        }
15759        text
15760    }
15761
15762    fn deterministic_layout(input: &LayoutInput) -> LayoutResult {
15763        Engine::new_deterministic()
15764            .expect("bundled fonts")
15765            .layout(input)
15766            .expect("layout succeeds")
15767    }
15768
15769    #[test]
15770    fn an_unsupported_complex_field_keeps_its_cached_display() {
15771        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>"#;
15772        let mut input = make_input_with_text("");
15773        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
15774
15775        let text = output_text(&deterministic_layout(&input));
15776        assert!(text.concat().contains("17 August 2026"), "{text:?}");
15777    }
15778
15779    #[test]
15780    fn a_complex_field_keeps_each_cached_result_runs_formatting() {
15781        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>"#;
15782        let mut input = make_input_with_text("");
15783        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
15784
15785        let output = deterministic_layout(&input);
15786        let mut displays = Vec::new();
15787        for page in &output.pages {
15788            oxml_layout::walk(&page.elements, &mut |element, _| {
15789                if let PositionedElement::Text(run) = element
15790                    && matches!(run.text.as_str(), "bold" | "italic")
15791                {
15792                    displays.push((run.text.clone(), run.bold, run.italic));
15793                }
15794            });
15795        }
15796        assert_eq!(
15797            displays,
15798            vec![
15799                ("bold".to_owned(), true, false),
15800                ("italic".to_owned(), false, true)
15801            ]
15802        );
15803    }
15804
15805    #[test]
15806    fn a_computed_complex_field_keeps_its_cached_result_run_formatting() {
15807        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>"#;
15808        let mut input = make_input_with_text("");
15809        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
15810        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
15811            panic!("expected paragraph")
15812        };
15813        let RunContent::Field(field) = &mut paragraph.runs[0].content[0] else {
15814            panic!("expected field")
15815        };
15816        field.cached_result = "edited stored value".to_owned();
15817
15818        let output = deterministic_layout(&input);
15819        let mut displays = Vec::new();
15820        for page in &output.pages {
15821            oxml_layout::walk(&page.elements, &mut |element, _| {
15822                if let PositionedElement::Text(run) = element
15823                    && run.text == "1"
15824                {
15825                    displays.push((run.bold, run.italic));
15826                }
15827            });
15828        }
15829        assert_eq!(displays, vec![(true, true)]);
15830    }
15831
15832    #[test]
15833    fn a_pageref_inside_a_table_uses_the_final_target_page() {
15834        use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
15835
15836        let mut input = make_input_with_text("");
15837        input.document.body.content.clear();
15838        let mut field = CT_P::new();
15839        field
15840            .runs
15841            .push(cross_reference_run("PAGEREF destination", "cached"));
15842        let mut cell = CT_Tc::new();
15843        cell.content = vec![CellContent::Paragraph(field)];
15844        let mut row = CT_Row::new();
15845        row.cells.push(cell);
15846        let mut table = CT_Tbl::new();
15847        table.rows.push(row);
15848        input.document.body.content.push(BodyContent::Table(table));
15849        input.document.body.add_paragraph(target_paragraph(
15850            &[(4, "destination", 0, 1)],
15851            "target",
15852            false,
15853        ));
15854
15855        let output = deterministic_layout(&input);
15856        let text = output_text(&output);
15857        assert!(text.iter().any(|value| value == "2"), "{text:?}");
15858        assert!(!text.iter().any(|value| value == "cached"), "{text:?}");
15859    }
15860
15861    #[test]
15862    fn a_resolved_pageref_uses_a_fixed_pagination_placeholder() {
15863        let build = |display: &str| {
15864            let mut input = make_input_with_text("");
15865            input.document.body.content.clear();
15866            let mut field = CT_P::new();
15867            field
15868                .runs
15869                .push(cross_reference_run("PAGEREF destination", display));
15870            input.document.body.add_paragraph(field);
15871            input.document.body.add_paragraph(target_paragraph(
15872                &[(4, "destination", 0, 1)],
15873                "target",
15874                false,
15875            ));
15876            deterministic_layout(&input)
15877        };
15878        let short = build("7");
15879        let long = build(&"stale display ".repeat(1000));
15880
15881        assert_eq!(short.pages.len(), long.pages.len());
15882        assert_eq!(output_text(&short), output_text(&long));
15883    }
15884
15885    #[test]
15886    fn every_target_at_a_paragraph_end_is_retained() {
15887        let mut input = make_input_with_text("");
15888        input.document.body.content.clear();
15889        let mut fields = CT_P::new();
15890        for name in ["first", "second"] {
15891            fields
15892                .runs
15893                .push(cross_reference_run(&format!("PAGEREF {name}"), "cached"));
15894        }
15895        input.document.body.add_paragraph(fields);
15896        input.document.body.add_paragraph(target_paragraph(
15897            &[(4, "first", 1, 1), (5, "second", 1, 1)],
15898            "target",
15899            false,
15900        ));
15901
15902        let text = output_text(&deterministic_layout(&input));
15903        assert_eq!(
15904            text.iter().filter(|value| value.as_str() == "2").count(),
15905            2,
15906            "{text:?}"
15907        );
15908    }
15909
15910    #[test]
15911    fn a_target_before_hidden_text_is_retained() {
15912        let mut input = make_input_with_text("");
15913        input.document.body.content.clear();
15914        let mut field = CT_P::new();
15915        field
15916            .runs
15917            .push(cross_reference_run("PAGEREF destination", "cached"));
15918        input.document.body.add_paragraph(field);
15919        input.document.body.add_paragraph(target_paragraph(
15920            &[(4, "destination", 0, 1)],
15921            "hidden target",
15922            true,
15923        ));
15924
15925        let text = output_text(&deterministic_layout(&input));
15926        assert!(text.iter().any(|value| value == "2"), "{text:?}");
15927        assert!(!text.iter().any(|value| value == "cached"), "{text:?}");
15928    }
15929}