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
76enum MainStoryLayoutItem<'a> {
77    Paragraph(&'a CT_P, Vec<usize>),
78    Table(&'a CT_Tbl, Vec<usize>),
79}
80
81#[derive(Clone, Copy)]
82enum BlockControlOwner {
83    Body,
84    Table,
85    Row,
86    Cell,
87}
88
89fn main_story_layout_items(document: &CT_Document) -> Vec<MainStoryLayoutItem<'_>> {
90    let mut items = Vec::new();
91    for (body_index, content) in document.body.content.iter().enumerate() {
92        let path = vec![body_index];
93        match content {
94            BodyContent::Paragraph(paragraph) => {
95                items.push(MainStoryLayoutItem::Paragraph(paragraph, path))
96            }
97            BodyContent::Table(table) => items.push(MainStoryLayoutItem::Table(table, path)),
98            BodyContent::ContentControl(control) => {
99                collect_body_control_layout_items(control, &path, &mut items)
100            }
101            BodyContent::RawXml(_) => {}
102        }
103    }
104    items
105}
106
107fn collect_body_control_layout_items<'a>(
108    control: &'a CT_Sdt,
109    path: &[usize],
110    items: &mut Vec<MainStoryLayoutItem<'a>>,
111) {
112    for (content_index, content) in control.content.iter().enumerate() {
113        let mut content_path = path.to_vec();
114        content_path.push(content_index);
115        match content {
116            SdtContent::Paragraph(paragraph) => {
117                items.push(MainStoryLayoutItem::Paragraph(paragraph, content_path))
118            }
119            SdtContent::Table(table) => items.push(MainStoryLayoutItem::Table(table, content_path)),
120            SdtContent::ContentControl(control) => {
121                collect_body_control_layout_items(control, &content_path, items)
122            }
123            SdtContent::Row(_)
124            | SdtContent::Cell(_)
125            | SdtContent::Run(_)
126            | SdtContent::RawXml(_) => {}
127        }
128    }
129}
130
131/// Immutable source identities allocated once before layout starts.
132pub(crate) struct SourceRegistry {
133    nodes: Vec<WordSourcePath>,
134    ids: HashMap<WordSourcePath, SourceNodeId>,
135    body_ids: Vec<Option<SourceNodeId>>,
136}
137
138impl SourceRegistry {
139    fn for_input(input: &LayoutInput) -> Self {
140        let mut registry = Self {
141            nodes: Vec::new(),
142            ids: HashMap::new(),
143            body_ids: Vec::with_capacity(input.document.body.content.len()),
144        };
145
146        for (body_index, content) in input.document.body.content.iter().enumerate() {
147            match content {
148                BodyContent::Paragraph(_) => {
149                    let id = registry.insert_node(WordSourcePath {
150                        story: WordStory::Document,
151                        children: vec![body_index],
152                    });
153                    registry.body_ids.push(Some(id));
154                }
155                BodyContent::Table(table) => {
156                    registry.body_ids.push(None);
157                    registry.collect_table(table, &WordStory::Document, &[body_index])
158                }
159                BodyContent::ContentControl(control) => {
160                    registry.body_ids.push(None);
161                    let mut items = Vec::new();
162                    collect_body_control_layout_items(control, &[body_index], &mut items);
163                    for item in items {
164                        match item {
165                            MainStoryLayoutItem::Paragraph(_, children) => {
166                                registry.insert(WordSourcePath {
167                                    story: WordStory::Document,
168                                    children,
169                                });
170                            }
171                            MainStoryLayoutItem::Table(table, children) => {
172                                registry.collect_table(table, &WordStory::Document, &children)
173                            }
174                        }
175                    }
176                }
177                BodyContent::RawXml(_) => {
178                    registry.body_ids.push(None);
179                }
180            }
181        }
182
183        let mut headers = input.headers.iter().collect::<Vec<_>>();
184        headers.sort_unstable_by_key(|(relationship_id, _)| *relationship_id);
185        for (relationship_id, header) in headers {
186            let story = WordStory::Header {
187                relationship_id: relationship_id.clone(),
188            };
189            for paragraph_index in 0..header.paragraphs.len() {
190                registry.insert(WordSourcePath {
191                    story: story.clone(),
192                    children: vec![paragraph_index],
193                });
194            }
195        }
196
197        let mut footers = input.footers.iter().collect::<Vec<_>>();
198        footers.sort_unstable_by_key(|(relationship_id, _)| *relationship_id);
199        for (relationship_id, footer) in footers {
200            let story = WordStory::Footer {
201                relationship_id: relationship_id.clone(),
202            };
203            for paragraph_index in 0..footer.paragraphs.len() {
204                registry.insert(WordSourcePath {
205                    story: story.clone(),
206                    children: vec![paragraph_index],
207                });
208            }
209        }
210
211        for (story_kind, stream) in [
212            (NoteStream::Footnote, input.footnotes.as_ref()),
213            (NoteStream::Endnote, input.endnotes.as_ref()),
214        ]
215        .into_iter()
216        .filter_map(|(story, stream)| stream.map(|stream| (story, stream)))
217        {
218            for note in &stream.footnotes {
219                if stream.get_by_id(note.id).is_none() {
220                    continue;
221                }
222                let story = match story_kind {
223                    NoteStream::Footnote => WordStory::Footnote { id: note.id },
224                    NoteStream::Endnote => WordStory::Endnote { id: note.id },
225                };
226                for paragraph_index in 0..note.paragraphs.len() {
227                    registry.insert(WordSourcePath {
228                        story: story.clone(),
229                        children: vec![paragraph_index],
230                    });
231                }
232            }
233        }
234
235        registry
236    }
237
238    fn collect_table(&mut self, table: &CT_Tbl, story: &WordStory, prefix: &[usize]) {
239        for (row, row_path) in table::layout_table_rows(table, prefix) {
240            for (cell, cell_path) in table::layout_row_cells(row, &row_path) {
241                for (content_index, content) in cell.content.iter().enumerate() {
242                    let mut content_path = cell_path.clone();
243                    content_path.push(content_index);
244                    self.collect_cell_content(content, story, &content_path);
245                }
246            }
247        }
248    }
249
250    fn collect_cell_content(&mut self, content: &CellContent, story: &WordStory, path: &[usize]) {
251        match content {
252            CellContent::Paragraph(_) => self.insert(WordSourcePath {
253                story: story.clone(),
254                children: path.to_vec(),
255            }),
256            CellContent::Table(table) => self.collect_table(table, story, path),
257            CellContent::ContentControl(control) => self.collect_cell_control(control, story, path),
258        }
259    }
260
261    fn collect_cell_control(&mut self, control: &CT_Sdt, story: &WordStory, path: &[usize]) {
262        for (content_index, content) in control.content.iter().enumerate() {
263            let mut content_path = path.to_vec();
264            content_path.push(content_index);
265            match content {
266                SdtContent::Paragraph(_) => self.insert(WordSourcePath {
267                    story: story.clone(),
268                    children: content_path,
269                }),
270                SdtContent::Table(table) => self.collect_table(table, story, &content_path),
271                SdtContent::ContentControl(control) => {
272                    self.collect_cell_control(control, story, &content_path)
273                }
274                SdtContent::Row(_)
275                | SdtContent::Cell(_)
276                | SdtContent::Run(_)
277                | SdtContent::RawXml(_) => {}
278            }
279        }
280    }
281
282    fn insert(&mut self, path: WordSourcePath) {
283        if self.ids.contains_key(&path) {
284            return;
285        }
286        let id = self.insert_node(path.clone());
287        self.ids.insert(path, id);
288    }
289
290    fn insert_node(&mut self, path: WordSourcePath) -> SourceNodeId {
291        let index = u32::try_from(self.nodes.len() + 1)
292            .expect("a layout result cannot contain more than u32::MAX source paragraphs");
293        let id = SourceNodeId::new(index).expect("source ids are one based");
294        self.nodes.push(path);
295        id
296    }
297
298    fn body_id(&self, body_index: usize) -> Option<SourceNodeId> {
299        self.body_ids.get(body_index).copied().flatten()
300    }
301
302    pub(crate) fn id(&self, story: &WordStory, children: &[usize]) -> Option<SourceNodeId> {
303        self.ids
304            .get(&WordSourcePath {
305                story: story.clone(),
306                children: children.to_vec(),
307            })
308            .copied()
309    }
310
311    fn into_nodes(self) -> Vec<WordSourcePath> {
312        self.nodes
313    }
314}
315
316fn project_paragraph_runs(para: &CT_P, view: RevisionView) -> Vec<ProjectedRun<'_>> {
317    let mut projected = Vec::new();
318    for boundary in 0..=para.runs.len() {
319        let mut owners = para
320            .content_controls
321            .iter()
322            .filter(|(at, _, _, _)| *at == boundary)
323            .map(|(_, raw_before, _, control)| {
324                (
325                    RawOrder::Raw(*raw_before),
326                    0u8,
327                    ProjectedParagraphOwner::Control(control),
328                )
329            })
330            .chain(
331                para.revisions
332                    .iter()
333                    .filter(|(at, _, _)| *at == boundary)
334                    .map(|(_, slot, revision)| {
335                        (
336                            paragraph_revision_raw_order(para, boundary, *slot),
337                            1u8,
338                            ProjectedParagraphOwner::Revision {
339                                revision,
340                                hyperlink_index: hyperlink_revision_index(*slot),
341                            },
342                        )
343                    }),
344            )
345            .collect::<Vec<_>>();
346        owners.sort_by_key(|(raw_order, kind, _)| (*raw_order, *kind));
347        for (raw_order, _, owner) in owners {
348            match owner {
349                ProjectedParagraphOwner::Control(control) => {
350                    project_control_runs(control, view, boundary, raw_order, &mut projected)
351                }
352                ProjectedParagraphOwner::Revision {
353                    revision,
354                    hyperlink_index,
355                } => project_revision_runs(
356                    revision,
357                    view,
358                    boundary,
359                    raw_order,
360                    hyperlink_index,
361                    false,
362                    false,
363                    &mut projected,
364                ),
365            }
366        }
367        if let Some(run) = para.runs.get(boundary) {
368            projected.push(ProjectedRun {
369                run,
370                boundary,
371                raw_order: RawOrder::AfterRaw,
372                ordinary_run_index: Some(boundary),
373                hyperlink_index: None,
374                force_underline: false,
375                force_strike: false,
376            });
377        }
378    }
379    projected
380}
381
382enum ProjectedParagraphOwner<'a> {
383    Control(&'a CT_Sdt),
384    Revision {
385        revision: &'a CT_Revision,
386        hyperlink_index: Option<usize>,
387    },
388}
389
390fn paragraph_revision_raw_order(para: &CT_P, boundary: usize, slot: usize) -> RawOrder {
391    let Some(index) = hyperlink_revision_index(slot) else {
392        return RawOrder::Raw(slot);
393    };
394    if let Some(raw_before) = para
395        .hyperlinks
396        .get(index)
397        .and_then(|hyperlink| hyperlink.preserved_raw_before)
398    {
399        RawOrder::Raw(raw_before)
400    } else if para
401        .hyperlinks
402        .get(index)
403        .is_some_and(|hyperlink| boundary == hyperlink.run_end)
404    {
405        RawOrder::BeforeRaw
406    } else {
407        RawOrder::AfterRaw
408    }
409}
410
411fn project_control_runs<'a>(
412    control: &'a CT_Sdt,
413    view: RevisionView,
414    boundary: usize,
415    raw_order: RawOrder,
416    projected: &mut Vec<ProjectedRun<'a>>,
417) {
418    for content_boundary in 0..=control.content.len() {
419        for (_, revision) in control
420            .revisions()
421            .iter()
422            .filter(|(at, _)| *at == content_boundary)
423        {
424            project_revision_runs(
425                revision, view, boundary, raw_order, None, false, false, projected,
426            );
427        }
428        if let Some(content) = control.content.get(content_boundary) {
429            match content {
430                SdtContent::Run(run) => projected.push(ProjectedRun {
431                    run,
432                    boundary,
433                    raw_order,
434                    ordinary_run_index: None,
435                    hyperlink_index: None,
436                    force_underline: false,
437                    force_strike: false,
438                }),
439                SdtContent::ContentControl(control) => {
440                    project_control_runs(control, view, boundary, raw_order, projected);
441                }
442                SdtContent::Paragraph(paragraph) => {
443                    project_control_paragraph_runs(paragraph, view, boundary, raw_order, projected)
444                }
445                SdtContent::Table(table) => {
446                    project_control_table_runs(table, view, boundary, raw_order, projected);
447                }
448                SdtContent::Row(row) => {
449                    project_control_row_runs(row, view, boundary, raw_order, projected);
450                }
451                SdtContent::Cell(cell) => {
452                    project_control_cell_runs(cell, view, boundary, raw_order, projected);
453                }
454                SdtContent::RawXml(_) => {}
455            }
456        }
457    }
458}
459
460fn project_control_paragraph_runs<'a>(
461    paragraph: &'a CT_P,
462    view: RevisionView,
463    boundary: usize,
464    raw_order: RawOrder,
465    projected: &mut Vec<ProjectedRun<'a>>,
466) {
467    for nested in project_paragraph_runs(paragraph, view) {
468        projected.push(ProjectedRun {
469            boundary,
470            raw_order,
471            ordinary_run_index: None,
472            hyperlink_index: None,
473            ..nested
474        });
475    }
476}
477
478fn project_control_table_runs<'a>(
479    table: &'a CT_Tbl,
480    view: RevisionView,
481    boundary: usize,
482    raw_order: RawOrder,
483    projected: &mut Vec<ProjectedRun<'a>>,
484) {
485    for row_boundary in 0..=table.rows.len() {
486        for (_, _, control) in table
487            .content_controls
488            .iter()
489            .filter(|(at, _, _)| *at == row_boundary)
490        {
491            project_control_runs(control, view, boundary, raw_order, projected);
492        }
493        if let Some(row) = table.rows.get(row_boundary) {
494            project_control_row_runs(row, view, boundary, raw_order, projected);
495        }
496    }
497}
498
499fn project_control_row_runs<'a>(
500    row: &'a CT_Row,
501    view: RevisionView,
502    boundary: usize,
503    raw_order: RawOrder,
504    projected: &mut Vec<ProjectedRun<'a>>,
505) {
506    for cell_boundary in 0..=row.cells.len() {
507        for (_, _, control) in row
508            .content_controls
509            .iter()
510            .filter(|(at, _, _)| *at == cell_boundary)
511        {
512            project_control_runs(control, view, boundary, raw_order, projected);
513        }
514        if let Some(cell) = row.cells.get(cell_boundary) {
515            project_control_cell_runs(cell, view, boundary, raw_order, projected);
516        }
517    }
518}
519
520fn project_control_cell_runs<'a>(
521    cell: &'a CT_Tc,
522    view: RevisionView,
523    boundary: usize,
524    raw_order: RawOrder,
525    projected: &mut Vec<ProjectedRun<'a>>,
526) {
527    for content in &cell.content {
528        match content {
529            CellContent::Paragraph(paragraph) => {
530                project_control_paragraph_runs(paragraph, view, boundary, raw_order, projected)
531            }
532            CellContent::Table(table) => {
533                project_control_table_runs(table, view, boundary, raw_order, projected);
534            }
535            CellContent::ContentControl(control) => {
536                project_control_runs(control, view, boundary, raw_order, projected);
537            }
538        }
539    }
540}
541
542fn project_revision_runs<'a>(
543    revision: &'a CT_Revision,
544    view: RevisionView,
545    boundary: usize,
546    raw_order: RawOrder,
547    hyperlink_index: Option<usize>,
548    inherited_underline: bool,
549    inherited_strike: bool,
550    projected: &mut Vec<ProjectedRun<'a>>,
551) {
552    let included = match view {
553        RevisionView::Tracked => true,
554        RevisionView::Accepted => matches!(
555            revision.kind(),
556            RevisionKind::Insertion | RevisionKind::MoveTo
557        ),
558    };
559    if !included {
560        return;
561    }
562
563    let force_underline = inherited_underline
564        || (view == RevisionView::Tracked
565            && matches!(
566                revision.kind(),
567                RevisionKind::Insertion | RevisionKind::MoveTo
568            ));
569    let force_strike = inherited_strike
570        || (view == RevisionView::Tracked
571            && matches!(
572                revision.kind(),
573                RevisionKind::Deletion | RevisionKind::MoveFrom
574            ));
575    if matches!(
576        revision.kind(),
577        RevisionKind::Insertion | RevisionKind::MoveTo
578    ) && let Some(paragraph) = revision.content_paragraph()
579    {
580        for nested in project_paragraph_runs(paragraph, view) {
581            projected.push(ProjectedRun {
582                boundary,
583                raw_order,
584                ordinary_run_index: None,
585                hyperlink_index,
586                force_underline: force_underline || nested.force_underline,
587                force_strike: force_strike || nested.force_strike,
588                ..nested
589            });
590        }
591        return;
592    }
593    let runs = match revision.content() {
594        RevisionContent::Runs(runs) => runs.as_slice(),
595        RevisionContent::Marker => &[],
596        RevisionContent::PriorRunProperties(_)
597        | RevisionContent::PriorParagraphProperties(_)
598        | RevisionContent::PriorTableProperties(_)
599        | RevisionContent::PriorSectionProperties(_) => return,
600    };
601
602    for run_boundary in 0..=runs.len() {
603        for (_, nested) in revision
604            .nested_revisions()
605            .iter()
606            .filter(|(at, _)| *at == run_boundary)
607        {
608            project_revision_runs(
609                nested,
610                view,
611                boundary,
612                raw_order,
613                hyperlink_index,
614                force_underline,
615                force_strike,
616                projected,
617            );
618        }
619        if let Some(run) = runs.get(run_boundary) {
620            projected.push(ProjectedRun {
621                run,
622                boundary,
623                raw_order,
624                ordinary_run_index: None,
625                hyperlink_index,
626                force_underline,
627                force_strike,
628            });
629        }
630    }
631}
632
633#[allow(clippy::too_many_arguments)]
634fn push_equations_before_order(
635    paragraph: &CT_P,
636    boundary: usize,
637    order: RawOrder,
638    cursor: &mut usize,
639    inline_items: &mut Vec<InlineItem>,
640    fm: &mut FontManager,
641    font_size: f64,
642    color: Color,
643    available_width: f64,
644    math_properties: Option<&rdocx_oxml::math::MathProperties>,
645    diagnostics: &mut Vec<Diagnostic>,
646) -> Result<()> {
647    while let Some((equation_boundary, raw_before, equation)) = paragraph.equations.get(*cursor) {
648        if *equation_boundary > boundary
649            || (*equation_boundary == boundary
650                && !match order {
651                    RawOrder::BeforeRaw => false,
652                    RawOrder::Raw(limit) => *raw_before < limit,
653                    RawOrder::AfterRaw => true,
654                })
655        {
656            break;
657        }
658        let source_path =
659            format!("paragraph/run-boundary/{equation_boundary}/raw-child/{raw_before}");
660        let (measured, display) = crate::math::layout_officemath(
661            equation,
662            fm,
663            font_size,
664            color,
665            available_width,
666            math_properties,
667            &source_path,
668            diagnostics,
669        )?;
670        if display
671            && !inline_items.is_empty()
672            && !matches!(inline_items.last(), Some(InlineItem::LineBreak))
673        {
674            inline_items.push(InlineItem::LineBreak);
675        }
676        inline_items.push(InlineItem::Group {
677            width: measured.width,
678            height: measured.height(),
679            baseline: Some(measured.ascent),
680            group: measured.group,
681        });
682        if display {
683            inline_items.push(InlineItem::LineBreak);
684        }
685        *cursor += 1;
686    }
687    Ok(())
688}
689
690fn projected_paragraph_text(para: &CT_P, view: RevisionView) -> String {
691    project_paragraph_runs(para, view)
692        .iter()
693        .map(|projected| projected.run.text())
694        .collect()
695}
696
697fn projected_content_char_starts(run: &CT_R) -> Vec<usize> {
698    let mut starts = Vec::with_capacity(run.content.len());
699    let mut char_offset = 0usize;
700    for content in &run.content {
701        starts.push(char_offset);
702        char_offset += match content {
703            RunContent::Text(text) | RunContent::DeletedText(text) => text.text.chars().count(),
704            RunContent::Tab | RunContent::Break(_) => 1,
705            RunContent::Field(field) => field
706                .projected_text()
707                .map_or(0, |text| text.chars().count()),
708            RunContent::Drawing(_)
709            | RunContent::FootnoteRef { .. }
710            | RunContent::EndnoteRef { .. }
711            | RunContent::CommentReference { .. } => 0,
712        };
713    }
714    debug_assert_eq!(char_offset, run.text().chars().count());
715    starts
716}
717
718fn paragraph_has_visible_revision(para: &CT_P) -> bool {
719    let property_revision = para.properties.as_ref().is_some_and(|properties| {
720        properties.numbering_revision.is_some()
721            || properties.change.is_some()
722            || properties
723                .sect_pr
724                .as_ref()
725                .is_some_and(|section| section.change.is_some())
726            || properties
727                .rpr
728                .as_ref()
729                .is_some_and(run_properties_have_revision)
730    });
731    property_revision
732        || para
733            .runs
734            .iter()
735            .filter_map(|run| run.properties.as_ref())
736            .any(run_properties_have_revision)
737        || para
738            .content_controls
739            .iter()
740            .any(|(_, _, _, control)| control_has_visible_revision(control))
741        || para
742            .revisions
743            .iter()
744            .any(|(_, _, revision)| revision_is_visible(revision))
745}
746
747fn run_properties_have_revision(properties: &rdocx_oxml::properties::CT_RPr) -> bool {
748    properties.change.is_some() || !properties.revision_markers.is_empty()
749}
750
751fn run_has_visible_content(run: &CT_R) -> bool {
752    run.content.iter().any(|content| match content {
753        RunContent::Text(text) | RunContent::DeletedText(text) => !text.text.is_empty(),
754        RunContent::CommentReference { .. } => false,
755        RunContent::Tab
756        | RunContent::Break(_)
757        | RunContent::Drawing(_)
758        | RunContent::Field(_)
759        | RunContent::FootnoteRef { .. }
760        | RunContent::EndnoteRef { .. } => true,
761    })
762}
763
764fn control_has_visible_revision(control: &CT_Sdt) -> bool {
765    control
766        .revisions()
767        .iter()
768        .any(|(_, revision)| revision_is_visible(revision))
769        || control.content.iter().any(|content| match content {
770            SdtContent::Paragraph(paragraph) => paragraph_has_visible_revision(paragraph),
771            SdtContent::ContentControl(control) => control_has_visible_revision(control),
772            SdtContent::Table(table) => {
773                table
774                    .content_controls
775                    .iter()
776                    .any(|(_, _, control)| control_has_visible_revision(control))
777                    || table.rows.iter().any(row_has_visible_control_revision)
778            }
779            SdtContent::Row(row) => row_has_visible_control_revision(row),
780            SdtContent::Cell(cell) => cell_has_visible_control_revision(cell),
781            SdtContent::Run(run) => run
782                .properties
783                .as_ref()
784                .is_some_and(run_properties_have_revision),
785            SdtContent::RawXml(_) => false,
786        })
787}
788
789fn row_has_visible_control_revision(row: &CT_Row) -> bool {
790    row.content_controls
791        .iter()
792        .any(|(_, _, control)| control_has_visible_revision(control))
793        || row.cells.iter().any(cell_has_visible_control_revision)
794}
795
796fn cell_has_visible_control_revision(cell: &CT_Tc) -> bool {
797    cell.content.iter().any(|content| match content {
798        CellContent::Paragraph(paragraph) => paragraph_has_visible_revision(paragraph),
799        CellContent::Table(table) => {
800            table
801                .content_controls
802                .iter()
803                .any(|(_, _, control)| control_has_visible_revision(control))
804                || table.rows.iter().any(row_has_visible_control_revision)
805        }
806        CellContent::ContentControl(control) => control_has_visible_revision(control),
807    })
808}
809
810fn revision_is_visible(revision: &CT_Revision) -> bool {
811    if let Some(paragraph) = revision.content_paragraph() {
812        return project_paragraph_runs(paragraph, RevisionView::Tracked)
813            .iter()
814            .any(|projected| run_has_visible_content(projected.run))
815            || paragraph_has_visible_revision(paragraph);
816    }
817    match revision.content() {
818        RevisionContent::Runs(runs) => {
819            runs.iter().any(run_has_visible_content)
820                || revision
821                    .nested_revisions()
822                    .iter()
823                    .any(|(_, nested)| revision_is_visible(nested))
824        }
825        RevisionContent::Marker => revision
826            .nested_revisions()
827            .iter()
828            .any(|(_, nested)| revision_is_visible(nested)),
829        RevisionContent::PriorRunProperties(_)
830        | RevisionContent::PriorParagraphProperties(_)
831        | RevisionContent::PriorTableProperties(_)
832        | RevisionContent::PriorSectionProperties(_) => true,
833    }
834}
835
836/// The layout engine.
837pub struct Engine {
838    font_manager: FontManager,
839    caller_font_aliases: Vec<(String, String)>,
840    paragraph_cache_context: Option<ReusableEngineContext>,
841    paragraph_cache: VecDeque<ParagraphCacheEntry>,
842    paragraph_cache_bytes: usize,
843    paragraph_cache_hits: usize,
844    paragraph_cache_builds: usize,
845    pending_paragraph_cache: Option<VecDeque<ParagraphCacheEntry>>,
846    pending_paragraph_cache_bytes: usize,
847    #[cfg(test)]
848    pending_paragraph_cache_peak_entries: usize,
849    #[cfg(test)]
850    pending_paragraph_cache_peak_bytes: usize,
851    paragraph_cache_reads_enabled: bool,
852    table_cache: VecDeque<TableCacheEntry>,
853    table_cache_bytes: usize,
854    table_cache_hits: usize,
855    table_cache_builds: usize,
856    pending_table_cache: Option<VecDeque<TableCacheEntry>>,
857    pending_table_cache_bytes: usize,
858    #[cfg(test)]
859    pending_table_cache_peak_entries: usize,
860    #[cfg(test)]
861    pending_table_cache_peak_bytes: usize,
862    header_footer_cache: VecDeque<HeaderFooterCacheEntry>,
863    header_footer_cache_bytes: usize,
864    header_footer_cache_hits: usize,
865    header_footer_cache_builds: usize,
866    pending_header_footer_cache: Option<VecDeque<HeaderFooterCacheEntry>>,
867    pending_header_footer_cache_bytes: usize,
868    #[cfg(test)]
869    pending_header_footer_cache_peak_entries: usize,
870    #[cfg(test)]
871    pending_header_footer_cache_peak_bytes: usize,
872    header_footer_cache_reads_enabled: bool,
873    restart_cache: Option<RestartCache>,
874    #[cfg(test)]
875    owned_context_builds: usize,
876    #[cfg(test)]
877    body_debug_work: usize,
878    #[cfg(test)]
879    retained_page_deep_copies: usize,
880    #[cfg(test)]
881    last_restart_candidate_bytes: usize,
882    #[cfg(test)]
883    last_rebuilt_page_range: Option<std::ops::Range<usize>>,
884    #[cfg(test)]
885    last_shared_block_counts: (usize, usize),
886    #[cfg(test)]
887    page_layout_invocations: usize,
888}
889
890#[derive(Clone, PartialEq)]
891struct ReusableEngineContext {
892    revision_view: RevisionView,
893    automatic_hyphenation: bool,
894    math_properties: Option<rdocx_oxml::math::MathProperties>,
895    has_wrapping_drawing: bool,
896    styles: CT_Styles,
897    numbering: Option<rdocx_oxml::numbering::CT_Numbering>,
898    sections: Vec<CT_SectPr>,
899    headers: HashMap<String, rdocx_oxml::header_footer::CT_HdrFtr>,
900    footers: HashMap<String, rdocx_oxml::header_footer::CT_HdrFtr>,
901    images: HashMap<String, crate::input::ImageData>,
902    charts: HashMap<String, std::result::Result<Box<oxml_chart::CT_ChartSpace>, String>>,
903    chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet,
904    chart_color_map: oxml_drawing::color::ColorMap,
905    core_properties: Option<rdocx_oxml::core_properties::CoreProperties>,
906    hyperlink_urls: HashMap<String, String>,
907    footnotes: Option<rdocx_oxml::footnotes::CT_Footnotes>,
908    endnotes: Option<rdocx_oxml::footnotes::CT_Footnotes>,
909    theme: Option<rdocx_oxml::theme::Theme>,
910    fonts: Vec<oxml_layout::FontFile>,
911    caller_font_aliases: Vec<(String, String)>,
912    background_xml: Option<Vec<u8>>,
913}
914
915#[cfg(test)]
916thread_local! {
917    static RETAINED_CONTEXT_FONT_BYTES_COMPARED: Cell<usize> = const { Cell::new(0) };
918}
919
920fn retained_context_fonts_match(
921    retained: &[oxml_layout::FontFile],
922    input: &[oxml_layout::FontFile],
923) -> bool {
924    #[cfg(test)]
925    RETAINED_CONTEXT_FONT_BYTES_COMPARED.set(
926        RETAINED_CONTEXT_FONT_BYTES_COMPARED
927            .get()
928            .saturating_add(retained.iter().map(|font| font.data.len()).sum::<usize>()),
929    );
930    retained == input
931}
932
933#[cfg(test)]
934fn reset_retained_context_font_bytes_compared() {
935    RETAINED_CONTEXT_FONT_BYTES_COMPARED.set(0);
936}
937
938#[cfg(test)]
939fn retained_context_font_bytes_compared() -> usize {
940    RETAINED_CONTEXT_FONT_BYTES_COMPARED.get()
941}
942
943impl ReusableEngineContext {
944    #[cfg(test)]
945    fn for_input(input: &LayoutInput, caller_font_aliases: &[(String, String)]) -> Self {
946        Self::for_input_with_wrap(
947            input,
948            caller_font_aliases,
949            document_has_wrapping_drawing(input),
950        )
951    }
952
953    fn for_input_with_wrap(
954        input: &LayoutInput,
955        caller_font_aliases: &[(String, String)],
956        has_wrapping_drawing: bool,
957    ) -> Self {
958        let mut sections = input
959            .document
960            .body
961            .content
962            .iter()
963            .filter_map(|content| match content {
964                BodyContent::Paragraph(paragraph) => paragraph
965                    .properties
966                    .as_ref()
967                    .and_then(|properties| properties.sect_pr.clone()),
968                _ => None,
969            })
970            .collect::<Vec<_>>();
971        sections.extend(input.document.body.sect_pr.iter().cloned());
972        Self {
973            revision_view: input.revision_view,
974            automatic_hyphenation: input.automatic_hyphenation,
975            math_properties: input.math_properties.clone(),
976            has_wrapping_drawing,
977            styles: input.styles.clone(),
978            numbering: input.numbering.clone(),
979            sections,
980            headers: input.headers.clone(),
981            footers: input.footers.clone(),
982            images: input.images.clone(),
983            charts: input.charts.clone(),
984            chart_theme: input.chart_theme.clone(),
985            chart_color_map: input.chart_color_map.clone(),
986            core_properties: input.core_properties.clone(),
987            hyperlink_urls: input.hyperlink_urls.clone(),
988            footnotes: input.footnotes.clone(),
989            endnotes: input.endnotes.clone(),
990            theme: input.theme.clone(),
991            fonts: input.fonts.clone(),
992            caller_font_aliases: bounded_caller_aliases(caller_font_aliases),
993            background_xml: input.document.background_xml.clone(),
994        }
995    }
996
997    fn matches_input(
998        &self,
999        input: &LayoutInput,
1000        caller_font_aliases: &[(String, String)],
1001        has_wrapping_drawing: bool,
1002    ) -> bool {
1003        self.matches_input_after_unchanged_fonts(input, caller_font_aliases, has_wrapping_drawing)
1004            && retained_context_fonts_match(&self.fonts, &input.fonts)
1005    }
1006
1007    fn matches_input_after_unchanged_fonts(
1008        &self,
1009        input: &LayoutInput,
1010        caller_font_aliases: &[(String, String)],
1011        has_wrapping_drawing: bool,
1012    ) -> bool {
1013        let sections_match = self.sections.iter().eq(input
1014            .document
1015            .body
1016            .content
1017            .iter()
1018            .filter_map(|content| match content {
1019                BodyContent::Paragraph(paragraph) => paragraph
1020                    .properties
1021                    .as_ref()
1022                    .and_then(|properties| properties.sect_pr.as_ref()),
1023                BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
1024                    None
1025                }
1026            })
1027            .chain(input.document.body.sect_pr.iter()));
1028        self.revision_view == input.revision_view
1029            && self.automatic_hyphenation == input.automatic_hyphenation
1030            && self.math_properties == input.math_properties
1031            && self.has_wrapping_drawing == has_wrapping_drawing
1032            && self.styles == input.styles
1033            && self.numbering == input.numbering
1034            && sections_match
1035            && self.headers == input.headers
1036            && self.footers == input.footers
1037            && self.images == input.images
1038            && self.charts == input.charts
1039            && self.chart_theme == input.chart_theme
1040            && self.chart_color_map == input.chart_color_map
1041            && self.core_properties == input.core_properties
1042            && self.hyperlink_urls == input.hyperlink_urls
1043            && self.footnotes == input.footnotes
1044            && self.endnotes == input.endnotes
1045            && self.theme == input.theme
1046            && self.caller_font_aliases == caller_font_aliases
1047            && self.background_xml == input.document.background_xml
1048    }
1049}
1050
1051#[derive(Clone, PartialEq)]
1052struct ParagraphCacheKey {
1053    paragraph: CT_P,
1054    content_width_bits: u64,
1055    revision_view: RevisionView,
1056}
1057
1058struct ParagraphCacheEntry {
1059    fingerprint: u64,
1060    key: ParagraphCacheKey,
1061    block: Arc<ParagraphBlock>,
1062    diagnostics: Vec<Diagnostic>,
1063    font_trace: Vec<FontId>,
1064    reflow_direction: TextDirection,
1065    bytes: usize,
1066}
1067
1068#[derive(Clone, PartialEq)]
1069struct TableCacheKey {
1070    table: CT_Tbl,
1071    content_width_bits: u64,
1072    revision_view: RevisionView,
1073    with_provenance: bool,
1074}
1075
1076struct TableCacheEntry {
1077    fingerprint: u64,
1078    key: TableCacheKey,
1079    block: Arc<table::TableBlock>,
1080    semantics: TableSemantics,
1081    diagnostics: Vec<Diagnostic>,
1082    font_trace: Vec<FontId>,
1083    bytes: usize,
1084}
1085
1086#[derive(Clone, Copy, PartialEq, Eq)]
1087enum HeaderFooterStoryKind {
1088    Header,
1089    Footer,
1090}
1091
1092#[derive(Clone, PartialEq)]
1093struct HeaderFooterCacheKey {
1094    story: HeaderFooterStoryKind,
1095    variant: HdrFtrType,
1096    section: CT_SectPr,
1097    relationship_id: String,
1098    part: rdocx_oxml::header_footer::CT_HdrFtr,
1099    resolved_part_bytes: Vec<u8>,
1100    with_provenance: bool,
1101}
1102
1103#[derive(Clone)]
1104struct HeaderFooterVariantContent {
1105    blocks: Vec<ParagraphBlock>,
1106    directions: Vec<TextDirection>,
1107    watermark: Option<GroupElement>,
1108}
1109
1110struct HeaderFooterCacheEntry {
1111    key: HeaderFooterCacheKey,
1112    content: HeaderFooterVariantContent,
1113    diagnostics: Vec<Diagnostic>,
1114    font_trace: Vec<FontId>,
1115    bytes: usize,
1116}
1117
1118struct RestartCache {
1119    body: Vec<RestartBodyEntry>,
1120    with_provenance: bool,
1121    raw_pages: Vec<Arc<PageFrame>>,
1122    pages: Vec<Arc<PageFrame>>,
1123    substitution_inputs: Vec<Option<FieldSubstitutionInputs>>,
1124    outlines: Vec<oxml_layout::OutlineEntry>,
1125    checkpoints: Vec<paginator::PaginationCheckpoint>,
1126    font_trace: Vec<FontId>,
1127    bytes: usize,
1128}
1129
1130#[derive(Clone)]
1131enum RestartBodyEntry {
1132    Paragraph {
1133        fingerprint: u64,
1134        identity: Vec<u8>,
1135        note_references: Vec<NoteRef>,
1136        bytes: usize,
1137    },
1138    Table {
1139        fingerprint: u64,
1140        identity: Vec<u8>,
1141        bytes: usize,
1142    },
1143}
1144
1145impl RestartBodyEntry {
1146    fn matches(&self, content: &BodyContent) -> bool {
1147        match (self, content) {
1148            (
1149                Self::Paragraph {
1150                    fingerprint,
1151                    identity,
1152                    ..
1153                },
1154                BodyContent::Paragraph(paragraph),
1155            ) => {
1156                *fingerprint == paragraph_fingerprint(paragraph)
1157                    && restart_body_identity(content).as_ref() == Some(identity)
1158            }
1159            (
1160                Self::Table {
1161                    fingerprint,
1162                    identity,
1163                    ..
1164                },
1165                BodyContent::Table(table),
1166            ) => {
1167                *fingerprint == table_fingerprint(table)
1168                    && restart_body_identity(content).as_ref() == Some(identity)
1169            }
1170            _ => false,
1171        }
1172    }
1173
1174    fn for_content(content: &BodyContent, view: RevisionView) -> Option<Self> {
1175        let identity = restart_body_identity(content)?;
1176        match content {
1177            BodyContent::Paragraph(paragraph) => {
1178                let mut note_references = paragraph_note_references(paragraph, view);
1179                note_references.shrink_to_fit();
1180                let bytes = identity.capacity().saturating_add(
1181                    note_references
1182                        .capacity()
1183                        .saturating_mul(std::mem::size_of::<NoteRef>()),
1184                );
1185                Some(Self::Paragraph {
1186                    fingerprint: paragraph_fingerprint(paragraph),
1187                    identity,
1188                    note_references,
1189                    bytes,
1190                })
1191            }
1192            BodyContent::Table(table) => Some(Self::Table {
1193                fingerprint: table_fingerprint(table),
1194                bytes: identity.capacity(),
1195                identity,
1196            }),
1197            BodyContent::ContentControl(_) | BodyContent::RawXml(_) => None,
1198        }
1199    }
1200
1201    fn bytes(&self) -> usize {
1202        match self {
1203            Self::Paragraph { bytes, .. } | Self::Table { bytes, .. } => *bytes,
1204        }
1205    }
1206
1207    fn note_references(&self) -> &[NoteRef] {
1208        match self {
1209            Self::Paragraph {
1210                note_references, ..
1211            } => note_references,
1212            Self::Table { .. } => &[],
1213        }
1214    }
1215}
1216
1217fn restart_body_identity(content: &BodyContent) -> Option<Vec<u8>> {
1218    if !matches!(content, BodyContent::Paragraph(_) | BodyContent::Table(_)) {
1219        return None;
1220    }
1221    let mut document = CT_Document::new();
1222    document.body.sect_pr = None;
1223    document.body.content.push(content.clone());
1224    let mut identity = document.to_xml().ok()?;
1225    identity.shrink_to_fit();
1226    Some(identity)
1227}
1228
1229fn paragraph_note_references(paragraph: &CT_P, view: RevisionView) -> Vec<NoteRef> {
1230    project_paragraph_runs(paragraph, view)
1231        .into_iter()
1232        .flat_map(|projected| projected.run.content.iter())
1233        .filter_map(|content| match content {
1234            RunContent::FootnoteRef { id } => Some(NoteRef {
1235                stream: NoteStream::Footnote,
1236                id: *id,
1237            }),
1238            RunContent::EndnoteRef { id } => Some(NoteRef {
1239                stream: NoteStream::Endnote,
1240                id: *id,
1241            }),
1242            _ => None,
1243        })
1244        .collect()
1245}
1246
1247fn body_note_references(input: &LayoutInput) -> Vec<NoteRef> {
1248    input
1249        .document
1250        .body
1251        .content
1252        .iter()
1253        .flat_map(|content| match content {
1254            BodyContent::Paragraph(paragraph) => {
1255                paragraph_note_references(paragraph, input.revision_view)
1256            }
1257            BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
1258                Vec::new()
1259            }
1260        })
1261        .collect()
1262}
1263
1264const fn arc_allocation_bytes<T>() -> usize {
1265    std::mem::size_of::<T>() + 2 * std::mem::size_of::<usize>()
1266}
1267
1268#[derive(Clone, PartialEq, Eq)]
1269struct FieldSubstitutionInputs {
1270    page_index: usize,
1271    page_number: usize,
1272    total_pages: usize,
1273    bookmark_pages: Vec<(usize, usize)>,
1274    font_identity: Vec<FontId>,
1275    revision_view: RevisionView,
1276}
1277
1278const CACHE_MAX_ENTRIES: usize = 5_216;
1279const CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
1280const PARAGRAPH_CACHE_MAX_ENTRIES: usize = 4_096;
1281const PARAGRAPH_CACHE_MAX_BYTES: usize = 50 * 1024 * 1024;
1282const TABLE_CACHE_MAX_ENTRIES: usize = 32;
1283const TABLE_CACHE_MAX_BYTES: usize = 2 * 1024 * 1024;
1284const HEADER_FOOTER_CACHE_MAX_ENTRIES: usize = 64;
1285const HEADER_FOOTER_CACHE_MAX_BYTES: usize = 4 * 1024 * 1024;
1286const RESTART_CACHE_MAX_ENTRIES: usize = 1_024;
1287const CALLER_ALIAS_MAX_ENTRIES: usize = 256;
1288const CALLER_ALIAS_MAX_RETAINED_BYTES: usize = 64 * 1024;
1289const _: () = assert!(PARAGRAPH_CACHE_MAX_ENTRIES == 4_096);
1290const _: () = assert!(PARAGRAPH_CACHE_MAX_BYTES == 50 * 1024 * 1024);
1291const _: () = assert!(HEADER_FOOTER_CACHE_MAX_ENTRIES == 64);
1292const _: () = assert!(HEADER_FOOTER_CACHE_MAX_BYTES == 4 * 1024 * 1024);
1293const _: () = assert!(CACHE_MAX_ENTRIES == 5_216);
1294const _: () = assert!(CACHE_MAX_BYTES == 64 * 1024 * 1024);
1295const _: () = assert!(
1296    PARAGRAPH_CACHE_MAX_ENTRIES
1297        + TABLE_CACHE_MAX_ENTRIES
1298        + HEADER_FOOTER_CACHE_MAX_ENTRIES
1299        + RESTART_CACHE_MAX_ENTRIES
1300        <= CACHE_MAX_ENTRIES
1301);
1302const _: () = assert!(
1303    PARAGRAPH_CACHE_MAX_BYTES + TABLE_CACHE_MAX_BYTES + HEADER_FOOTER_CACHE_MAX_BYTES
1304        <= CACHE_MAX_BYTES
1305);
1306const CACHE_SOURCE_NODE: SourceNodeId = match SourceNodeId::new(1) {
1307    Some(node) => node,
1308    None => panic!("one is a valid source node id"),
1309};
1310
1311/// Return the deterministic prefix that fits the private caller-alias bounds.
1312///
1313/// The byte accounting matches `oxml-layout`: requested and target strings in
1314/// the ordered identity plus the normalized requested key and target value in
1315/// the font lookup map.
1316fn bounded_caller_aliases(aliases: &[(String, String)]) -> Vec<(String, String)> {
1317    let mut bounded = Vec::with_capacity(aliases.len().min(CALLER_ALIAS_MAX_ENTRIES));
1318    let mut retained_bytes = 0usize;
1319    for (requested, target) in aliases {
1320        if bounded.len() == CALLER_ALIAS_MAX_ENTRIES {
1321            break;
1322        }
1323        let normalized_requested = requested.to_lowercase();
1324        let entry_bytes = requested
1325            .len()
1326            .saturating_add(target.len())
1327            .saturating_add(normalized_requested.len())
1328            .saturating_add(target.len());
1329        let next_retained_bytes = retained_bytes.saturating_add(entry_bytes);
1330        if next_retained_bytes > CALLER_ALIAS_MAX_RETAINED_BYTES {
1331            break;
1332        }
1333        bounded.push((requested.clone(), target.clone()));
1334        retained_bytes = next_retained_bytes;
1335    }
1336    bounded
1337}
1338
1339impl Default for Engine {
1340    fn default() -> Self {
1341        Self::new()
1342    }
1343}
1344
1345impl Engine {
1346    fn with_font_manager(font_manager: FontManager) -> Self {
1347        Self {
1348            font_manager,
1349            caller_font_aliases: Vec::new(),
1350            paragraph_cache_context: None,
1351            paragraph_cache: VecDeque::new(),
1352            paragraph_cache_bytes: 0,
1353            paragraph_cache_hits: 0,
1354            paragraph_cache_builds: 0,
1355            pending_paragraph_cache: None,
1356            pending_paragraph_cache_bytes: 0,
1357            #[cfg(test)]
1358            pending_paragraph_cache_peak_entries: 0,
1359            #[cfg(test)]
1360            pending_paragraph_cache_peak_bytes: 0,
1361            paragraph_cache_reads_enabled: false,
1362            table_cache: VecDeque::new(),
1363            table_cache_bytes: 0,
1364            table_cache_hits: 0,
1365            table_cache_builds: 0,
1366            pending_table_cache: None,
1367            pending_table_cache_bytes: 0,
1368            #[cfg(test)]
1369            pending_table_cache_peak_entries: 0,
1370            #[cfg(test)]
1371            pending_table_cache_peak_bytes: 0,
1372            header_footer_cache: VecDeque::new(),
1373            header_footer_cache_bytes: 0,
1374            header_footer_cache_hits: 0,
1375            header_footer_cache_builds: 0,
1376            pending_header_footer_cache: None,
1377            pending_header_footer_cache_bytes: 0,
1378            #[cfg(test)]
1379            pending_header_footer_cache_peak_entries: 0,
1380            #[cfg(test)]
1381            pending_header_footer_cache_peak_bytes: 0,
1382            header_footer_cache_reads_enabled: false,
1383            restart_cache: None,
1384            #[cfg(test)]
1385            owned_context_builds: 0,
1386            #[cfg(test)]
1387            body_debug_work: 0,
1388            #[cfg(test)]
1389            retained_page_deep_copies: 0,
1390            #[cfg(test)]
1391            last_restart_candidate_bytes: 0,
1392            #[cfg(test)]
1393            last_rebuilt_page_range: None,
1394            #[cfg(test)]
1395            last_shared_block_counts: (0, 0),
1396            #[cfg(test)]
1397            page_layout_invocations: 0,
1398        }
1399    }
1400
1401    pub fn new() -> Self {
1402        Self::with_font_manager(FontManager::new())
1403    }
1404
1405    /// Create an engine that resolves fonts without system font discovery.
1406    pub fn new_deterministic() -> Result<Self> {
1407        Ok(Self::with_font_manager(FontManager::new_deterministic()?))
1408    }
1409
1410    /// Create an engine whose font universe is supplied entirely by the
1411    /// layout input, without bundled or system-font discovery.
1412    pub(crate) fn new_with_caller_fonts() -> Self {
1413        Self::with_font_manager(FontManager::new_with_fonts(Vec::new()))
1414    }
1415
1416    /// Set byte-free caller aliases from requested family to loaded family.
1417    pub fn set_caller_font_aliases(&mut self, aliases: &[(String, String)]) {
1418        let aliases = bounded_caller_aliases(aliases);
1419        if self.caller_font_aliases != aliases {
1420            self.caller_font_aliases = aliases;
1421        }
1422    }
1423
1424    /// Take a reusable engine only when its complete retained-work context
1425    /// matches the proposed receiver input.
1426    #[doc(hidden)]
1427    pub fn take_if_compatible(source: &mut Option<Self>, input: &LayoutInput) -> Option<Self> {
1428        Self::take_if_compatible_with_caller_aliases(source, input, &[])
1429    }
1430
1431    /// Take a reusable engine only when its complete caller-font and alias
1432    /// context matches the proposed receiver input.
1433    #[doc(hidden)]
1434    pub fn take_if_compatible_with_caller_aliases(
1435        source: &mut Option<Self>,
1436        input: &LayoutInput,
1437        caller_font_aliases: &[(String, String)],
1438    ) -> Option<Self> {
1439        let caller_font_aliases = bounded_caller_aliases(caller_font_aliases);
1440        let has_wrapping_drawing = document_has_wrapping_drawing(input);
1441        let compatible = source.as_ref().is_some_and(|engine| {
1442            engine.caller_font_aliases == caller_font_aliases
1443                && engine
1444                    .paragraph_cache_context
1445                    .as_ref()
1446                    .is_some_and(|context| {
1447                        context.matches_input(input, &caller_font_aliases, has_wrapping_drawing)
1448                    })
1449                && engine.pending_paragraph_cache.is_none()
1450                && engine.pending_header_footer_cache.is_none()
1451        });
1452        compatible.then(|| source.take()).flatten()
1453    }
1454
1455    /// Lay out the entire document.
1456    pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
1457        self.layout_inner(input, None)
1458    }
1459
1460    /// Lay out the document and retain its result-local Word source table.
1461    pub(crate) fn layout_with_provenance(
1462        &mut self,
1463        input: &LayoutInput,
1464    ) -> Result<(LayoutResult, Vec<WordSourcePath>)> {
1465        let sources = SourceRegistry::for_input(input);
1466        let result = self.layout_inner(input, Some(&sources))?;
1467        Ok((result, sources.into_nodes()))
1468    }
1469
1470    fn layout_inner(
1471        &mut self,
1472        input: &LayoutInput,
1473        sources: Option<&SourceRegistry>,
1474    ) -> Result<LayoutResult> {
1475        // Load user-provided / DOCX-embedded fonts (highest priority). An exact
1476        // unchanged set is a no-op in a reusable engine.
1477        let font_context_changed = self.font_manager.load_additional_fonts(&input.fonts)
1478            | self
1479                .font_manager
1480                .set_caller_aliases(&self.caller_font_aliases);
1481        self.font_manager.begin_layout();
1482        let has_wrapping_drawing = document_has_wrapping_drawing(input);
1483
1484        if font_context_changed {
1485            self.paragraph_cache.clear();
1486            self.paragraph_cache_bytes = 0;
1487            self.table_cache.clear();
1488            self.table_cache_bytes = 0;
1489            self.header_footer_cache.clear();
1490            self.header_footer_cache_bytes = 0;
1491        }
1492        let context_matches = !font_context_changed
1493            && self
1494                .paragraph_cache_context
1495                .as_ref()
1496                .is_some_and(|context| {
1497                    context.matches_input_after_unchanged_fonts(
1498                        input,
1499                        &self.caller_font_aliases,
1500                        has_wrapping_drawing,
1501                    )
1502                });
1503        self.paragraph_cache_reads_enabled = context_matches;
1504        self.header_footer_cache_reads_enabled = context_matches;
1505        self.pending_paragraph_cache = Some(VecDeque::new());
1506        self.pending_paragraph_cache_bytes = 0;
1507        self.pending_table_cache = Some(VecDeque::new());
1508        self.pending_table_cache_bytes = 0;
1509        self.pending_header_footer_cache = Some(VecDeque::new());
1510        self.pending_header_footer_cache_bytes = 0;
1511        #[cfg(test)]
1512        {
1513            self.pending_paragraph_cache_peak_entries = 0;
1514            self.pending_paragraph_cache_peak_bytes = 0;
1515            self.pending_table_cache_peak_entries = 0;
1516            self.pending_table_cache_peak_bytes = 0;
1517            self.pending_header_footer_cache_peak_entries = 0;
1518            self.pending_header_footer_cache_peak_bytes = 0;
1519            self.page_layout_invocations = 0;
1520        }
1521
1522        let result = self.layout_transaction(input, sources, has_wrapping_drawing);
1523        let pending = self.pending_paragraph_cache.take().unwrap_or_default();
1524        let pending_tables = self.pending_table_cache.take().unwrap_or_default();
1525        let pending_header_footers = self.pending_header_footer_cache.take().unwrap_or_default();
1526        self.pending_paragraph_cache_bytes = 0;
1527        self.pending_table_cache_bytes = 0;
1528        self.pending_header_footer_cache_bytes = 0;
1529        self.paragraph_cache_reads_enabled = false;
1530        self.header_footer_cache_reads_enabled = false;
1531        if result.is_ok() {
1532            if !context_matches {
1533                self.paragraph_cache.clear();
1534                self.paragraph_cache_bytes = 0;
1535                self.table_cache.clear();
1536                self.table_cache_bytes = 0;
1537                self.header_footer_cache.clear();
1538                self.header_footer_cache_bytes = 0;
1539                self.paragraph_cache_context = Some(ReusableEngineContext::for_input_with_wrap(
1540                    input,
1541                    &self.caller_font_aliases,
1542                    has_wrapping_drawing,
1543                ));
1544                #[cfg(test)]
1545                {
1546                    self.owned_context_builds += 1;
1547                }
1548            }
1549            for entry in pending {
1550                self.publish_paragraph_cache_entry(entry);
1551            }
1552            for entry in pending_tables {
1553                self.publish_table_cache_entry(entry);
1554            }
1555            for entry in pending_header_footers {
1556                self.publish_header_footer_cache_entry(entry);
1557            }
1558        }
1559        let current_fonts = self
1560            .font_manager
1561            .current_layout_fonts()
1562            .iter()
1563            .copied()
1564            .collect::<std::collections::HashSet<_>>();
1565        self.paragraph_cache.retain(|entry| {
1566            entry
1567                .font_trace
1568                .iter()
1569                .all(|font_id| current_fonts.contains(font_id))
1570        });
1571        self.paragraph_cache_bytes = self.paragraph_cache.iter().map(|entry| entry.bytes).sum();
1572        self.table_cache.retain(|entry| {
1573            entry
1574                .font_trace
1575                .iter()
1576                .all(|font_id| current_fonts.contains(font_id))
1577        });
1578        self.table_cache_bytes = self.table_cache.iter().map(|entry| entry.bytes).sum();
1579        self.header_footer_cache.retain(|entry| {
1580            entry
1581                .font_trace
1582                .iter()
1583                .all(|font_id| current_fonts.contains(font_id))
1584        });
1585        self.header_footer_cache_bytes = self
1586            .header_footer_cache
1587            .iter()
1588            .map(|entry| entry.bytes)
1589            .sum();
1590        self.font_manager.retain_current_fonts();
1591        result
1592    }
1593
1594    fn layout_transaction(
1595        &mut self,
1596        input: &LayoutInput,
1597        sources: Option<&SourceRegistry>,
1598        document_wraps: bool,
1599    ) -> Result<LayoutResult> {
1600        let retained_context_matches = self.paragraph_cache_reads_enabled;
1601        let styles = &input.styles;
1602        let mut num_state = NumberingState::new();
1603        let media = MediaRegistry::new(&input.images);
1604        let mut diagnostics = Vec::new();
1605
1606        // Re-breaking a paragraph around a floating drawing needs its line
1607        // breaking inputs kept alive past layout. Nearly no document has a
1608        // drawing that wraps, so the state is dropped again unless one does.
1609        // Get final section properties (body-level sectPr)
1610        let final_sect_pr = input
1611            .document
1612            .body
1613            .sect_pr
1614            .as_ref()
1615            .cloned()
1616            .unwrap_or_else(CT_SectPr::default_letter);
1617
1618        // Build sections: each section has blocks + geometry + header/footer
1619        let mut sections: Vec<paginator::SharedSection> = Vec::new();
1620        let mut current_blocks: Vec<SharedLayoutBlock> = Vec::new();
1621        let mut current_sect_pr: Option<CT_SectPr> = None; // Will be set from paragraph sect_pr
1622
1623        for content in main_story_layout_items(&input.document) {
1624            match content {
1625                MainStoryLayoutItem::Paragraph(para, path) => {
1626                    // Check if this paragraph ends a section (has sect_pr)
1627                    let para_sect_pr = para.properties.as_ref().and_then(|p| p.sect_pr.clone());
1628
1629                    let sect_pr_for_layout = para_sect_pr
1630                        .as_ref()
1631                        .or(current_sect_pr.as_ref())
1632                        .unwrap_or(&final_sect_pr);
1633                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
1634
1635                    let source = sources.and_then(|sources| {
1636                        if path.len() == 1 {
1637                            sources.body_id(path[0])
1638                        } else {
1639                            sources.id(&WordStory::Document, &path)
1640                        }
1641                    });
1642                    let mut block = self.layout_body_paragraph(
1643                        para,
1644                        geometry.content_width(),
1645                        styles,
1646                        input,
1647                        &media,
1648                        &mut num_state,
1649                        &mut diagnostics,
1650                        source,
1651                    )?;
1652
1653                    // Detect heading style for outline generation
1654                    if let Some(level) = detect_heading_level(para, styles) {
1655                        let heading_text = projected_paragraph_text(para, input.revision_view);
1656                        let replacement = match &mut block {
1657                            SharedLayoutBlock::Owned { block, .. } => match block.as_mut() {
1658                                LayoutBlock::Paragraph(paragraph) => {
1659                                    paragraph.heading_level = Some(level);
1660                                    paragraph.heading_text = Some(heading_text);
1661                                    None
1662                                }
1663                                LayoutBlock::Table(_) => unreachable!(),
1664                            },
1665                            SharedLayoutBlock::Paragraph {
1666                                block: shared,
1667                                semantics,
1668                            } => {
1669                                let mut paragraph = shared.as_ref().clone();
1670                                rebind_paragraph_source(&mut paragraph, semantics.source_node)?;
1671                                paragraph.heading_level = Some(level);
1672                                paragraph.heading_text = Some(heading_text);
1673                                Some(SharedLayoutBlock::Owned {
1674                                    block: Box::new(LayoutBlock::Paragraph(paragraph)),
1675                                    reflow_direction: semantics.reflow_direction,
1676                                })
1677                            }
1678                            SharedLayoutBlock::Table { .. } => unreachable!(),
1679                        };
1680                        if let Some(replacement) = replacement {
1681                            block = replacement;
1682                        }
1683                    }
1684
1685                    current_blocks.push(block);
1686
1687                    // If this paragraph has sect_pr, it ends a section
1688                    if let Some(sect_pr) = para_sect_pr {
1689                        let geometry = sect_pr_to_geometry(&sect_pr);
1690                        let header_footer = layout_header_footer(
1691                            self,
1692                            &sect_pr,
1693                            input,
1694                            styles,
1695                            &media,
1696                            &mut num_state,
1697                            &mut diagnostics,
1698                            sources,
1699                        )?;
1700                        let title_pg = sect_pr.title_pg.unwrap_or(false);
1701                        let (header_footer, header_footer_semantics) = header_footer
1702                            .map_or((None, None), |(content, semantics)| {
1703                                (Some(content), Some(semantics))
1704                            });
1705                        sections.push(paginator::SharedSection {
1706                            blocks: std::mem::take(&mut current_blocks),
1707                            geometry,
1708                            header_footer,
1709                            header_footer_semantics,
1710                            title_pg,
1711                            page_number_start: section_page_number_start(&sect_pr),
1712                        });
1713                        current_sect_pr = Some(sect_pr);
1714                    }
1715                }
1716                MainStoryLayoutItem::Table(tbl, path) => {
1717                    let sect_pr_for_layout = current_sect_pr.as_ref().unwrap_or(&final_sect_pr);
1718                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
1719
1720                    let table_block = self.layout_body_table(
1721                        tbl,
1722                        geometry.content_width(),
1723                        styles,
1724                        input,
1725                        &media,
1726                        &mut num_state,
1727                        &mut diagnostics,
1728                        sources,
1729                        &WordStory::Document,
1730                        &path,
1731                    )?;
1732                    current_blocks.push(table_block);
1733                }
1734            }
1735        }
1736
1737        // Remaining blocks belong to the final section
1738        let final_geometry = sect_pr_to_geometry(&final_sect_pr);
1739        let final_hf = layout_header_footer(
1740            self,
1741            &final_sect_pr,
1742            input,
1743            styles,
1744            &media,
1745            &mut num_state,
1746            &mut diagnostics,
1747            sources,
1748        )?;
1749        let final_title_pg = final_sect_pr.title_pg.unwrap_or(false);
1750        let (final_hf, final_hf_semantics) = final_hf
1751            .map_or((None, None), |(content, semantics)| {
1752                (Some(content), Some(semantics))
1753            });
1754        sections.push(paginator::SharedSection {
1755            blocks: current_blocks,
1756            geometry: final_geometry,
1757            header_footer: final_hf,
1758            header_footer_semantics: final_hf_semantics,
1759            title_pg: final_title_pg,
1760            page_number_start: section_page_number_start(&final_sect_pr),
1761        });
1762        let structure = assign_shared_document_structure(&mut sections);
1763        #[cfg(test)]
1764        {
1765            self.last_shared_block_counts = sections
1766                .iter()
1767                .flat_map(|section| &section.blocks)
1768                .fold((0, 0), |(paragraphs, tables), block| match block {
1769                    SharedLayoutBlock::Paragraph { block, .. } if Arc::strong_count(block) >= 2 => {
1770                        (paragraphs + 1, tables)
1771                    }
1772                    SharedLayoutBlock::Table { block, .. } if Arc::strong_count(block) >= 2 => {
1773                        (paragraphs, tables + 1)
1774                    }
1775                    SharedLayoutBlock::Owned { .. }
1776                    | SharedLayoutBlock::Paragraph { .. }
1777                    | SharedLayoutBlock::Table { .. } => (paragraphs, tables),
1778                });
1779        }
1780
1781        // Lay the notes out once per width, before pagination, so the paginator
1782        // can reserve exactly the height it will later draw. A note is broken to
1783        // the measure of the section carrying its reference, so every section's
1784        // width is registered. The endnote pages that follow the last body page
1785        // are drawn against `final_geometry`, which belongs to the section
1786        // pushed just above and is therefore already in this list.
1787        let content_widths: Vec<f64> = sections
1788            .iter()
1789            .map(|section| section.geometry.content_width())
1790            .collect();
1791        let notes = NoteRegistry::build(
1792            input,
1793            styles,
1794            &media,
1795            &mut self.font_manager,
1796            &mut num_state,
1797            &content_widths,
1798            &mut diagnostics,
1799            sources,
1800        )?;
1801
1802        let mut font_trace = self.font_manager.current_layout_fonts().to_vec();
1803        let restart_record_eligible = sections.len() == 1
1804            && sections[0].blocks.len() == input.document.body.content.len()
1805            && input.document.background_xml.is_none()
1806            && !document_wraps
1807            && input
1808                .document
1809                .body
1810                .content
1811                .iter()
1812                .zip(&sections[0].blocks)
1813                .all(|(content, block)| match content {
1814                    BodyContent::Paragraph(paragraph) if block.paragraph().is_some() => {
1815                        paragraph_is_restart_record_source_safe(paragraph, styles)
1816                            && restart_record_block_is_safe(block)
1817                    }
1818                    BodyContent::Table(table) if block.table().is_some() => {
1819                        table_is_cache_safe(table, styles)
1820                    }
1821                    _ => false,
1822                });
1823        let restart_eligible = restart_record_eligible
1824            && input
1825                .document
1826                .body
1827                .content
1828                .iter()
1829                .zip(&sections[0].blocks)
1830                .all(|(content, block)| {
1831                    !matches!(content, BodyContent::Paragraph(paragraph) if paragraph_has_field(paragraph))
1832                        && restart_block_is_safe(block)
1833                });
1834        let reusable_restart_record = restart_record_eligible
1835            && retained_context_matches
1836            && self.restart_cache.as_ref().is_some_and(|cache| {
1837                cache.font_trace == font_trace
1838                    && cache.with_provenance == sources.is_some()
1839                    && cache
1840                        .body
1841                        .iter()
1842                        .flat_map(|entry| entry.note_references().iter().copied())
1843                        .eq(body_note_references(input))
1844                    && (sources.is_none() || cache.body.len() == input.document.body.content.len())
1845            });
1846        let reusable_restart = restart_eligible && reusable_restart_record;
1847        let body_unchanged = reusable_restart_record
1848            && self.restart_cache.as_ref().is_some_and(|cache| {
1849                cache.body.len() == input.document.body.content.len()
1850                    && input
1851                        .document
1852                        .body
1853                        .content
1854                        .iter()
1855                        .zip(&cache.body)
1856                        .all(|(content, retained)| retained.matches(content))
1857            });
1858        let first_changed = reusable_restart.then(|| {
1859            let cache = self.restart_cache.as_ref().expect("restart cache exists");
1860            input
1861                .document
1862                .body
1863                .content
1864                .iter()
1865                .zip(&cache.body)
1866                .position(|(current, previous)| !previous.matches(current))
1867                .unwrap_or_else(|| input.document.body.content.len().min(cache.body.len()))
1868        });
1869        let common_suffix = reusable_restart.then(|| {
1870            let cache = self.restart_cache.as_ref().expect("restart cache exists");
1871            input
1872                .document
1873                .body
1874                .content
1875                .iter()
1876                .rev()
1877                .zip(cache.body.iter().rev())
1878                .take_while(|(current, previous)| previous.matches(current))
1879                .count()
1880        });
1881        let restart_checkpoint = first_changed.and_then(|first_changed| {
1882            self.restart_cache
1883                .as_ref()
1884                .expect("restart cache exists")
1885                .checkpoints
1886                .iter()
1887                .rev()
1888                .find(|checkpoint| checkpoint.next_block_index <= first_changed)
1889                .copied()
1890        });
1891        let tail_source = restart_checkpoint.and_then(|restart| {
1892            let cache = self.restart_cache.as_ref().expect("restart cache exists");
1893            let common_suffix = common_suffix.expect("reusable restart has an exact suffix");
1894            let new_tail = input.document.body.content.len() - common_suffix;
1895            let old_tail = cache.body.len() - common_suffix;
1896            let block_delta = new_tail as isize - old_tail as isize;
1897            cache
1898                .checkpoints
1899                .iter()
1900                .find(|checkpoint| {
1901                    checkpoint.next_block_index >= old_tail
1902                        && checkpoint
1903                            .next_block_index
1904                            .checked_add_signed(block_delta)
1905                            .is_some_and(|next| next > restart.next_block_index)
1906                })
1907                .copied()
1908                .map(|old| {
1909                    (
1910                        paginator::PaginationCheckpoint {
1911                            next_block_index: old
1912                                .next_block_index
1913                                .checked_add_signed(block_delta)
1914                                .expect("common suffix block index remains in range"),
1915                            page_count: old.page_count,
1916                            next_header_page_number: old.next_header_page_number,
1917                        },
1918                        old,
1919                    )
1920                })
1921        });
1922
1923        let (mut pages, mut outlines, mut checkpoints) = if restart_eligible {
1924            let mut recorded = paginator::paginate_shared_single_section_recorded(
1925                &sections[0],
1926                &self.font_manager,
1927                &media,
1928                &notes,
1929                restart_checkpoint,
1930                tail_source.map(|(stop, _)| stop),
1931            );
1932            #[cfg(test)]
1933            {
1934                self.page_layout_invocations = recorded.pages.len();
1935            }
1936            if recorded.stopped_at.is_none() {
1937                if let Some(checkpoint) = restart_checkpoint {
1938                    let references = body_note_references(input);
1939                    paginator::append_endnote_pages_for_references(
1940                        &mut recorded.pages,
1941                        &references,
1942                        &notes,
1943                        final_geometry,
1944                        checkpoint.page_count,
1945                    );
1946                } else {
1947                    paginator::append_endnote_pages(&mut recorded.pages, &notes, final_geometry);
1948                }
1949            }
1950            for page in &mut recorded.pages {
1951                mark_remaining_artifacts(&mut page.elements);
1952            }
1953            let mut pages = restart_checkpoint.map_or_else(Vec::new, |checkpoint| {
1954                self.restart_cache
1955                    .as_ref()
1956                    .expect("a restart checkpoint belongs to retained pages")
1957                    .raw_pages[..checkpoint.page_count]
1958                    .iter()
1959                    .map(Arc::clone)
1960                    .collect()
1961            });
1962            pages.extend(recorded.pages.into_iter().map(Arc::new));
1963            let mut outlines = restart_checkpoint.map_or_else(Vec::new, |checkpoint| {
1964                self.restart_cache
1965                    .as_ref()
1966                    .expect("a restart checkpoint belongs to retained outlines")
1967                    .outlines
1968                    .iter()
1969                    .filter(|outline| outline.page_index < checkpoint.page_count)
1970                    .cloned()
1971                    .collect()
1972            });
1973            outlines.extend(recorded.outlines);
1974            let mut checkpoints = restart_checkpoint.map_or_else(Vec::new, |checkpoint| {
1975                self.restart_cache
1976                    .as_ref()
1977                    .expect("a restart checkpoint belongs to retained state")
1978                    .checkpoints
1979                    .iter()
1980                    .copied()
1981                    .filter(|candidate| candidate.page_count <= checkpoint.page_count)
1982                    .collect()
1983            });
1984            checkpoints.extend(recorded.checkpoints);
1985            if let (Some(stopped), Some((_, old_tail))) = (recorded.stopped_at, tail_source) {
1986                debug_assert_eq!(stopped.page_count, old_tail.page_count);
1987                let cache = self.restart_cache.as_ref().expect("restart cache exists");
1988                pages.extend(
1989                    cache.raw_pages[old_tail.page_count..]
1990                        .iter()
1991                        .map(Arc::clone),
1992                );
1993                outlines.extend(
1994                    cache
1995                        .outlines
1996                        .iter()
1997                        .filter(|outline| outline.page_index >= old_tail.page_count)
1998                        .cloned(),
1999                );
2000                let block_delta =
2001                    stopped.next_block_index as isize - old_tail.next_block_index as isize;
2002                checkpoints.extend(
2003                    cache
2004                        .checkpoints
2005                        .iter()
2006                        .filter(|candidate| candidate.page_count > old_tail.page_count)
2007                        .map(|candidate| paginator::PaginationCheckpoint {
2008                            next_block_index: candidate
2009                                .next_block_index
2010                                .checked_add_signed(block_delta)
2011                                .expect("common suffix block index remains in range"),
2012                            page_count: candidate.page_count,
2013                            next_header_page_number: candidate.next_header_page_number,
2014                        }),
2015                );
2016            }
2017            checkpoints.sort_unstable_by_key(|checkpoint| checkpoint.next_block_index);
2018            checkpoints.dedup();
2019            (pages, outlines, checkpoints)
2020        } else {
2021            let (mut pages, outlines) =
2022                paginator::paginate_shared_sections(&sections, &self.font_manager, &media, &notes);
2023            #[cfg(test)]
2024            {
2025                self.page_layout_invocations = pages.len();
2026            }
2027            // Endnotes read at the end of the document, so they follow the last
2028            // body page rather than sitting at the foot of their reference's page.
2029            paginator::append_endnote_pages(&mut pages, &notes, final_geometry);
2030            apply_page_background(&mut pages, input);
2031            for page in &mut pages {
2032                mark_remaining_artifacts(&mut page.elements);
2033            }
2034            (
2035                pages.into_iter().map(Arc::new).collect(),
2036                outlines,
2037                Vec::new(),
2038            )
2039        };
2040        if body_unchanged
2041            && let Some(cache) = self.restart_cache.as_ref()
2042            && cache.raw_pages.len() == pages.len()
2043        {
2044            for (page, retained) in pages.iter_mut().zip(&cache.raw_pages) {
2045                *page = Arc::clone(retained);
2046            }
2047        }
2048        let mut raw_pages = restart_record_eligible.then(|| pages.clone());
2049
2050        // Post-pagination pass: record bookmark targets and substitute fields.
2051        let total_pages = pages.len();
2052        let bookmark_pages = pages
2053            .iter()
2054            .flat_map(|page| {
2055                let mut targets = Vec::new();
2056                oxml_layout::walk(&page.elements, &mut |element, _| {
2057                    if let PositionedElement::Text(run) = element
2058                        && let Some(FieldKind::Target(target)) = run.field_kind
2059                    {
2060                        targets.push((target, page.page_number));
2061                    }
2062                });
2063                targets
2064            })
2065            .collect::<HashMap<_, _>>();
2066        let page_reference_names = page_reference_names(input);
2067        let mut unresolved_targets = Vec::new();
2068        for page in &pages {
2069            oxml_layout::walk(&page.elements, &mut |element, _| {
2070                if let PositionedElement::Text(run) = element
2071                    && let Some(FieldKind::TargetPage(target)) = run.field_kind
2072                    && !bookmark_pages.contains_key(&target)
2073                {
2074                    unresolved_targets.push(target);
2075                }
2076            });
2077        }
2078        unresolved_targets.sort_unstable();
2079        unresolved_targets.dedup();
2080        for target in unresolved_targets {
2081            let name = page_reference_names
2082                .get(target)
2083                .cloned()
2084                .unwrap_or_else(|| format!("#{target}"));
2085            diagnostics.push(Diagnostic {
2086                message: format!(
2087                    "PAGEREF target {name} did not reach pagination, unresolved placeholder retained"
2088                ),
2089            });
2090        }
2091        let mut bookmark_identity = bookmark_pages
2092            .iter()
2093            .map(|(&target, &page_number)| (target, page_number))
2094            .collect::<Vec<_>>();
2095        bookmark_identity.sort_unstable();
2096        let mut substitution_inputs = Vec::with_capacity(pages.len());
2097        let mut reuse_result_pages = vec![false; pages.len()];
2098        for (page_index, page) in pages.iter_mut().enumerate() {
2099            if !page_has_substitution_state(page) {
2100                reuse_result_pages[page_index] = self.restart_cache.as_ref().is_some_and(|cache| {
2101                    cache.substitution_inputs.get(page_index) == Some(&None)
2102                        && cache
2103                            .raw_pages
2104                            .get(page_index)
2105                            .is_some_and(|retained| Arc::ptr_eq(page, retained))
2106                });
2107                substitution_inputs.push(None);
2108                continue;
2109            }
2110            let inputs = FieldSubstitutionInputs {
2111                page_index,
2112                page_number: page.page_number,
2113                total_pages,
2114                bookmark_pages: bookmark_identity.clone(),
2115                font_identity: font_trace.clone(),
2116                revision_view: input.revision_view,
2117            };
2118            let reusable = self.restart_cache.as_ref().is_some_and(|cache| {
2119                cache
2120                    .substitution_inputs
2121                    .get(page_index)
2122                    .and_then(Option::as_ref)
2123                    == Some(&inputs)
2124                    && cache
2125                        .raw_pages
2126                        .get(page_index)
2127                        .is_some_and(|retained| Arc::ptr_eq(page, retained))
2128            });
2129            if reusable {
2130                reuse_result_pages[page_index] = true;
2131                substitution_inputs.push(Some(inputs));
2132                continue;
2133            }
2134            let page = Arc::make_mut(page);
2135            let page_num = page.page_number;
2136            substitute_fields(
2137                &mut page.elements,
2138                page_num,
2139                total_pages,
2140                &bookmark_pages,
2141                &mut self.font_manager,
2142            );
2143            substitution_inputs.push(Some(inputs));
2144        }
2145
2146        #[cfg(test)]
2147        {
2148            self.last_rebuilt_page_range = Some(0..pages.len());
2149        }
2150        if restart_checkpoint.is_some()
2151            && let Some(cache) = self.restart_cache.as_ref()
2152            && cache.font_trace == font_trace
2153        {
2154            let mut rebuilt_start = pages.len();
2155            let mut rebuilt_end = 0;
2156            for (page_index, (page, retained)) in pages.iter().zip(&cache.raw_pages).enumerate() {
2157                if Arc::ptr_eq(page, retained) {
2158                    reuse_result_pages[page_index] = true;
2159                } else {
2160                    rebuilt_start = rebuilt_start.min(page_index);
2161                    rebuilt_end = page_index + 1;
2162                }
2163            }
2164            if pages.len() != cache.pages.len() {
2165                rebuilt_start = rebuilt_start.min(pages.len().min(cache.pages.len()));
2166                rebuilt_end = pages.len();
2167            }
2168            let rebuilt_range = if rebuilt_start < rebuilt_end {
2169                rebuilt_start..rebuilt_end
2170            } else {
2171                0..0
2172            };
2173            #[cfg(test)]
2174            {
2175                self.last_rebuilt_page_range = Some(rebuilt_range);
2176            }
2177            #[cfg(not(test))]
2178            {
2179                let _ = rebuilt_range;
2180            }
2181        }
2182        // Metrics-only empty carriers must still resolve through the result,
2183        // but they do not get to move a glyph-bearing font earlier in the
2184        // deterministic result order.
2185        let mut carrier_fonts = Vec::new();
2186        fn collect_carrier_fonts(elements: &[PositionedElement], fonts: &mut Vec<FontId>) {
2187            for element in elements {
2188                match element {
2189                    PositionedElement::Text(run)
2190                        if run.text.is_empty() && run.glyph_ids.is_empty() =>
2191                    {
2192                        if !fonts.contains(&run.font_id) {
2193                            fonts.push(run.font_id);
2194                        }
2195                    }
2196                    PositionedElement::Group(group) => {
2197                        collect_carrier_fonts(&group.children, fonts)
2198                    }
2199                    PositionedElement::MarkedContent { children, .. } => {
2200                        collect_carrier_fonts(children, fonts)
2201                    }
2202                    _ => {}
2203                }
2204            }
2205        }
2206        for page in &pages {
2207            collect_carrier_fonts(&page.elements, &mut carrier_fonts);
2208        }
2209        self.font_manager.replay_layout_font_trace(&carrier_fonts);
2210
2211        // Remap persistent manager ids to result-local ids and omit faces that
2212        // are no longer present in the current layout.
2213        let fonts = if self.font_manager.every_loaded_font_is_current() {
2214            self.font_manager.all_font_data()
2215        } else {
2216            let current_fonts = self.font_manager.current_layout_fonts().to_vec();
2217            canonicalize_layout_fonts(&mut pages, &self.font_manager, &current_fonts)?
2218        };
2219        if let Some(cache) = self.restart_cache.as_ref() {
2220            for (page_index, reuse) in reuse_result_pages.into_iter().enumerate() {
2221                if reuse && let Some(retained) = cache.pages.get(page_index) {
2222                    pages[page_index] = Arc::clone(retained);
2223                }
2224            }
2225        }
2226        let mut retained_pages = restart_record_eligible.then(|| pages.clone());
2227
2228        // Convert core properties to document metadata
2229        let metadata = input.core_properties.as_ref().map(|cp| DocumentMetadata {
2230            title: cp.title.clone(),
2231            author: cp.creator.clone(),
2232            subject: cp.subject.clone(),
2233            keywords: cp.keywords.clone(),
2234            creator: Some("rdocx".to_string()),
2235        });
2236
2237        if restart_record_eligible
2238            && pages.len().max(checkpoints.len()) <= RESTART_CACHE_MAX_ENTRIES
2239            && let Some(raw_pages) = raw_pages.as_mut()
2240            && let Some(retained_pages) = retained_pages.as_mut()
2241        {
2242            let old_body = self
2243                .restart_cache
2244                .as_ref()
2245                .map(|cache| cache.body.as_slice());
2246            let prefix = first_changed.unwrap_or(0);
2247            let suffix = common_suffix.unwrap_or(0);
2248            let new_len = input.document.body.content.len();
2249            let old_len = old_body.map_or(0, <[RestartBodyEntry]>::len);
2250            let mut body = input
2251                .document
2252                .body
2253                .content
2254                .iter()
2255                .enumerate()
2256                .filter_map(|(index, content)| {
2257                    if index < prefix {
2258                        return old_body.and_then(|body| body.get(index)).cloned();
2259                    }
2260                    if index >= new_len.saturating_sub(suffix) {
2261                        let old_index = old_len.saturating_sub(new_len - index);
2262                        return old_body.and_then(|body| body.get(old_index)).cloned();
2263                    }
2264                    RestartBodyEntry::for_content(content, input.revision_view)
2265                })
2266                .collect::<Vec<_>>();
2267            let body_complete = body.len() == new_len;
2268            body.shrink_to_fit();
2269            raw_pages.shrink_to_fit();
2270            retained_pages.shrink_to_fit();
2271            substitution_inputs.shrink_to_fit();
2272            outlines.shrink_to_fit();
2273            checkpoints.shrink_to_fit();
2274            font_trace.shrink_to_fit();
2275            let mut candidate = RestartCache {
2276                body,
2277                with_provenance: sources.is_some(),
2278                raw_pages: std::mem::take(raw_pages),
2279                pages: std::mem::take(retained_pages),
2280                substitution_inputs,
2281                outlines: outlines.clone(),
2282                checkpoints,
2283                font_trace,
2284                bytes: 0,
2285            };
2286            candidate.outlines.shrink_to_fit();
2287            for inputs in candidate.substitution_inputs.iter_mut().flatten() {
2288                inputs.bookmark_pages.shrink_to_fit();
2289                inputs.font_identity.shrink_to_fit();
2290            }
2291            let bytes = if body_complete {
2292                restart_cache_bytes(&candidate)
2293            } else {
2294                usize::MAX
2295            };
2296            #[cfg(test)]
2297            {
2298                self.last_restart_candidate_bytes = bytes;
2299            }
2300            let entries = restart_cache_entries(&candidate);
2301            if self.restart_candidate_fits_aggregate(entries, bytes) {
2302                candidate.bytes = bytes;
2303                self.restart_cache = Some(candidate);
2304            } else {
2305                self.restart_cache = None;
2306            }
2307        } else {
2308            self.restart_cache = None;
2309        }
2310        let mut result = LayoutResult::new(pages, fonts, metadata, outlines);
2311        result.diagnostics = diagnostics;
2312        result.structure = Some(structure);
2313        Ok(result)
2314    }
2315
2316    #[allow(clippy::too_many_arguments)]
2317    fn layout_body_paragraph(
2318        &mut self,
2319        paragraph: &CT_P,
2320        content_width: f64,
2321        styles: &CT_Styles,
2322        input: &LayoutInput,
2323        media: &MediaRegistry,
2324        numbering: &mut NumberingState,
2325        diagnostics: &mut Vec<Diagnostic>,
2326        source_node: Option<SourceNodeId>,
2327    ) -> Result<SharedLayoutBlock> {
2328        if !paragraph_is_cache_safe(paragraph, styles) {
2329            // Traversal-sensitive content can change generated state consumed
2330            // by later blocks. The conservative boundary is the first such
2331            // block, after which no retained block is read in this layout.
2332            self.paragraph_cache_reads_enabled = false;
2333            let (block, reflow_direction) = layout_paragraph_with_source_and_direction(
2334                paragraph,
2335                content_width,
2336                styles,
2337                input,
2338                media,
2339                &mut self.font_manager,
2340                numbering,
2341                diagnostics,
2342                source_node,
2343            )?;
2344            return Ok(SharedLayoutBlock::Owned {
2345                block: Box::new(LayoutBlock::Paragraph(block)),
2346                reflow_direction,
2347            });
2348        }
2349
2350        let fingerprint = paragraph_fingerprint(paragraph);
2351        if self.paragraph_cache_reads_enabled
2352            && let Some(entry) = self.paragraph_cache.iter().find(|entry| {
2353                entry.fingerprint == fingerprint
2354                    && entry.key.paragraph == *paragraph
2355                    && entry.key.content_width_bits == content_width.to_bits()
2356                    && entry.key.revision_view == input.revision_view
2357            })
2358        {
2359            diagnostics.extend(entry.diagnostics.iter().cloned());
2360            self.font_manager
2361                .replay_layout_font_trace(&entry.font_trace);
2362            self.paragraph_cache_hits += 1;
2363            return Ok(SharedLayoutBlock::Paragraph {
2364                block: Arc::clone(&entry.block),
2365                semantics: ParagraphSemantics {
2366                    source_node,
2367                    structure_id: None,
2368                    reflow_direction: entry.reflow_direction,
2369                },
2370            });
2371        }
2372
2373        let diagnostics_start = diagnostics.len();
2374        self.font_manager.begin_paragraph_font_trace();
2375        let block_result = layout_paragraph_with_source_and_direction(
2376            paragraph,
2377            content_width,
2378            styles,
2379            input,
2380            media,
2381            &mut self.font_manager,
2382            numbering,
2383            diagnostics,
2384            Some(CACHE_SOURCE_NODE),
2385        );
2386        let font_trace = self.font_manager.finish_paragraph_font_trace();
2387        let (mut block, reflow_direction) = block_result?;
2388        self.paragraph_cache_builds += 1;
2389
2390        let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
2391        if let Some(font_trace) = font_trace {
2392            let bytes = paragraph_cache_entry_bytes(
2393                paragraph,
2394                &block,
2395                &cached_diagnostics,
2396                font_trace.len(),
2397            );
2398            let block = Arc::new(block);
2399            self.stage_paragraph_cache_entry(ParagraphCacheEntry {
2400                fingerprint,
2401                key: ParagraphCacheKey {
2402                    paragraph: paragraph.clone(),
2403                    content_width_bits: content_width.to_bits(),
2404                    revision_view: input.revision_view,
2405                },
2406                block: Arc::clone(&block),
2407                diagnostics: cached_diagnostics,
2408                font_trace,
2409                reflow_direction,
2410                bytes,
2411            });
2412            return Ok(SharedLayoutBlock::Paragraph {
2413                block,
2414                semantics: ParagraphSemantics {
2415                    source_node,
2416                    structure_id: None,
2417                    reflow_direction,
2418                },
2419            });
2420        }
2421
2422        rebind_paragraph_source(&mut block, source_node)?;
2423        Ok(SharedLayoutBlock::Owned {
2424            block: Box::new(LayoutBlock::Paragraph(block)),
2425            reflow_direction,
2426        })
2427    }
2428
2429    #[allow(clippy::too_many_arguments)]
2430    fn layout_body_table(
2431        &mut self,
2432        table: &CT_Tbl,
2433        content_width: f64,
2434        styles: &CT_Styles,
2435        input: &LayoutInput,
2436        media: &MediaRegistry,
2437        numbering: &mut NumberingState,
2438        diagnostics: &mut Vec<Diagnostic>,
2439        sources: Option<&SourceRegistry>,
2440        story: &WordStory,
2441        path: &[usize],
2442    ) -> Result<SharedLayoutBlock> {
2443        if !table_is_cache_safe(table, styles) {
2444            self.paragraph_cache_reads_enabled = false;
2445            return table::layout_table_with_provenance(
2446                table,
2447                content_width,
2448                styles,
2449                input,
2450                media,
2451                &mut self.font_manager,
2452                numbering,
2453                diagnostics,
2454                sources,
2455                story,
2456                path,
2457            )
2458            .map(|(block, semantics)| SharedLayoutBlock::Table {
2459                block: Arc::new(block),
2460                semantics,
2461            });
2462        }
2463
2464        let fingerprint = table_fingerprint(table);
2465        if self.paragraph_cache_reads_enabled
2466            && let Some(entry) = self.table_cache.iter().find(|entry| {
2467                entry.fingerprint == fingerprint
2468                    && entry.key.table == *table
2469                    && entry.key.content_width_bits == content_width.to_bits()
2470                    && entry.key.revision_view == input.revision_view
2471                    && entry.key.with_provenance == sources.is_some()
2472            })
2473        {
2474            diagnostics.extend(entry.diagnostics.iter().cloned());
2475            self.font_manager
2476                .replay_layout_font_trace(&entry.font_trace);
2477            self.table_cache_hits += 1;
2478            return Ok(SharedLayoutBlock::Table {
2479                block: Arc::clone(&entry.block),
2480                semantics: table_semantics(
2481                    table,
2482                    entry.block.as_ref(),
2483                    &entry.semantics,
2484                    sources,
2485                    story,
2486                    path,
2487                ),
2488            });
2489        }
2490
2491        let diagnostics_start = diagnostics.len();
2492        self.font_manager.begin_paragraph_font_trace();
2493        let block_result = table::layout_table_with_provenance(
2494            table,
2495            content_width,
2496            styles,
2497            input,
2498            media,
2499            &mut self.font_manager,
2500            numbering,
2501            diagnostics,
2502            sources,
2503            story,
2504            path,
2505        );
2506        let font_trace = self.font_manager.finish_paragraph_font_trace();
2507        let (mut block, semantics) = block_result?;
2508        self.table_cache_builds += 1;
2509
2510        let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
2511        if let Some(font_trace) = font_trace {
2512            canonicalize_table_sources(&mut block)?;
2513            let key = TableCacheKey {
2514                table: table.clone(),
2515                content_width_bits: content_width.to_bits(),
2516                revision_view: input.revision_view,
2517                with_provenance: sources.is_some(),
2518            };
2519            let bytes = table_cache_entry_bytes(
2520                &key,
2521                &block,
2522                &semantics,
2523                &cached_diagnostics,
2524                font_trace.len(),
2525            );
2526            let block = Arc::new(block);
2527            self.stage_table_cache_entry(TableCacheEntry {
2528                fingerprint,
2529                key,
2530                block: Arc::clone(&block),
2531                semantics: semantics.clone(),
2532                diagnostics: cached_diagnostics,
2533                font_trace,
2534                bytes,
2535            });
2536            return Ok(SharedLayoutBlock::Table { block, semantics });
2537        }
2538        Ok(SharedLayoutBlock::Table {
2539            block: Arc::new(block),
2540            semantics,
2541        })
2542    }
2543
2544    #[cfg(test)]
2545    fn paragraph_cache_counts(&self) -> (usize, usize) {
2546        (self.paragraph_cache_hits, self.paragraph_cache_builds)
2547    }
2548
2549    #[cfg(test)]
2550    fn table_cache_counts(&self) -> (usize, usize) {
2551        (self.table_cache_hits, self.table_cache_builds)
2552    }
2553
2554    #[cfg(test)]
2555    fn owned_context_build_count(&self) -> usize {
2556        self.owned_context_builds
2557    }
2558
2559    #[cfg(test)]
2560    fn hot_path_work_counts(&self) -> (usize, usize) {
2561        (self.body_debug_work, self.retained_page_deep_copies)
2562    }
2563
2564    #[cfg(test)]
2565    fn page_layout_invocation_count(&self) -> usize {
2566        self.page_layout_invocations
2567    }
2568
2569    fn publish_paragraph_cache_entry(&mut self, entry: ParagraphCacheEntry) {
2570        if entry.bytes > PARAGRAPH_CACHE_MAX_BYTES {
2571            return;
2572        }
2573        while self.paragraph_cache.len() >= PARAGRAPH_CACHE_MAX_ENTRIES
2574            || self.paragraph_cache_bytes.saturating_add(entry.bytes) > PARAGRAPH_CACHE_MAX_BYTES
2575        {
2576            let Some(evicted) = self.paragraph_cache.pop_front() else {
2577                break;
2578            };
2579            self.paragraph_cache_bytes = self.paragraph_cache_bytes.saturating_sub(evicted.bytes);
2580        }
2581        let Some(bytes) = self.paragraph_cache_bytes.checked_add(entry.bytes) else {
2582            return;
2583        };
2584        if bytes > PARAGRAPH_CACHE_MAX_BYTES {
2585            return;
2586        }
2587        self.paragraph_cache_bytes = bytes;
2588        self.paragraph_cache.push_back(entry);
2589    }
2590
2591    fn restart_candidate_fits_aggregate(
2592        &self,
2593        candidate_entries: usize,
2594        candidate_bytes: usize,
2595    ) -> bool {
2596        let pending_paragraph_entries = self
2597            .pending_paragraph_cache
2598            .as_ref()
2599            .map_or(0, VecDeque::len);
2600        let pending_table_entries = self.pending_table_cache.as_ref().map_or(0, VecDeque::len);
2601        let pending_header_footer_entries = self
2602            .pending_header_footer_cache
2603            .as_ref()
2604            .map_or(0, VecDeque::len);
2605        let entries = self
2606            .paragraph_cache
2607            .len()
2608            .checked_add(self.table_cache.len())
2609            .and_then(|entries| entries.checked_add(self.header_footer_cache.len()))
2610            .and_then(|entries| entries.checked_add(pending_paragraph_entries))
2611            .and_then(|entries| entries.checked_add(pending_table_entries))
2612            .and_then(|entries| entries.checked_add(pending_header_footer_entries))
2613            .and_then(|entries| entries.checked_add(candidate_entries));
2614        let bytes = self
2615            .paragraph_cache_bytes
2616            .checked_add(self.table_cache_bytes)
2617            .and_then(|bytes| bytes.checked_add(self.header_footer_cache_bytes))
2618            .and_then(|bytes| bytes.checked_add(self.pending_paragraph_cache_bytes))
2619            .and_then(|bytes| bytes.checked_add(self.pending_table_cache_bytes))
2620            .and_then(|bytes| bytes.checked_add(self.pending_header_footer_cache_bytes))
2621            .and_then(|bytes| bytes.checked_add(candidate_bytes));
2622        entries.is_some_and(|entries| entries <= CACHE_MAX_ENTRIES)
2623            && bytes.is_some_and(|bytes| bytes <= CACHE_MAX_BYTES)
2624    }
2625
2626    fn stage_paragraph_cache_entry(&mut self, entry: ParagraphCacheEntry) {
2627        if entry.bytes > PARAGRAPH_CACHE_MAX_BYTES {
2628            return;
2629        }
2630        let Some(pending) = self.pending_paragraph_cache.as_mut() else {
2631            return;
2632        };
2633        while pending.len() >= PARAGRAPH_CACHE_MAX_ENTRIES
2634            || self
2635                .pending_paragraph_cache_bytes
2636                .saturating_add(entry.bytes)
2637                > PARAGRAPH_CACHE_MAX_BYTES
2638        {
2639            let Some(evicted) = pending.pop_front() else {
2640                break;
2641            };
2642            self.pending_paragraph_cache_bytes = self
2643                .pending_paragraph_cache_bytes
2644                .saturating_sub(evicted.bytes);
2645        }
2646        self.pending_paragraph_cache_bytes += entry.bytes;
2647        pending.push_back(entry);
2648        #[cfg(test)]
2649        {
2650            self.pending_paragraph_cache_peak_entries =
2651                self.pending_paragraph_cache_peak_entries.max(pending.len());
2652            self.pending_paragraph_cache_peak_bytes = self
2653                .pending_paragraph_cache_peak_bytes
2654                .max(self.pending_paragraph_cache_bytes);
2655        }
2656    }
2657
2658    fn publish_table_cache_entry(&mut self, entry: TableCacheEntry) {
2659        if entry.bytes > TABLE_CACHE_MAX_BYTES {
2660            return;
2661        }
2662        while self.table_cache.len() >= TABLE_CACHE_MAX_ENTRIES
2663            || self.table_cache_bytes.saturating_add(entry.bytes) > TABLE_CACHE_MAX_BYTES
2664        {
2665            let Some(evicted) = self.table_cache.pop_front() else {
2666                break;
2667            };
2668            self.table_cache_bytes = self.table_cache_bytes.saturating_sub(evicted.bytes);
2669        }
2670        self.table_cache_bytes += entry.bytes;
2671        self.table_cache.push_back(entry);
2672        let restart_entries = self.restart_cache.as_ref().map_or(0, restart_cache_entries);
2673        let restart_bytes = self.restart_cache.as_ref().map_or(0, |cache| cache.bytes);
2674        debug_assert!(
2675            self.paragraph_cache.len() + self.table_cache.len() + restart_entries
2676                <= CACHE_MAX_ENTRIES
2677        );
2678        debug_assert!(
2679            self.paragraph_cache_bytes + self.table_cache_bytes + restart_bytes <= CACHE_MAX_BYTES
2680        );
2681    }
2682
2683    fn stage_table_cache_entry(&mut self, entry: TableCacheEntry) {
2684        if entry.bytes > TABLE_CACHE_MAX_BYTES {
2685            return;
2686        }
2687        let Some(pending) = self.pending_table_cache.as_mut() else {
2688            return;
2689        };
2690        while pending.len() >= TABLE_CACHE_MAX_ENTRIES
2691            || self.pending_table_cache_bytes.saturating_add(entry.bytes) > TABLE_CACHE_MAX_BYTES
2692        {
2693            let Some(evicted) = pending.pop_front() else {
2694                break;
2695            };
2696            self.pending_table_cache_bytes =
2697                self.pending_table_cache_bytes.saturating_sub(evicted.bytes);
2698        }
2699        self.pending_table_cache_bytes += entry.bytes;
2700        pending.push_back(entry);
2701        #[cfg(test)]
2702        {
2703            self.pending_table_cache_peak_entries =
2704                self.pending_table_cache_peak_entries.max(pending.len());
2705            self.pending_table_cache_peak_bytes = self
2706                .pending_table_cache_peak_bytes
2707                .max(self.pending_table_cache_bytes);
2708        }
2709    }
2710
2711    #[cfg(test)]
2712    fn header_footer_cache_counts(&self) -> (usize, usize) {
2713        (
2714            self.header_footer_cache_hits,
2715            self.header_footer_cache_builds,
2716        )
2717    }
2718
2719    fn publish_header_footer_cache_entry(&mut self, entry: HeaderFooterCacheEntry) {
2720        if entry.bytes > HEADER_FOOTER_CACHE_MAX_BYTES {
2721            return;
2722        }
2723        while self.header_footer_cache.len() >= HEADER_FOOTER_CACHE_MAX_ENTRIES
2724            || self.header_footer_cache_bytes.saturating_add(entry.bytes)
2725                > HEADER_FOOTER_CACHE_MAX_BYTES
2726        {
2727            let Some(evicted) = self.header_footer_cache.pop_front() else {
2728                break;
2729            };
2730            self.header_footer_cache_bytes =
2731                self.header_footer_cache_bytes.saturating_sub(evicted.bytes);
2732        }
2733        self.header_footer_cache_bytes += entry.bytes;
2734        self.header_footer_cache.push_back(entry);
2735        let restart_entries = self.restart_cache.as_ref().map_or(0, restart_cache_entries);
2736        let restart_bytes = self.restart_cache.as_ref().map_or(0, |cache| cache.bytes);
2737        debug_assert!(
2738            self.paragraph_cache.len()
2739                + self.table_cache.len()
2740                + self.header_footer_cache.len()
2741                + restart_entries
2742                <= CACHE_MAX_ENTRIES
2743        );
2744        debug_assert!(
2745            self.paragraph_cache_bytes
2746                + self.table_cache_bytes
2747                + self.header_footer_cache_bytes
2748                + restart_bytes
2749                <= CACHE_MAX_BYTES
2750        );
2751    }
2752
2753    fn stage_header_footer_cache_entry(&mut self, entry: HeaderFooterCacheEntry) {
2754        if entry.bytes > HEADER_FOOTER_CACHE_MAX_BYTES {
2755            return;
2756        }
2757        let Some(pending) = self.pending_header_footer_cache.as_mut() else {
2758            return;
2759        };
2760        while pending.len() >= HEADER_FOOTER_CACHE_MAX_ENTRIES
2761            || self
2762                .pending_header_footer_cache_bytes
2763                .saturating_add(entry.bytes)
2764                > HEADER_FOOTER_CACHE_MAX_BYTES
2765        {
2766            let Some(evicted) = pending.pop_front() else {
2767                break;
2768            };
2769            self.pending_header_footer_cache_bytes = self
2770                .pending_header_footer_cache_bytes
2771                .saturating_sub(evicted.bytes);
2772        }
2773        self.pending_header_footer_cache_bytes += entry.bytes;
2774        pending.push_back(entry);
2775        #[cfg(test)]
2776        {
2777            self.pending_header_footer_cache_peak_entries = self
2778                .pending_header_footer_cache_peak_entries
2779                .max(pending.len());
2780            self.pending_header_footer_cache_peak_bytes = self
2781                .pending_header_footer_cache_peak_bytes
2782                .max(self.pending_header_footer_cache_bytes);
2783        }
2784    }
2785}
2786
2787fn paragraph_source_is_cache_safe(
2788    paragraph: &CT_P,
2789    styles: &CT_Styles,
2790    allow_fields: bool,
2791    allow_bookmarks: bool,
2792) -> bool {
2793    let extra_xml_is_represented = paragraph.extra_xml.is_empty()
2794        || (allow_bookmarks && paragraph_bookmark_raw_is_exact(paragraph));
2795    if !paragraph.hyperlinks.is_empty()
2796        || !paragraph.comment_ranges.is_empty()
2797        || (!allow_bookmarks && !paragraph.bookmark_markers.is_empty())
2798        || !extra_xml_is_represented
2799        || !paragraph.content_controls.is_empty()
2800        || !paragraph.revisions.is_empty()
2801    {
2802        return false;
2803    }
2804
2805    let style_id = paragraph
2806        .properties
2807        .as_ref()
2808        .and_then(|properties| properties.style_id.as_deref());
2809    let resolved = style_resolver::resolve_paragraph_properties(style_id, styles);
2810    if resolved.num_id.is_some()
2811        || paragraph.properties.as_ref().is_some_and(|properties| {
2812            properties.num_id.is_some()
2813                || properties.sect_pr.is_some()
2814                || properties.numbering_revision.is_some()
2815                || !properties.numbering_revision_xml.is_empty()
2816                || properties.change.is_some()
2817                || !properties.revision_xml.is_empty()
2818                || properties.rpr.as_ref().is_some_and(|rpr| {
2819                    !rpr.revision_markers.is_empty()
2820                        || rpr.change.is_some()
2821                        || !rpr.revision_xml.is_empty()
2822                        || !rpr.revision_xml_positions.is_empty()
2823                })
2824        })
2825    {
2826        return false;
2827    }
2828
2829    paragraph.runs.iter().all(|run| {
2830        run.alt_drawings.is_empty()
2831            && run.extra_xml.is_empty()
2832            && run.extra_xml_positions.is_empty()
2833            && run.properties.as_ref().is_none_or(|rpr| {
2834                rpr.revision_markers.is_empty()
2835                    && rpr.change.is_none()
2836                    && rpr.revision_xml.is_empty()
2837                    && rpr.revision_xml_positions.is_empty()
2838            })
2839            && run.content.iter().all(|content| match content {
2840                RunContent::Text(_)
2841                | RunContent::Tab
2842                | RunContent::Break(_)
2843                | RunContent::FootnoteRef { .. }
2844                | RunContent::EndnoteRef { .. } => true,
2845                RunContent::Field(_) => allow_fields,
2846                _ => false,
2847            })
2848    })
2849}
2850
2851fn paragraph_is_restart_record_source_safe(paragraph: &CT_P, styles: &CT_Styles) -> bool {
2852    paragraph_source_is_cache_safe(paragraph, styles, true, true)
2853        && paragraph.runs.iter().all(|run| {
2854            run.properties
2855                .as_ref()
2856                .is_none_or(|properties| properties.language.is_none())
2857        })
2858}
2859
2860fn paragraph_is_cache_safe(paragraph: &CT_P, styles: &CT_Styles) -> bool {
2861    paragraph_source_is_cache_safe(paragraph, styles, false, false)
2862}
2863
2864fn paragraph_bookmark_raw_is_exact(paragraph: &CT_P) -> bool {
2865    paragraph.extra_xml.len() == paragraph.bookmark_markers.len()
2866        && paragraph
2867            .extra_xml
2868            .iter()
2869            .enumerate()
2870            .zip(&paragraph.bookmark_markers)
2871            .all(|((raw_index, (run_index, raw)), marker)| {
2872                let raw_before = paragraph.extra_xml[..raw_index]
2873                    .iter()
2874                    .filter(|(at, _)| at == run_index)
2875                    .count();
2876                *run_index == marker.run_index()
2877                    && raw_before == marker.raw_before()
2878                    && raw_xml_is_exact_word_bookmark(raw, marker)
2879            })
2880}
2881
2882fn raw_xml_is_exact_word_bookmark(raw: &[u8], marker: &BookmarkMarker) -> bool {
2883    let Some(start) = raw.iter().position(|byte| !byte.is_ascii_whitespace()) else {
2884        return false;
2885    };
2886    let end = raw
2887        .iter()
2888        .rposition(|byte| !byte.is_ascii_whitespace())
2889        .expect("non-empty raw XML has a final byte");
2890    let raw = &raw[start..=end];
2891    if !raw.ends_with(b"/>") {
2892        return false;
2893    }
2894    let Some((root_name, raw_attributes)) = raw_root_start_tag(raw) else {
2895        return false;
2896    };
2897    let Some(attributes) = parse_raw_attributes(raw_attributes) else {
2898        return false;
2899    };
2900    let expected_local_name = if marker.is_start() {
2901        b"bookmarkStart".as_slice()
2902    } else {
2903        b"bookmarkEnd".as_slice()
2904    };
2905    if xml_local_name(root_name) != expected_local_name
2906        || !raw_name_has_namespace(root_name, &attributes, rdocx_oxml::namespace::W_NS, false)
2907    {
2908        return false;
2909    }
2910    let word_attribute_count = |local: &[u8]| {
2911        attributes
2912            .iter()
2913            .filter(|(name, _)| {
2914                xml_local_name(name) == local
2915                    && raw_name_has_namespace(name, &attributes, rdocx_oxml::namespace::W_NS, true)
2916            })
2917            .count()
2918    };
2919    if word_attribute_count(b"id") != 1
2920        || word_attribute_count(b"name") != usize::from(marker.is_start())
2921    {
2922        return false;
2923    }
2924
2925    let mut document_xml = Vec::with_capacity(raw.len() + 160);
2926    document_xml.extend_from_slice(
2927        br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>"#,
2928    );
2929    document_xml.extend_from_slice(raw);
2930    document_xml.extend_from_slice(b"</w:p></w:body></w:document>");
2931    let Ok(document) = CT_Document::from_xml(&document_xml) else {
2932        return false;
2933    };
2934    let [BodyContent::Paragraph(parsed)] = document.body.content.as_slice() else {
2935        return false;
2936    };
2937    let [(run_index, parsed_raw)] = parsed.extra_xml.as_slice() else {
2938        return false;
2939    };
2940    let [parsed_marker] = parsed.bookmark_markers.as_slice() else {
2941        return false;
2942    };
2943    parsed.properties.is_none()
2944        && parsed.runs.is_empty()
2945        && parsed.hyperlinks.is_empty()
2946        && parsed.comment_ranges.is_empty()
2947        && parsed.content_controls.is_empty()
2948        && parsed.revisions.is_empty()
2949        && *run_index == 0
2950        && parsed_raw == raw
2951        && parsed_marker.raw_before() == 0
2952        && parsed_marker.is_start() == marker.is_start()
2953        && parsed_marker.id().is_some()
2954        && parsed_marker.id() == marker.id()
2955        && parsed_marker.name() == marker.name()
2956        && if parsed_marker.is_start() {
2957            parsed_marker.name().is_some()
2958        } else {
2959            parsed_marker.name().is_none()
2960        }
2961}
2962
2963fn paragraph_has_field(paragraph: &CT_P) -> bool {
2964    paragraph
2965        .runs
2966        .iter()
2967        .flat_map(|run| &run.content)
2968        .any(|content| matches!(content, RunContent::Field(_)))
2969}
2970
2971fn paragraph_has_note_reference(paragraph: &CT_P) -> bool {
2972    paragraph.runs.iter().any(|run| {
2973        run.content.iter().any(|content| {
2974            matches!(
2975                content,
2976                RunContent::FootnoteRef { .. } | RunContent::EndnoteRef { .. }
2977            )
2978        })
2979    })
2980}
2981
2982fn header_footer_part_is_cache_safe(
2983    part: &rdocx_oxml::header_footer::CT_HdrFtr,
2984    styles: &CT_Styles,
2985) -> bool {
2986    if !part.extra_xml.is_empty() {
2987        return false;
2988    }
2989    let raw_watermark_count = part
2990        .paragraphs
2991        .iter()
2992        .flat_map(|paragraph| &paragraph.runs)
2993        .flat_map(|run| &run.extra_xml)
2994        .filter(|raw| raw_xml_root_is_word_pict(raw, &part.extra_namespaces))
2995        .count();
2996    if raw_watermark_count != part.watermarks().len() {
2997        return false;
2998    }
2999    part.paragraphs.iter().all(|paragraph| {
3000        if !paragraph.extra_xml.is_empty() {
3001            return false;
3002        }
3003        let mut projected = paragraph.clone();
3004        for run in &mut projected.runs {
3005            if run.extra_xml.len() != run.extra_xml_positions.len()
3006                || !run
3007                    .extra_xml
3008                    .iter()
3009                    .all(|raw| raw_xml_root_is_word_pict(raw, &part.extra_namespaces))
3010            {
3011                return false;
3012            }
3013            run.extra_xml.clear();
3014            run.extra_xml_positions.clear();
3015        }
3016        !paragraph_has_note_reference(&projected) && paragraph_is_cache_safe(&projected, styles)
3017    })
3018}
3019
3020fn raw_xml_root_is_word_pict(raw: &[u8], namespaces: &[(String, String)]) -> bool {
3021    let raw = raw
3022        .iter()
3023        .position(|byte| !byte.is_ascii_whitespace())
3024        .map_or(raw, |start| &raw[start..]);
3025    let Some((name, attributes)) = raw.strip_prefix(b"<").and_then(|raw| {
3026        let name_end = raw
3027            .iter()
3028            .position(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/'))?;
3029        Some((&raw[..name_end], &raw[name_end..]))
3030    }) else {
3031        return false;
3032    };
3033    let mut components = name.rsplitn(2, |byte| *byte == b':');
3034    if components.next() != Some(b"pict".as_slice()) {
3035        return false;
3036    }
3037    let prefix = components.next();
3038    let declaration = prefix.map_or_else(
3039        || "xmlns".to_owned(),
3040        |prefix| format!("xmlns:{}", String::from_utf8_lossy(prefix)),
3041    );
3042    if let Some(namespace) = raw_xml_start_attribute(attributes, declaration.as_bytes()) {
3043        return namespace == rdocx_oxml::namespace::W_NS.as_bytes();
3044    }
3045    let Some(prefix) = prefix.and_then(|prefix| std::str::from_utf8(prefix).ok()) else {
3046        return false;
3047    };
3048    if prefix == "w" {
3049        return !namespaces.iter().any(|(name, namespace)| {
3050            name != "xmlns:w" && namespace == rdocx_oxml::namespace::W_NS
3051        });
3052    }
3053    namespaces.iter().any(|(name, namespace)| {
3054        name.strip_prefix("xmlns:") == Some(prefix) && namespace == rdocx_oxml::namespace::W_NS
3055    })
3056}
3057
3058fn raw_xml_start_attribute<'a>(mut input: &'a [u8], expected: &[u8]) -> Option<&'a [u8]> {
3059    loop {
3060        input = input
3061            .iter()
3062            .position(|byte| !byte.is_ascii_whitespace())
3063            .map_or(input, |start| &input[start..]);
3064        if input.first().is_none_or(|byte| matches!(byte, b'>' | b'/')) {
3065            return None;
3066        }
3067        let name_end = input
3068            .iter()
3069            .position(|byte| byte.is_ascii_whitespace() || matches!(byte, b'=' | b'>' | b'/'))?;
3070        let name = &input[..name_end];
3071        input = &input[name_end..];
3072        input = input
3073            .iter()
3074            .position(|byte| !byte.is_ascii_whitespace())
3075            .map_or(input, |start| &input[start..]);
3076        if input.first() != Some(&b'=') {
3077            return None;
3078        }
3079        input = &input[1..];
3080        input = input
3081            .iter()
3082            .position(|byte| !byte.is_ascii_whitespace())
3083            .map_or(input, |start| &input[start..]);
3084        let quote = *input.first()?;
3085        if !matches!(quote, b'\'' | b'"') {
3086            return None;
3087        }
3088        input = &input[1..];
3089        let value_end = input.iter().position(|byte| *byte == quote)?;
3090        let value = &input[..value_end];
3091        input = &input[value_end + 1..];
3092        if name == expected {
3093            return Some(value);
3094        }
3095    }
3096}
3097
3098fn header_footer_section_is_cache_safe(section: &CT_SectPr) -> bool {
3099    section.change.is_none() && section.extra_xml.is_empty()
3100}
3101
3102fn table_is_cache_safe(table: &CT_Tbl, styles: &CT_Styles) -> bool {
3103    table.extra_xml.is_empty()
3104        && table.content_controls.is_empty()
3105        && table.properties.as_ref().is_none_or(|properties| {
3106            properties.change.is_none() && properties.revision_xml.is_empty()
3107        })
3108        && table.rows.iter().all(|row| {
3109            row.extra_xml.is_empty()
3110                && row.content_controls.is_empty()
3111                && row.properties.as_ref().is_none_or(|properties| {
3112                    properties.revision_markers.is_empty() && properties.revision_xml.is_empty()
3113                })
3114                && row.cells.iter().all(|cell| {
3115                    cell.extra_xml.is_empty()
3116                        && cell
3117                            .properties
3118                            .as_ref()
3119                            .is_none_or(|properties| properties.extra_xml.is_empty())
3120                        && cell.content.iter().all(|content| match content {
3121                            CellContent::Paragraph(paragraph) => {
3122                                !paragraph_has_note_reference(paragraph)
3123                                    && paragraph_is_cache_safe(paragraph, styles)
3124                            }
3125                            CellContent::Table(table) => table_is_cache_safe(table, styles),
3126                            CellContent::ContentControl(_) => false,
3127                        })
3128                })
3129        })
3130}
3131
3132fn table_semantics(
3133    table: &CT_Tbl,
3134    block: &table::TableBlock,
3135    retained: &TableSemantics,
3136    sources: Option<&SourceRegistry>,
3137    story: &WordStory,
3138    table_path: &[usize],
3139) -> TableSemantics {
3140    let rows = table
3141        .rows
3142        .iter()
3143        .zip(&block.rows)
3144        .zip(&retained.rows)
3145        .enumerate()
3146        .map(|(row_index, ((row, block_row), retained_row))| {
3147            let cells = row
3148                .cells
3149                .iter()
3150                .zip(&block_row.cells)
3151                .zip(&retained_row.cells)
3152                .enumerate()
3153                .map(|(cell_index, ((cell, block_cell), retained_cell))| {
3154                    let blocks = cell
3155                        .content
3156                        .iter()
3157                        .zip(&block_cell.blocks)
3158                        .zip(&retained_cell.blocks)
3159                        .enumerate()
3160                        .map(|(content_index, ((content, block_item), retained_item))| {
3161                            let mut source_path = table_path.to_vec();
3162                            source_path.extend([row_index, cell_index, content_index]);
3163                            match (content, block_item, retained_item) {
3164                                (
3165                                    CellContent::Paragraph(_),
3166                                    table::CellBlock::Paragraph(_),
3167                                    CellBlockSemantics::Paragraph(retained),
3168                                ) => CellBlockSemantics::Paragraph(ParagraphSemantics {
3169                                    source_node: sources
3170                                        .and_then(|sources| sources.id(story, &source_path)),
3171                                    structure_id: None,
3172                                    reflow_direction: retained.reflow_direction,
3173                                }),
3174                                (
3175                                    CellContent::Table(table),
3176                                    table::CellBlock::Table(block),
3177                                    CellBlockSemantics::Table(retained),
3178                                ) => CellBlockSemantics::Table(table_semantics(
3179                                    table,
3180                                    block,
3181                                    retained,
3182                                    sources,
3183                                    story,
3184                                    &source_path,
3185                                )),
3186                                _ => unreachable!("cache-safe table topology stays aligned"),
3187                            }
3188                        })
3189                        .collect();
3190                    block::CellSemantics { blocks }
3191                })
3192                .collect();
3193            block::RowSemantics { cells }
3194        })
3195        .collect();
3196    TableSemantics { rows }
3197}
3198
3199fn canonicalize_table_sources(block: &mut table::TableBlock) -> Result<()> {
3200    for row in &mut block.rows {
3201        for cell in &mut row.cells {
3202            for block in &mut cell.blocks {
3203                match block {
3204                    table::CellBlock::Paragraph(paragraph) => {
3205                        rebind_paragraph_source(paragraph, Some(CACHE_SOURCE_NODE))?;
3206                    }
3207                    table::CellBlock::Table(table) => canonicalize_table_sources(table)?,
3208                }
3209            }
3210        }
3211    }
3212    Ok(())
3213}
3214
3215fn table_cache_entry_bytes(
3216    key: &TableCacheKey,
3217    block: &table::TableBlock,
3218    semantics: &TableSemantics,
3219    diagnostics: &[Diagnostic],
3220    font_trace_len: usize,
3221) -> usize {
3222    let diagnostic_bytes = diagnostics
3223        .len()
3224        .saturating_mul(std::mem::size_of::<Diagnostic>())
3225        .saturating_add(
3226            diagnostics
3227                .iter()
3228                .map(|diagnostic| diagnostic.message.capacity())
3229                .fold(0usize, usize::saturating_add),
3230        );
3231    std::mem::size_of::<TableCacheEntry>()
3232        .saturating_add(table_key_retained_bytes(&key.table))
3233        .saturating_add(table_block_retained_bytes(block))
3234        .saturating_add(table_semantics_retained_bytes(semantics))
3235        .saturating_add(2 * std::mem::size_of::<usize>())
3236        .saturating_add(font_trace_len.saturating_mul(std::mem::size_of::<FontId>()))
3237        .saturating_add(diagnostic_bytes)
3238}
3239
3240fn table_semantics_retained_bytes(semantics: &TableSemantics) -> usize {
3241    let mut bytes = semantics
3242        .rows
3243        .capacity()
3244        .saturating_mul(std::mem::size_of::<block::RowSemantics>());
3245    for row in &semantics.rows {
3246        bytes = bytes.saturating_add(
3247            row.cells
3248                .capacity()
3249                .saturating_mul(std::mem::size_of::<block::CellSemantics>()),
3250        );
3251        for cell in &row.cells {
3252            bytes = bytes.saturating_add(
3253                cell.blocks
3254                    .capacity()
3255                    .saturating_mul(std::mem::size_of::<CellBlockSemantics>()),
3256            );
3257            for block in &cell.blocks {
3258                if let CellBlockSemantics::Table(table) = block {
3259                    bytes = bytes.saturating_add(table_semantics_retained_bytes(table));
3260                }
3261            }
3262        }
3263    }
3264    bytes
3265}
3266
3267fn paragraph_key_retained_bytes(paragraph: &CT_P) -> usize {
3268    fn option_string_bytes(value: &Option<String>) -> usize {
3269        value.as_ref().map_or(0, String::capacity)
3270    }
3271    fn raw_vectors_bytes(values: &[Vec<u8>]) -> usize {
3272        values
3273            .len()
3274            .saturating_mul(std::mem::size_of::<Vec<u8>>())
3275            .saturating_add(
3276                values
3277                    .iter()
3278                    .map(Vec::capacity)
3279                    .fold(0usize, usize::saturating_add),
3280            )
3281    }
3282    fn run_properties_bytes(properties: &CT_RPr) -> usize {
3283        [
3284            &properties.style_id,
3285            &properties.font_ascii,
3286            &properties.font_hansi,
3287            &properties.font_east_asia,
3288            &properties.font_cs,
3289            &properties.font_ascii_theme,
3290            &properties.font_hansi_theme,
3291            &properties.color,
3292            &properties.color_theme,
3293            &properties.vert_align,
3294        ]
3295        .into_iter()
3296        .map(option_string_bytes)
3297        .fold(0usize, usize::saturating_add)
3298        .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3299        .saturating_add(
3300            properties
3301                .revision_markers
3302                .capacity()
3303                .saturating_mul(std::mem::size_of::<CT_Revision>()),
3304        )
3305        .saturating_add(raw_vectors_bytes(&properties.revision_xml))
3306        .saturating_add(
3307            properties
3308                .revision_xml_positions
3309                .capacity()
3310                .saturating_mul(std::mem::size_of::<(u8, usize)>()),
3311        )
3312    }
3313    fn shading_bytes(shading: &CT_Shd) -> usize {
3314        shading
3315            .val
3316            .capacity()
3317            .saturating_add(option_string_bytes(&shading.color))
3318            .saturating_add(option_string_bytes(&shading.fill))
3319    }
3320    fn paragraph_border_bytes(borders: &CT_PBdr) -> usize {
3321        [
3322            &borders.top,
3323            &borders.bottom,
3324            &borders.left,
3325            &borders.right,
3326            &borders.between,
3327            &borders.bar,
3328        ]
3329        .into_iter()
3330        .filter_map(Option::as_ref)
3331        .map(|edge| option_string_bytes(&edge.color))
3332        .fold(0usize, usize::saturating_add)
3333    }
3334    fn paragraph_properties_bytes(properties: &CT_PPr) -> usize {
3335        option_string_bytes(&properties.style_id)
3336            .saturating_add(option_string_bytes(&properties.line_rule))
3337            .saturating_add(
3338                properties
3339                    .borders
3340                    .as_ref()
3341                    .map_or(0, paragraph_border_bytes),
3342            )
3343            .saturating_add(properties.tabs.as_ref().map_or(0, |tabs| {
3344                tabs.tabs
3345                    .capacity()
3346                    .saturating_mul(std::mem::size_of::<CT_TabStop>())
3347            }))
3348            .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3349            .saturating_add(properties.rpr.as_ref().map_or(0, run_properties_bytes))
3350            .saturating_add(raw_vectors_bytes(&properties.numbering_revision_xml))
3351            .saturating_add(raw_vectors_bytes(&properties.revision_xml))
3352    }
3353
3354    let run_bytes = paragraph
3355        .runs
3356        .capacity()
3357        .saturating_mul(std::mem::size_of::<CT_R>())
3358        .saturating_add(
3359            paragraph
3360                .runs
3361                .iter()
3362                .map(|run| {
3363                    run.content
3364                        .capacity()
3365                        .saturating_mul(std::mem::size_of::<RunContent>())
3366                        .saturating_add(
3367                            run.content
3368                                .iter()
3369                                .map(|content| match content {
3370                                    RunContent::Text(text) | RunContent::DeletedText(text) => {
3371                                        text.text.capacity()
3372                                    }
3373                                    RunContent::Tab
3374                                    | RunContent::Break(_)
3375                                    | RunContent::Drawing(_)
3376                                    | RunContent::Field(_)
3377                                    | RunContent::FootnoteRef { .. }
3378                                    | RunContent::EndnoteRef { .. }
3379                                    | RunContent::CommentReference { .. } => 0,
3380                                })
3381                                .fold(0usize, usize::saturating_add),
3382                        )
3383                        .saturating_add(run.properties.as_ref().map_or(0, run_properties_bytes))
3384                        .saturating_add(raw_vectors_bytes(&run.extra_xml))
3385                        .saturating_add(
3386                            run.extra_xml_positions
3387                                .capacity()
3388                                .saturating_mul(std::mem::size_of::<usize>()),
3389                        )
3390                })
3391                .fold(0usize, usize::saturating_add),
3392        );
3393    let paragraph_vectors = paragraph
3394        .hyperlinks
3395        .capacity()
3396        .saturating_mul(std::mem::size_of::<rdocx_oxml::text::HyperlinkSpan>())
3397        .saturating_add(
3398            paragraph
3399                .comment_ranges
3400                .capacity()
3401                .saturating_mul(std::mem::size_of::<rdocx_oxml::text::CommentRangeMarker>()),
3402        )
3403        .saturating_add(
3404            paragraph
3405                .extra_xml
3406                .capacity()
3407                .saturating_mul(std::mem::size_of::<(usize, Vec<u8>)>()),
3408        )
3409        .saturating_add(
3410            paragraph
3411                .extra_xml
3412                .iter()
3413                .map(|(_, raw)| raw.capacity())
3414                .fold(0usize, usize::saturating_add),
3415        );
3416    run_bytes.saturating_add(paragraph_vectors).saturating_add(
3417        paragraph
3418            .properties
3419            .as_ref()
3420            .map_or(0, paragraph_properties_bytes),
3421    )
3422}
3423
3424fn table_key_retained_bytes(table: &CT_Tbl) -> usize {
3425    fn option_string_bytes(value: &Option<String>) -> usize {
3426        value.as_ref().map_or(0, String::capacity)
3427    }
3428    fn raw_entries_bytes(values: &[(usize, Vec<u8>)], capacity: usize) -> usize {
3429        capacity
3430            .saturating_mul(std::mem::size_of::<(usize, Vec<u8>)>())
3431            .saturating_add(
3432                values
3433                    .iter()
3434                    .map(|(_, raw)| raw.capacity())
3435                    .fold(0usize, usize::saturating_add),
3436            )
3437    }
3438    fn raw_vectors_bytes(values: &[Vec<u8>]) -> usize {
3439        values
3440            .len()
3441            .saturating_mul(std::mem::size_of::<Vec<u8>>())
3442            .saturating_add(
3443                values
3444                    .iter()
3445                    .map(Vec::capacity)
3446                    .fold(0usize, usize::saturating_add),
3447            )
3448    }
3449    fn shading_bytes(shading: &CT_Shd) -> usize {
3450        shading
3451            .val
3452            .capacity()
3453            .saturating_add(option_string_bytes(&shading.color))
3454            .saturating_add(option_string_bytes(&shading.fill))
3455    }
3456    fn table_border_bytes(borders: &rdocx_oxml::table::CT_TblBorders) -> usize {
3457        [
3458            &borders.top,
3459            &borders.bottom,
3460            &borders.left,
3461            &borders.right,
3462            &borders.inside_h,
3463            &borders.inside_v,
3464        ]
3465        .into_iter()
3466        .filter_map(Option::as_ref)
3467        .map(|edge| option_string_bytes(&edge.color))
3468        .fold(0usize, usize::saturating_add)
3469    }
3470    fn width_bytes(width: &rdocx_oxml::table::CT_TblWidth) -> usize {
3471        width.width_type.capacity()
3472    }
3473    fn table_properties_bytes(properties: &rdocx_oxml::table::CT_TblPr) -> usize {
3474        option_string_bytes(&properties.style_id)
3475            .saturating_add(option_string_bytes(&properties.layout))
3476            .saturating_add(properties.width.as_ref().map_or(0, width_bytes))
3477            .saturating_add(properties.indent.as_ref().map_or(0, width_bytes))
3478            .saturating_add(properties.borders.as_ref().map_or(0, table_border_bytes))
3479            .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3480            .saturating_add(
3481                properties
3482                    .look
3483                    .as_ref()
3484                    .map_or(0, |look| option_string_bytes(&look.val)),
3485            )
3486            .saturating_add(raw_vectors_bytes(&properties.revision_xml))
3487    }
3488    fn row_properties_bytes(properties: &rdocx_oxml::table::CT_TrPr) -> usize {
3489        option_string_bytes(&properties.height_rule)
3490            .saturating_add(option_string_bytes(&properties.cnf_style))
3491            .saturating_add(
3492                properties
3493                    .revision_markers
3494                    .capacity()
3495                    .saturating_mul(std::mem::size_of::<CT_Revision>()),
3496            )
3497            .saturating_add(raw_vectors_bytes(&properties.revision_xml))
3498    }
3499    fn cell_properties_bytes(properties: &rdocx_oxml::table::CT_TcPr) -> usize {
3500        properties
3501            .width
3502            .as_ref()
3503            .map_or(0, width_bytes)
3504            .saturating_add(properties.borders.as_ref().map_or(0, table_border_bytes))
3505            .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3506            .saturating_add(option_string_bytes(&properties.text_direction))
3507            .saturating_add(option_string_bytes(&properties.cnf_style))
3508            .saturating_add(raw_entries_bytes(
3509                &properties.extra_xml,
3510                properties.extra_xml.capacity(),
3511            ))
3512    }
3513
3514    let grid_bytes = table.grid.as_ref().map_or(0, |grid| {
3515        grid.columns
3516            .capacity()
3517            .saturating_mul(std::mem::size_of::<rdocx_oxml::table::CT_TblGridCol>())
3518    });
3519    table
3520        .rows
3521        .capacity()
3522        .saturating_mul(std::mem::size_of::<CT_Row>())
3523        .saturating_add(grid_bytes)
3524        .saturating_add(
3525            table
3526                .rows
3527                .iter()
3528                .map(|row| {
3529                    row.properties
3530                        .as_ref()
3531                        .map_or(0, row_properties_bytes)
3532                        .saturating_add(raw_entries_bytes(&row.extra_xml, row.extra_xml.capacity()))
3533                        .saturating_add(
3534                            row.content_controls
3535                                .capacity()
3536                                .saturating_mul(std::mem::size_of::<(usize, usize, CT_Sdt)>()),
3537                        )
3538                        .saturating_add(
3539                            row.cells
3540                                .capacity()
3541                                .saturating_mul(std::mem::size_of::<CT_Tc>())
3542                                .saturating_add(
3543                                    row.cells
3544                                        .iter()
3545                                        .map(|cell| {
3546                                            cell.properties
3547                                        .as_ref()
3548                                        .map_or(0, cell_properties_bytes)
3549                                        .saturating_add(raw_entries_bytes(
3550                                            &cell.extra_xml,
3551                                            cell.extra_xml.capacity(),
3552                                        ))
3553                                        .saturating_add(cell.content
3554                                        .capacity()
3555                                        .saturating_mul(std::mem::size_of::<CellContent>())
3556                                        .saturating_add(
3557                                            cell.content
3558                                                .iter()
3559                                                .map(|content| match content {
3560                                                    CellContent::Paragraph(paragraph) => {
3561                                                        paragraph_key_retained_bytes(paragraph)
3562                                                    }
3563                                                    CellContent::Table(table) => {
3564                                                        table_key_retained_bytes(table)
3565                                                    }
3566                                                    CellContent::ContentControl(_) => usize::MAX,
3567                                                })
3568                                                .fold(0usize, usize::saturating_add),
3569                                        ))
3570                                        })
3571                                        .fold(0usize, usize::saturating_add),
3572                                ),
3573                        )
3574                })
3575                .fold(0usize, usize::saturating_add),
3576        )
3577        .saturating_add(table.properties.as_ref().map_or(0, table_properties_bytes))
3578        .saturating_add(raw_entries_bytes(
3579            &table.extra_xml,
3580            table.extra_xml.capacity(),
3581        ))
3582        .saturating_add(
3583            table
3584                .content_controls
3585                .capacity()
3586                .saturating_mul(std::mem::size_of::<(usize, usize, CT_Sdt)>()),
3587        )
3588}
3589
3590fn table_block_retained_bytes(block: &table::TableBlock) -> usize {
3591    fn border_bytes(borders: &rdocx_oxml::table::CT_TblBorders) -> usize {
3592        [
3593            &borders.top,
3594            &borders.bottom,
3595            &borders.left,
3596            &borders.right,
3597            &borders.inside_h,
3598            &borders.inside_v,
3599        ]
3600        .into_iter()
3601        .map(|edge| {
3602            edge.as_ref()
3603                .and_then(|edge| edge.color.as_ref())
3604                .map_or(0, String::capacity)
3605        })
3606        .fold(0usize, usize::saturating_add)
3607    }
3608
3609    let rows = block
3610        .rows
3611        .iter()
3612        .map(|row| {
3613            row.cells
3614                .capacity()
3615                .saturating_mul(std::mem::size_of::<table::TableCell>())
3616                .saturating_add(
3617                    row.cells
3618                        .iter()
3619                        .map(|cell| {
3620                            cell.blocks
3621                                .capacity()
3622                                .saturating_mul(std::mem::size_of::<table::CellBlock>())
3623                                .saturating_add(
3624                                    cell.blocks
3625                                        .iter()
3626                                        .map(|block| match block {
3627                                            table::CellBlock::Paragraph(paragraph) => {
3628                                                paragraph_cache_entry_bytes(
3629                                                    &CT_P::new(),
3630                                                    paragraph,
3631                                                    &[],
3632                                                    0,
3633                                                )
3634                                            }
3635                                            table::CellBlock::Table(table) => {
3636                                                table_block_retained_bytes(table)
3637                                            }
3638                                        })
3639                                        .fold(0usize, usize::saturating_add),
3640                                )
3641                                .saturating_add(cell.borders.as_ref().map_or(0, border_bytes))
3642                        })
3643                        .fold(0usize, usize::saturating_add),
3644                )
3645        })
3646        .fold(0usize, usize::saturating_add);
3647    std::mem::size_of::<table::TableBlock>()
3648        .saturating_add(
3649            block
3650                .col_widths
3651                .capacity()
3652                .saturating_mul(std::mem::size_of::<f64>()),
3653        )
3654        .saturating_add(
3655            block
3656                .rows
3657                .capacity()
3658                .saturating_mul(std::mem::size_of::<table::TableRow>()),
3659        )
3660        .saturating_add(
3661            block
3662                .header_row_indices
3663                .capacity()
3664                .saturating_mul(std::mem::size_of::<usize>()),
3665        )
3666        .saturating_add(rows)
3667        .saturating_add(block.borders.as_ref().map_or(0, border_bytes))
3668}
3669
3670fn canonicalize_layout_fonts(
3671    pages: &mut [Arc<PageFrame>],
3672    font_manager: &FontManager,
3673    current_fonts: &[FontId],
3674) -> Result<Vec<oxml_layout::FontData>> {
3675    fn collect(
3676        elements: &[PositionedElement],
3677        remap: &mut HashMap<FontId, FontId>,
3678        order: &mut Vec<FontId>,
3679    ) {
3680        for element in elements {
3681            match element {
3682                PositionedElement::Text(run) => {
3683                    if let std::collections::hash_map::Entry::Vacant(entry) =
3684                        remap.entry(run.font_id)
3685                    {
3686                        let local = FontId(order.len() as u32);
3687                        entry.insert(local);
3688                        order.push(run.font_id);
3689                    }
3690                }
3691                PositionedElement::MultilingualText(run) => {
3692                    if let std::collections::hash_map::Entry::Vacant(entry) =
3693                        remap.entry(run.font_id)
3694                    {
3695                        let local = FontId(order.len() as u32);
3696                        entry.insert(local);
3697                        order.push(run.font_id);
3698                    }
3699                }
3700                PositionedElement::Group(group) => collect(&group.children, remap, order),
3701                PositionedElement::MarkedContent { children, .. } => {
3702                    collect(children, remap, order)
3703                }
3704                _ => {}
3705            }
3706        }
3707    }
3708
3709    fn rewrite(elements: &mut [PositionedElement], remap: &HashMap<FontId, FontId>) {
3710        for element in elements {
3711            match element {
3712                PositionedElement::Text(run) => {
3713                    run.font_id = remap[&run.font_id];
3714                }
3715                PositionedElement::MultilingualText(run) => {
3716                    run.font_id = remap[&run.font_id];
3717                }
3718                PositionedElement::Group(group) => rewrite(&mut group.children, remap),
3719                PositionedElement::MarkedContent { children, .. } => rewrite(children, remap),
3720                _ => {}
3721            }
3722        }
3723    }
3724
3725    let mut remap = HashMap::new();
3726    let mut order = Vec::with_capacity(current_fonts.len());
3727    for &font_id in current_fonts {
3728        if let std::collections::hash_map::Entry::Vacant(entry) = remap.entry(font_id) {
3729            let local = FontId(order.len() as u32);
3730            entry.insert(local);
3731            order.push(font_id);
3732        }
3733    }
3734    for page in pages.iter() {
3735        collect(&page.elements, &mut remap, &mut order);
3736    }
3737    let mut fonts = Vec::with_capacity(order.len());
3738    for persistent_id in order {
3739        let mut font = font_manager.font_data(persistent_id)?;
3740        font.id = remap[&persistent_id];
3741        fonts.push(font);
3742    }
3743    for page in pages {
3744        rewrite(&mut Arc::make_mut(page).elements, &remap);
3745    }
3746    Ok(fonts)
3747}
3748
3749fn restart_block_is_safe<B: LayoutBlockLike>(block: &B) -> bool {
3750    if let Some(paragraph) = block.paragraph() {
3751        restart_record_block_is_safe(block)
3752            && paragraph.lines.iter().all(|line| {
3753                line.items.iter().all(|item| match item {
3754                    LineItem::Text(text) | LineItem::Marker(text) => text.field_kind.is_none(),
3755                    LineItem::MultilingualText(text) => text.base().field_kind.is_none(),
3756                    LineItem::Tab {
3757                        leader: Some(text), ..
3758                    } => text.field_kind.is_none(),
3759                    _ => true,
3760                })
3761            })
3762    } else {
3763        restart_record_block_is_safe(block)
3764    }
3765}
3766
3767fn restart_record_block_is_safe<B: LayoutBlockLike>(block: &B) -> bool {
3768    if let Some(paragraph) = block.paragraph() {
3769        paragraph.anchored.is_empty()
3770            && paragraph.lines.iter().all(|line| {
3771                line.items.iter().all(|item| match item {
3772                    LineItem::Text(_) | LineItem::Marker(_) => true,
3773                    LineItem::MultilingualText(_) => false,
3774                    LineItem::Tab {
3775                        leader: Some(_), ..
3776                    } => true,
3777                    LineItem::Tab { leader: None, .. } => true,
3778                    LineItem::Image { .. } | LineItem::Group { .. } => false,
3779                    _ => false,
3780                })
3781            })
3782    } else {
3783        block.table().is_some()
3784    }
3785}
3786
3787fn page_has_substitution_state(page: &PageFrame) -> bool {
3788    let mut found = false;
3789    oxml_layout::walk(&page.elements, &mut |element, _| {
3790        found |= match element {
3791            PositionedElement::Text(run) => run.field_kind.is_some(),
3792            PositionedElement::MultilingualText(run) => run.field_kind.is_some(),
3793            _ => false,
3794        };
3795    });
3796    found
3797}
3798
3799fn restart_cache_entries(cache: &RestartCache) -> usize {
3800    cache.raw_pages.len().max(cache.checkpoints.len())
3801}
3802
3803fn restart_cache_bytes(cache: &RestartCache) -> usize {
3804    let vector_bytes = cache
3805        .body
3806        .capacity()
3807        .saturating_mul(std::mem::size_of::<RestartBodyEntry>())
3808        .saturating_add(
3809            cache
3810                .raw_pages
3811                .capacity()
3812                .saturating_add(cache.pages.capacity())
3813                .saturating_mul(std::mem::size_of::<Arc<PageFrame>>()),
3814        )
3815        .saturating_add(
3816            cache
3817                .substitution_inputs
3818                .capacity()
3819                .saturating_mul(std::mem::size_of::<Option<FieldSubstitutionInputs>>()),
3820        )
3821        .saturating_add(
3822            cache
3823                .outlines
3824                .capacity()
3825                .saturating_mul(std::mem::size_of::<oxml_layout::OutlineEntry>()),
3826        )
3827        .saturating_add(
3828            cache
3829                .checkpoints
3830                .capacity()
3831                .saturating_mul(std::mem::size_of::<paginator::PaginationCheckpoint>()),
3832        )
3833        .saturating_add(
3834            cache
3835                .font_trace
3836                .capacity()
3837                .saturating_mul(std::mem::size_of::<FontId>()),
3838        );
3839    let body_bytes = cache
3840        .body
3841        .iter()
3842        .map(RestartBodyEntry::bytes)
3843        .fold(0usize, usize::saturating_add);
3844    debug_assert_eq!(cache.raw_pages.len(), cache.pages.len());
3845    let page_bytes = cache
3846        .raw_pages
3847        .iter()
3848        .zip(&cache.pages)
3849        .map(|(pristine, substituted)| {
3850            let substituted_bytes = if Arc::ptr_eq(pristine, substituted) {
3851                0
3852            } else {
3853                page_frame_retained_bytes(substituted)
3854            };
3855            page_frame_retained_bytes(pristine)
3856                .saturating_add(2 * std::mem::size_of::<usize>())
3857                .saturating_add(substituted_bytes)
3858                .saturating_add(if Arc::ptr_eq(pristine, substituted) {
3859                    0
3860                } else {
3861                    2 * std::mem::size_of::<usize>()
3862                })
3863        })
3864        .fold(0usize, usize::saturating_add);
3865    let outline_bytes = cache
3866        .outlines
3867        .iter()
3868        .map(|outline| outline.title.capacity())
3869        .fold(0usize, usize::saturating_add);
3870    let substitution_bytes = cache
3871        .substitution_inputs
3872        .iter()
3873        .filter_map(Option::as_ref)
3874        .map(|inputs| {
3875            inputs
3876                .bookmark_pages
3877                .capacity()
3878                .saturating_mul(std::mem::size_of::<(usize, usize)>())
3879                .saturating_add(
3880                    inputs
3881                        .font_identity
3882                        .capacity()
3883                        .saturating_mul(std::mem::size_of::<FontId>()),
3884                )
3885        })
3886        .fold(0usize, usize::saturating_add);
3887    std::mem::size_of::<RestartCache>()
3888        .saturating_add(vector_bytes)
3889        .saturating_add(body_bytes)
3890        .saturating_add(page_bytes)
3891        .saturating_add(substitution_bytes)
3892        .saturating_add(outline_bytes)
3893}
3894
3895fn page_frame_retained_bytes(page: &PageFrame) -> usize {
3896    fn glyph_bytes(run: &GlyphRun) -> usize {
3897        run.text
3898            .capacity()
3899            .saturating_add(
3900                run.glyph_ids
3901                    .capacity()
3902                    .saturating_mul(std::mem::size_of::<u16>()),
3903            )
3904            .saturating_add(
3905                run.advances
3906                    .capacity()
3907                    .saturating_mul(std::mem::size_of::<f64>()),
3908            )
3909    }
3910
3911    fn multilingual_glyph_bytes(run: &oxml_layout::MultilingualGlyphRun) -> usize {
3912        run.logical_text
3913            .capacity()
3914            .saturating_add(run.language.as_ref().map_or(0, String::capacity))
3915            .saturating_add(
3916                run.glyph_ids
3917                    .capacity()
3918                    .saturating_mul(std::mem::size_of::<u16>()),
3919            )
3920            .saturating_add(
3921                [
3922                    run.x_advances.capacity(),
3923                    run.y_advances.capacity(),
3924                    run.x_offsets.capacity(),
3925                    run.y_offsets.capacity(),
3926                ]
3927                .into_iter()
3928                .sum::<usize>()
3929                .saturating_mul(std::mem::size_of::<f64>()),
3930            )
3931            .saturating_add(
3932                run.clusters
3933                    .capacity()
3934                    .saturating_mul(std::mem::size_of::<oxml_layout::GlyphCluster>()),
3935            )
3936    }
3937
3938    fn element_bytes(element: &PositionedElement) -> usize {
3939        match element {
3940            PositionedElement::Text(run) => glyph_bytes(run),
3941            PositionedElement::MultilingualText(run) => multilingual_glyph_bytes(run),
3942            PositionedElement::Image {
3943                data, content_type, ..
3944            } => data.capacity().saturating_add(content_type.capacity()),
3945            PositionedElement::LinkAnnotation { url, .. } => url.capacity(),
3946            PositionedElement::Group(group) => group
3947                .children
3948                .capacity()
3949                .saturating_mul(std::mem::size_of::<PositionedElement>())
3950                .saturating_add(
3951                    group
3952                        .children
3953                        .iter()
3954                        .map(element_bytes)
3955                        .fold(0usize, usize::saturating_add),
3956                )
3957                .saturating_add(format!("{:?}", group.effects).len()),
3958            PositionedElement::MarkedContent { children, .. } => children
3959                .capacity()
3960                .saturating_mul(std::mem::size_of::<PositionedElement>())
3961                .saturating_add(
3962                    children
3963                        .iter()
3964                        .map(element_bytes)
3965                        .fold(0usize, usize::saturating_add),
3966                ),
3967            PositionedElement::Path(path) => format!("{path:?}").len(),
3968            _ => 0,
3969        }
3970    }
3971
3972    std::mem::size_of::<PageFrame>()
3973        .saturating_add(
3974            page.elements
3975                .capacity()
3976                .saturating_mul(std::mem::size_of::<PositionedElement>()),
3977        )
3978        .saturating_add(
3979            page.elements
3980                .iter()
3981                .map(element_bytes)
3982                .fold(0usize, usize::saturating_add),
3983        )
3984        .saturating_add(format!("{:?}", page.background).len())
3985}
3986
3987fn rebind_text_source(text: &mut TextSegment, source_node: Option<SourceNodeId>) {
3988    match (text.source.as_mut(), source_node) {
3989        (Some(source), Some(node)) => source.node = node,
3990        (Some(_), None) => text.source = None,
3991        (None, _) => {}
3992    }
3993}
3994
3995fn rebind_multilingual_source(
3996    text: &mut oxml_layout::MultilingualTextSegment,
3997    source_node: Option<SourceNodeId>,
3998) -> Result<()> {
3999    let mut base = text.base().clone();
4000    rebind_text_source(&mut base, source_node);
4001    *text = oxml_layout::MultilingualTextSegment::new(
4002        base,
4003        text.logical_index(),
4004        text.language().map(str::to_owned),
4005        text.script(),
4006        text.direction(),
4007        text.bidi_level(),
4008        text.x_advances().to_vec(),
4009        text.y_advances().to_vec(),
4010        text.x_offsets().to_vec(),
4011        text.y_offsets().to_vec(),
4012        text.clusters().to_vec(),
4013        text.break_after(),
4014    )?;
4015    Ok(())
4016}
4017
4018fn rebind_paragraph_source(
4019    block: &mut ParagraphBlock,
4020    source_node: Option<SourceNodeId>,
4021) -> Result<()> {
4022    for line in &mut block.lines {
4023        for item in &mut line.items {
4024            match item {
4025                LineItem::Text(text) | LineItem::Marker(text) => {
4026                    rebind_text_source(text, source_node)
4027                }
4028                LineItem::MultilingualText(text) => rebind_multilingual_source(text, source_node)?,
4029                LineItem::Tab {
4030                    leader: Some(leader),
4031                    ..
4032                } => rebind_text_source(leader, source_node),
4033                _ => {}
4034            }
4035        }
4036    }
4037    if let Some(reflow) = block.reflow.as_mut() {
4038        for item in &mut reflow.items {
4039            match item {
4040                InlineItem::Text(text) | InlineItem::Marker(text) => {
4041                    rebind_text_source(text, source_node)
4042                }
4043                InlineItem::MultilingualText(text) => {
4044                    rebind_multilingual_source(text, source_node)?
4045                }
4046                InlineItem::HyphenatedText { segment, .. } => {
4047                    rebind_text_source(segment, source_node)
4048                }
4049                _ => {}
4050            }
4051        }
4052    }
4053    Ok(())
4054}
4055
4056fn rebind_header_footer_sources(
4057    story_kind: HeaderFooterStoryKind,
4058    relationship_id: &str,
4059    part: &rdocx_oxml::header_footer::CT_HdrFtr,
4060    blocks: &mut [ParagraphBlock],
4061    sources: Option<&SourceRegistry>,
4062) -> Result<()> {
4063    let story = match story_kind {
4064        HeaderFooterStoryKind::Header => WordStory::Header {
4065            relationship_id: relationship_id.to_owned(),
4066        },
4067        HeaderFooterStoryKind::Footer => WordStory::Footer {
4068            relationship_id: relationship_id.to_owned(),
4069        },
4070    };
4071    for (paragraph_index, block) in blocks.iter_mut().enumerate() {
4072        debug_assert!(paragraph_index < part.paragraphs.len());
4073        let source = sources.and_then(|sources| sources.id(&story, &[paragraph_index]));
4074        rebind_paragraph_source(block, source)?;
4075    }
4076    Ok(())
4077}
4078
4079fn header_footer_cache_entry_bytes(
4080    key: &HeaderFooterCacheKey,
4081    content: &HeaderFooterVariantContent,
4082    diagnostics: &Vec<Diagnostic>,
4083    font_trace: &Vec<FontId>,
4084) -> usize {
4085    let block_bytes = key
4086        .part
4087        .paragraphs
4088        .iter()
4089        .zip(&content.blocks)
4090        .map(|(paragraph, block)| paragraph_cache_entry_bytes(paragraph, block, &[], 0))
4091        .fold(0usize, usize::saturating_add);
4092    let direction_bytes = content
4093        .directions
4094        .capacity()
4095        .saturating_mul(std::mem::size_of::<TextDirection>());
4096    let diagnostic_bytes = diagnostics
4097        .capacity()
4098        .saturating_mul(std::mem::size_of::<Diagnostic>())
4099        .saturating_add(
4100            diagnostics
4101                .iter()
4102                .map(|diagnostic| diagnostic.message.capacity())
4103                .fold(0usize, usize::saturating_add),
4104        );
4105    let watermark_bytes = content.watermark.as_ref().map_or(0, |watermark| {
4106        page_frame_retained_bytes(&PageFrame::new(
4107            1,
4108            0.0,
4109            0.0,
4110            vec![PositionedElement::Group(watermark.clone())],
4111        ))
4112    });
4113    let section_capacity = key
4114        .section
4115        .header_refs
4116        .capacity()
4117        .saturating_mul(std::mem::size_of::<rdocx_oxml::header_footer::HdrFtrRef>())
4118        .saturating_add(
4119            key.section
4120                .footer_refs
4121                .capacity()
4122                .saturating_mul(std::mem::size_of::<rdocx_oxml::header_footer::HdrFtrRef>()),
4123        )
4124        .saturating_add(key.section.columns.as_ref().map_or(0, |columns| {
4125            columns
4126                .columns
4127                .capacity()
4128                .saturating_mul(std::mem::size_of::<rdocx_oxml::document::CT_Column>())
4129        }))
4130        .saturating_add(
4131            key.section
4132                .extra_xml
4133                .capacity()
4134                .saturating_mul(std::mem::size_of::<Vec<u8>>()),
4135        )
4136        .saturating_add(
4137            key.section
4138                .header_refs
4139                .iter()
4140                .chain(&key.section.footer_refs)
4141                .map(|reference| reference.rel_id.capacity())
4142                .fold(0usize, usize::saturating_add),
4143        )
4144        .saturating_add(
4145            key.section
4146                .extra_xml
4147                .iter()
4148                .map(Vec::capacity)
4149                .fold(0usize, usize::saturating_add),
4150        );
4151    let paragraph_raw_capacity = key
4152        .part
4153        .paragraphs
4154        .iter()
4155        .map(|paragraph| {
4156            paragraph
4157                .extra_xml
4158                .capacity()
4159                .saturating_mul(std::mem::size_of::<(usize, Vec<u8>)>())
4160                .saturating_add(
4161                    paragraph
4162                        .extra_xml
4163                        .iter()
4164                        .map(|(_, raw)| raw.capacity())
4165                        .fold(0usize, usize::saturating_add),
4166                )
4167                .saturating_add(
4168                    paragraph
4169                        .runs
4170                        .iter()
4171                        .map(|run| {
4172                            run.extra_xml
4173                                .capacity()
4174                                .saturating_mul(std::mem::size_of::<Vec<u8>>())
4175                                .saturating_add(
4176                                    run.extra_xml
4177                                        .iter()
4178                                        .map(Vec::capacity)
4179                                        .fold(0usize, usize::saturating_add),
4180                                )
4181                                .saturating_add(
4182                                    run.extra_xml_positions
4183                                        .capacity()
4184                                        .saturating_mul(std::mem::size_of::<usize>()),
4185                                )
4186                        })
4187                        .fold(0usize, usize::saturating_add),
4188                )
4189        })
4190        .fold(0usize, usize::saturating_add);
4191    let watermark_capacity = key
4192        .part
4193        .watermarks()
4194        .iter()
4195        .map(|watermark| {
4196            std::mem::size_of::<VmlWatermark>().saturating_add(match watermark {
4197                VmlWatermark::Text {
4198                    text,
4199                    color,
4200                    font_family,
4201                    ..
4202                } => text
4203                    .capacity()
4204                    .saturating_add(color.capacity())
4205                    .saturating_add(font_family.as_ref().map_or(0, String::capacity)),
4206                VmlWatermark::Image {
4207                    relationship_id, ..
4208                } => relationship_id.capacity(),
4209            })
4210        })
4211        .fold(0usize, usize::saturating_add);
4212    let part_capacity = key
4213        .part
4214        .paragraphs
4215        .capacity()
4216        .saturating_mul(std::mem::size_of::<CT_P>())
4217        .saturating_add(
4218            content
4219                .blocks
4220                .capacity()
4221                .saturating_mul(std::mem::size_of::<ParagraphBlock>()),
4222        )
4223        .saturating_add(
4224            key.part
4225                .extra_namespaces
4226                .capacity()
4227                .saturating_mul(std::mem::size_of::<(String, String)>()),
4228        )
4229        .saturating_add(
4230            key.part
4231                .extra_xml
4232                .capacity()
4233                .saturating_mul(std::mem::size_of::<Vec<u8>>()),
4234        )
4235        .saturating_add(
4236            key.part
4237                .extra_namespaces
4238                .iter()
4239                .map(|(prefix, namespace)| prefix.capacity().saturating_add(namespace.capacity()))
4240                .fold(0usize, usize::saturating_add),
4241        )
4242        .saturating_add(
4243            key.part
4244                .extra_xml
4245                .iter()
4246                .map(Vec::capacity)
4247                .fold(0usize, usize::saturating_add),
4248        );
4249    std::mem::size_of::<HeaderFooterCacheEntry>()
4250        .saturating_add(key.relationship_id.capacity())
4251        .saturating_add(key.resolved_part_bytes.capacity())
4252        .saturating_add(format!("{:?}", key.section).len())
4253        .saturating_add(format!("{:?}", key.part).len())
4254        .saturating_add(section_capacity)
4255        .saturating_add(part_capacity)
4256        .saturating_add(paragraph_raw_capacity)
4257        .saturating_add(watermark_capacity)
4258        .saturating_add(block_bytes)
4259        .saturating_add(direction_bytes)
4260        .saturating_add(watermark_bytes)
4261        .saturating_add(diagnostic_bytes)
4262        .saturating_add(
4263            font_trace
4264                .capacity()
4265                .saturating_mul(std::mem::size_of::<FontId>()),
4266        )
4267}
4268
4269fn paragraph_cache_entry_bytes(
4270    paragraph: &CT_P,
4271    block: &ParagraphBlock,
4272    diagnostics: &[Diagnostic],
4273    font_trace_len: usize,
4274) -> usize {
4275    fn option_string_bytes(value: &Option<String>) -> usize {
4276        value.as_ref().map_or(0, String::capacity)
4277    }
4278    fn shading_bytes(shading: &CT_Shd) -> usize {
4279        shading
4280            .val
4281            .capacity()
4282            .saturating_add(option_string_bytes(&shading.color))
4283            .saturating_add(option_string_bytes(&shading.fill))
4284    }
4285    fn run_properties_bytes(properties: &CT_RPr) -> usize {
4286        [
4287            &properties.style_id,
4288            &properties.font_ascii,
4289            &properties.font_hansi,
4290            &properties.font_east_asia,
4291            &properties.font_cs,
4292            &properties.font_ascii_theme,
4293            &properties.font_hansi_theme,
4294            &properties.color,
4295            &properties.color_theme,
4296            &properties.vert_align,
4297        ]
4298        .into_iter()
4299        .map(option_string_bytes)
4300        .fold(0usize, usize::saturating_add)
4301        .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
4302    }
4303    fn border_bytes(borders: &CT_PBdr) -> usize {
4304        [
4305            &borders.top,
4306            &borders.bottom,
4307            &borders.left,
4308            &borders.right,
4309            &borders.between,
4310            &borders.bar,
4311        ]
4312        .into_iter()
4313        .map(|edge| {
4314            edge.as_ref()
4315                .and_then(|edge| edge.color.as_ref())
4316                .map_or(0, String::capacity)
4317        })
4318        .fold(0usize, usize::saturating_add)
4319    }
4320    fn paragraph_properties_bytes(properties: &CT_PPr) -> usize {
4321        option_string_bytes(&properties.style_id)
4322            .saturating_add(option_string_bytes(&properties.line_rule))
4323            .saturating_add(properties.borders.as_ref().map_or(0, border_bytes))
4324            .saturating_add(properties.tabs.as_ref().map_or(0, |tabs| {
4325                tabs.tabs
4326                    .capacity()
4327                    .saturating_mul(std::mem::size_of::<CT_TabStop>())
4328            }))
4329            .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
4330            .saturating_add(properties.rpr.as_ref().map_or(0, run_properties_bytes))
4331    }
4332    fn paragraph_key_bytes(paragraph: &CT_P) -> usize {
4333        paragraph
4334            .runs
4335            .capacity()
4336            .saturating_mul(std::mem::size_of::<CT_R>())
4337            .saturating_add(
4338                paragraph
4339                    .runs
4340                    .iter()
4341                    .map(|run| {
4342                        run.content
4343                            .capacity()
4344                            .saturating_mul(std::mem::size_of::<RunContent>())
4345                            .saturating_add(
4346                                run.content
4347                                    .iter()
4348                                    .map(|content| match content {
4349                                        RunContent::Text(text) => text.text.capacity(),
4350                                        _ => 0,
4351                                    })
4352                                    .fold(0usize, usize::saturating_add),
4353                            )
4354                            .saturating_add(run.properties.as_ref().map_or(0, run_properties_bytes))
4355                    })
4356                    .fold(0usize, usize::saturating_add),
4357            )
4358            .saturating_add(
4359                paragraph
4360                    .properties
4361                    .as_ref()
4362                    .map_or(0, paragraph_properties_bytes),
4363            )
4364    }
4365    fn text_bytes(text: &TextSegment) -> usize {
4366        text.text.capacity()
4367            + text.glyph_ids.capacity() * std::mem::size_of::<u16>()
4368            + text.advances.capacity() * std::mem::size_of::<f64>()
4369            + text.hyperlink_url.as_ref().map_or(0, String::capacity)
4370    }
4371    fn inline_bytes(item: &InlineItem) -> usize {
4372        match item {
4373            InlineItem::Text(text) | InlineItem::Marker(text) => text_bytes(text),
4374            InlineItem::MultilingualText(_) => usize::MAX,
4375            InlineItem::Group { .. } => usize::MAX,
4376            _ => 0,
4377        }
4378    }
4379    fn line_item_bytes(item: &LineItem) -> usize {
4380        match item {
4381            LineItem::Text(text) | LineItem::Marker(text) => text_bytes(text),
4382            LineItem::MultilingualText(_) => usize::MAX,
4383            LineItem::Tab { leader, .. } => leader.as_ref().map_or(0, text_bytes),
4384            LineItem::Group { .. } => usize::MAX,
4385            _ => 0,
4386        }
4387    }
4388
4389    let paragraph_bytes = paragraph_key_bytes(paragraph);
4390    let line_bytes = block
4391        .lines
4392        .capacity()
4393        .saturating_mul(std::mem::size_of::<oxml_layout::LayoutLine>())
4394        .saturating_add(
4395            block
4396                .lines
4397                .iter()
4398                .map(|line| {
4399                    line.items
4400                        .capacity()
4401                        .saturating_mul(std::mem::size_of::<LineItem>())
4402                        .saturating_add(
4403                            line.items
4404                                .iter()
4405                                .map(line_item_bytes)
4406                                .fold(0usize, usize::saturating_add),
4407                        )
4408                })
4409                .fold(0usize, usize::saturating_add),
4410        );
4411    let reflow_bytes = block.reflow.as_ref().map_or(0, |reflow| {
4412        std::mem::size_of_val(reflow.as_ref())
4413            .saturating_add(
4414                reflow
4415                    .items
4416                    .capacity()
4417                    .saturating_mul(std::mem::size_of::<InlineItem>()),
4418            )
4419            .saturating_add(
4420                reflow
4421                    .items
4422                    .iter()
4423                    .map(inline_bytes)
4424                    .fold(0usize, usize::saturating_add),
4425            )
4426            .saturating_add(
4427                reflow
4428                    .params
4429                    .tab_stops
4430                    .capacity()
4431                    .saturating_mul(std::mem::size_of::<oxml_layout::TabStop>()),
4432            )
4433            .saturating_add(
4434                reflow
4435                    .params
4436                    .line_prefix_widths
4437                    .capacity()
4438                    .saturating_mul(std::mem::size_of::<f64>()),
4439            )
4440            .saturating_add(
4441                reflow
4442                    .params
4443                    .line_suffix_widths
4444                    .capacity()
4445                    .saturating_mul(std::mem::size_of::<f64>()),
4446            )
4447    });
4448    let diagnostic_bytes = diagnostics
4449        .len()
4450        .saturating_mul(std::mem::size_of::<Diagnostic>())
4451        .saturating_add(
4452            diagnostics
4453                .iter()
4454                .map(|diagnostic| diagnostic.message.capacity())
4455                .fold(0usize, usize::saturating_add),
4456        );
4457    std::mem::size_of::<ParagraphCacheEntry>()
4458        .saturating_add(arc_allocation_bytes::<ParagraphBlock>())
4459        .saturating_add(paragraph_bytes)
4460        .saturating_add(line_bytes)
4461        .saturating_add(reflow_bytes)
4462        .saturating_add(if block.anchored.is_empty() {
4463            0
4464        } else {
4465            usize::MAX
4466        })
4467        .saturating_add(block.heading_text.as_ref().map_or(0, String::capacity))
4468        .saturating_add(block.borders.as_ref().map_or(0, border_bytes))
4469        .saturating_add(font_trace_len * std::mem::size_of::<FontId>())
4470        .saturating_add(diagnostic_bytes)
4471}
4472
4473struct StableFingerprint(u64);
4474
4475impl StableFingerprint {
4476    fn new() -> Self {
4477        Self(0xcbf2_9ce4_8422_2325)
4478    }
4479
4480    fn write_bytes(&mut self, value: &[u8]) {
4481        for byte in value {
4482            self.0 ^= u64::from(*byte);
4483            self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
4484        }
4485    }
4486
4487    fn write_usize(&mut self, value: usize) {
4488        self.write_bytes(&value.to_le_bytes());
4489    }
4490
4491    fn write_tag(&mut self, value: u8) {
4492        self.write_bytes(&[value]);
4493    }
4494}
4495
4496fn paragraph_fingerprint(paragraph: &CT_P) -> u64 {
4497    let mut fingerprint = StableFingerprint::new();
4498    fingerprint.write_usize(paragraph.runs.len());
4499    fingerprint.write_tag(u8::from(paragraph.properties.is_some()));
4500    for run in &paragraph.runs {
4501        fingerprint.write_tag(u8::from(run.properties.is_some()));
4502        fingerprint.write_usize(run.content.len());
4503        for content in &run.content {
4504            match content {
4505                RunContent::Text(text) => {
4506                    fingerprint.write_tag(0);
4507                    fingerprint.write_bytes(text.text.as_bytes());
4508                    fingerprint.write_tag(u8::from(text.preserve_space));
4509                }
4510                RunContent::DeletedText(text) => {
4511                    fingerprint.write_tag(1);
4512                    fingerprint.write_bytes(text.text.as_bytes());
4513                    fingerprint.write_tag(u8::from(text.preserve_space));
4514                }
4515                RunContent::Tab => fingerprint.write_tag(2),
4516                RunContent::Break(kind) => {
4517                    fingerprint.write_tag(3);
4518                    fingerprint.write_tag(match kind {
4519                        BreakType::Line => 0,
4520                        BreakType::Page => 1,
4521                        BreakType::Column => 2,
4522                    });
4523                }
4524                RunContent::Drawing(_)
4525                | RunContent::Field(_)
4526                | RunContent::FootnoteRef { .. }
4527                | RunContent::EndnoteRef { .. }
4528                | RunContent::CommentReference { .. } => fingerprint.write_tag(4),
4529            }
4530        }
4531    }
4532    fingerprint.0
4533}
4534
4535fn table_fingerprint(table: &CT_Tbl) -> u64 {
4536    fn write_table(table: &CT_Tbl, fingerprint: &mut StableFingerprint) {
4537        fingerprint.write_tag(u8::from(table.properties.is_some()));
4538        fingerprint.write_usize(table.grid.as_ref().map_or(0, |grid| grid.columns.len()));
4539        fingerprint.write_usize(table.rows.len());
4540        for row in &table.rows {
4541            fingerprint.write_tag(u8::from(row.properties.is_some()));
4542            fingerprint.write_usize(row.cells.len());
4543            for cell in &row.cells {
4544                fingerprint.write_tag(u8::from(cell.properties.is_some()));
4545                fingerprint.write_usize(cell.content.len());
4546                for content in &cell.content {
4547                    match content {
4548                        CellContent::Paragraph(paragraph) => {
4549                            fingerprint.write_tag(0);
4550                            fingerprint
4551                                .write_bytes(&paragraph_fingerprint(paragraph).to_le_bytes());
4552                        }
4553                        CellContent::Table(table) => {
4554                            fingerprint.write_tag(1);
4555                            write_table(table, fingerprint);
4556                        }
4557                        CellContent::ContentControl(_) => fingerprint.write_tag(2),
4558                    }
4559                }
4560            }
4561        }
4562    }
4563
4564    let mut fingerprint = StableFingerprint::new();
4565    write_table(table, &mut fingerprint);
4566    fingerprint.0
4567}
4568
4569/// Apply page background color from `w:background` element to all pages.
4570fn apply_page_background(pages: &mut [PageFrame], input: &LayoutInput) {
4571    let bg_xml = match &input.document.background_xml {
4572        Some(xml) => xml,
4573        None => return,
4574    };
4575
4576    // Parse w:color attribute from background XML
4577    let xml_str = std::str::from_utf8(bg_xml).unwrap_or("");
4578    let color = extract_background_color(xml_str);
4579    let color = match color {
4580        Some(c) => c,
4581        None => return,
4582    };
4583
4584    // Insert a full-page FilledRect at position 0 on every page (renders underneath everything)
4585    for page in pages.iter_mut() {
4586        page.elements.insert(
4587            0,
4588            PositionedElement::FilledRect {
4589                rect: Rect {
4590                    x: 0.0,
4591                    y: 0.0,
4592                    width: page.width,
4593                    height: page.height,
4594                },
4595                color,
4596            },
4597        );
4598    }
4599}
4600
4601/// Extract the background color hex from w:background XML.
4602fn extract_background_color(xml: &str) -> Option<Color> {
4603    // Look for w:color="RRGGBB" or color="RRGGBB"
4604    for attr in ["w:color=\"", "color=\""] {
4605        if let Some(start) = xml.find(attr) {
4606            let val_start = start + attr.len();
4607            if let Some(end) = xml[val_start..].find('"') {
4608                let hex = &xml[val_start..val_start + end];
4609                if hex.len() == 6 && hex != "auto" {
4610                    return Some(Color::from_hex(hex));
4611                }
4612            }
4613        }
4614    }
4615    None
4616}
4617
4618/// Replace field placeholder GlyphRuns with actual values.
4619fn substitute_fields(
4620    elements: &mut Vec<PositionedElement>,
4621    page_number: usize,
4622    total_pages: usize,
4623    bookmark_pages: &HashMap<usize, usize>,
4624    fm: &mut FontManager,
4625) {
4626    for element in elements.iter_mut() {
4627        match element {
4628            PositionedElement::Text(run) => {
4629                let Some(fk) = run.field_kind else {
4630                    continue;
4631                };
4632                let value = match fk {
4633                    FieldKind::Page => page_number.to_string(),
4634                    FieldKind::NumPages => total_pages.to_string(),
4635                    FieldKind::TargetPage(target) => {
4636                        let Some(page) = bookmark_pages.get(&target) else {
4637                            run.field_kind = None;
4638                            continue;
4639                        };
4640                        page.to_string()
4641                    }
4642                    FieldKind::Target(_) => continue,
4643                };
4644                if let Ok(shaped) = fm.shape_text(run.font_id, &value, run.font_size) {
4645                    run.text = value;
4646                    run.glyph_ids = shaped.glyph_ids;
4647                    run.advances = shaped.advances;
4648                }
4649            }
4650            PositionedElement::Group(group) => substitute_fields(
4651                &mut group.children,
4652                page_number,
4653                total_pages,
4654                bookmark_pages,
4655                fm,
4656            ),
4657            PositionedElement::MarkedContent { children, .. } => {
4658                substitute_fields(children, page_number, total_pages, bookmark_pages, fm)
4659            }
4660            _ => {}
4661        }
4662    }
4663    elements.retain(|element| match element {
4664        PositionedElement::Text(run) => !matches!(run.field_kind, Some(FieldKind::Target(_))),
4665        PositionedElement::MarkedContent { children, .. } => !children.is_empty(),
4666        _ => true,
4667    });
4668}
4669
4670fn mark_remaining_artifacts(elements: &mut Vec<PositionedElement>) {
4671    let unmarked = std::mem::take(elements);
4672    *elements = unmarked
4673        .into_iter()
4674        .map(|element| match element {
4675            PositionedElement::MarkedContent { .. } | PositionedElement::LinkAnnotation { .. } => {
4676                element
4677            }
4678            _ => PositionedElement::MarkedContent {
4679                structure: None,
4680                children: vec![element],
4681            },
4682        })
4683        .collect();
4684}
4685
4686struct StructureBuilder {
4687    nodes: Vec<StructureNode>,
4688}
4689
4690impl StructureBuilder {
4691    fn add(&mut self, role: StructureRole, parent: Option<StructureId>) -> StructureId {
4692        let id = StructureId::new(self.nodes.len() as u32 + 1)
4693            .expect("a structure node index is always non-zero");
4694        self.nodes.push(StructureNode {
4695            id,
4696            role,
4697            children: Vec::new(),
4698            alternate_text: None,
4699        });
4700        if let Some(parent) = parent
4701            && let Some(node) = self.nodes.get_mut(parent.get() as usize - 1)
4702        {
4703            node.children.push(id);
4704        }
4705        id
4706    }
4707
4708    fn set_alternate_text(&mut self, id: StructureId, text: String) {
4709        if let Some(node) = self.nodes.get_mut(id.get() as usize - 1) {
4710            node.alternate_text = Some(text);
4711        }
4712    }
4713}
4714
4715#[derive(Clone, Copy)]
4716struct ListFrame {
4717    num_id: u32,
4718    level: u8,
4719    list: StructureId,
4720    last_item: Option<StructureId>,
4721}
4722
4723fn assign_shared_document_structure(
4724    sections: &mut [paginator::SharedSection],
4725) -> DocumentStructure {
4726    let mut builder = StructureBuilder { nodes: Vec::new() };
4727    let root = builder.add(StructureRole::Document, None);
4728
4729    for section in sections {
4730        let mut lists: Vec<ListFrame> = Vec::new();
4731        for block in &mut section.blocks {
4732            match block {
4733                SharedLayoutBlock::Paragraph { block, semantics } => {
4734                    lists.clear();
4735                    let paragraph_id = builder.add(StructureRole::Paragraph, Some(root));
4736                    semantics.structure_id = Some(paragraph_id);
4737                    debug_assert!(block.list.is_none());
4738                    debug_assert!(block.anchored.is_empty());
4739                }
4740                SharedLayoutBlock::Table { block, semantics } => {
4741                    lists.clear();
4742                    assign_shared_table_structure(&mut builder, block, semantics, root);
4743                }
4744                SharedLayoutBlock::Owned { block, .. } => match block.as_mut() {
4745                    LayoutBlock::Paragraph(paragraph) => {
4746                        assign_owned_paragraph_structure(&mut builder, paragraph, root, &mut lists);
4747                    }
4748                    LayoutBlock::Table(table) => {
4749                        lists.clear();
4750                        assign_owned_table_structure(&mut builder, table, root);
4751                    }
4752                },
4753            }
4754        }
4755    }
4756
4757    DocumentStructure {
4758        root,
4759        nodes: builder.nodes,
4760    }
4761}
4762
4763fn assign_owned_paragraph_structure(
4764    builder: &mut StructureBuilder,
4765    paragraph: &mut ParagraphBlock,
4766    root: StructureId,
4767    lists: &mut Vec<ListFrame>,
4768) {
4769    if let Some((num_id, level)) = paragraph.list {
4770        if lists.last().is_some_and(|frame| frame.num_id != num_id) {
4771            lists.clear();
4772        }
4773        while lists.last().is_some_and(|frame| frame.level > level) {
4774            lists.pop();
4775        }
4776        if lists.is_empty() || lists.last().is_some_and(|frame| frame.level < level) {
4777            let parent = lists
4778                .last()
4779                .and_then(|frame| frame.last_item)
4780                .unwrap_or(root);
4781            let list = builder.add(StructureRole::List, Some(parent));
4782            lists.push(ListFrame {
4783                num_id,
4784                level,
4785                list,
4786                last_item: None,
4787            });
4788        }
4789        let frame = lists
4790            .last_mut()
4791            .expect("the requested list level has been allocated");
4792        let item = builder.add(StructureRole::ListItem, Some(frame.list));
4793        frame.last_item = Some(item);
4794        let paragraph_id = builder.add(StructureRole::Paragraph, Some(item));
4795        paragraph.structure_id = Some(paragraph_id);
4796        assign_paragraph_figures(builder, paragraph, paragraph_id, item);
4797    } else {
4798        lists.clear();
4799        let role = paragraph
4800            .heading_level
4801            .map(|level| StructureRole::Heading(level.min(6) as u8))
4802            .unwrap_or(StructureRole::Paragraph);
4803        let paragraph_id = builder.add(role, Some(root));
4804        paragraph.structure_id = Some(paragraph_id);
4805        assign_paragraph_figures(builder, paragraph, paragraph_id, root);
4806    }
4807}
4808
4809fn assign_owned_table_structure(
4810    builder: &mut StructureBuilder,
4811    table: &mut table::TableBlock,
4812    parent: StructureId,
4813) {
4814    let table_id = builder.add(StructureRole::Table, Some(parent));
4815    table.structure_id = Some(table_id);
4816    for row in &mut table.rows {
4817        let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4818        row.structure_id = Some(row_id);
4819        for cell in &mut row.cells {
4820            let role = if row.is_header {
4821                StructureRole::TableHeaderCell
4822            } else {
4823                StructureRole::TableCell
4824            };
4825            let cell_id = builder.add(role, Some(row_id));
4826            cell.structure_id = Some(cell_id);
4827            for block in &mut cell.blocks {
4828                assign_cell_block_structure(builder, block, cell_id);
4829            }
4830        }
4831    }
4832}
4833
4834fn assign_shared_table_structure(
4835    builder: &mut StructureBuilder,
4836    table: &table::TableBlock,
4837    semantics: &mut TableSemantics,
4838    parent: StructureId,
4839) {
4840    let table_id = builder.add(StructureRole::Table, Some(parent));
4841    for (row, row_semantics) in table.rows.iter().zip(&mut semantics.rows) {
4842        let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4843        for (cell, cell_semantics) in row.cells.iter().zip(&mut row_semantics.cells) {
4844            let role = if row.is_header {
4845                StructureRole::TableHeaderCell
4846            } else {
4847                StructureRole::TableCell
4848            };
4849            let cell_id = builder.add(role, Some(row_id));
4850            for (block, block_semantics) in cell.blocks.iter().zip(&mut cell_semantics.blocks) {
4851                match (block, block_semantics) {
4852                    (
4853                        table::CellBlock::Paragraph(paragraph),
4854                        CellBlockSemantics::Paragraph(semantics),
4855                    ) => {
4856                        let role = paragraph
4857                            .heading_level
4858                            .map(|level| StructureRole::Heading(level.min(6) as u8))
4859                            .unwrap_or(StructureRole::Paragraph);
4860                        semantics.structure_id = Some(builder.add(role, Some(cell_id)));
4861                    }
4862                    (table::CellBlock::Table(table), CellBlockSemantics::Table(semantics)) => {
4863                        assign_shared_table_structure(builder, table, semantics, cell_id)
4864                    }
4865                    _ => unreachable!("shared table semantics stay aligned"),
4866                }
4867            }
4868        }
4869    }
4870}
4871
4872#[cfg(test)]
4873fn assign_document_structure(sections: &mut [paginator::Section]) -> DocumentStructure {
4874    let mut builder = StructureBuilder { nodes: Vec::new() };
4875    let root = builder.add(StructureRole::Document, None);
4876
4877    for section in sections {
4878        let mut lists: Vec<ListFrame> = Vec::new();
4879        for block in &mut section.blocks {
4880            match block {
4881                LayoutBlock::Paragraph(paragraph) => {
4882                    if let Some((num_id, level)) = paragraph.list {
4883                        if lists.last().is_some_and(|frame| frame.num_id != num_id) {
4884                            lists.clear();
4885                        }
4886                        while lists.last().is_some_and(|frame| frame.level > level) {
4887                            lists.pop();
4888                        }
4889                        if lists.is_empty() || lists.last().is_some_and(|frame| frame.level < level)
4890                        {
4891                            let parent = lists
4892                                .last()
4893                                .and_then(|frame| frame.last_item)
4894                                .unwrap_or(root);
4895                            let list = builder.add(StructureRole::List, Some(parent));
4896                            lists.push(ListFrame {
4897                                num_id,
4898                                level,
4899                                list,
4900                                last_item: None,
4901                            });
4902                        }
4903                        let frame = lists
4904                            .last_mut()
4905                            .expect("the requested list level has been allocated");
4906                        let item = builder.add(StructureRole::ListItem, Some(frame.list));
4907                        frame.last_item = Some(item);
4908                        let paragraph_id = builder.add(StructureRole::Paragraph, Some(item));
4909                        paragraph.structure_id = Some(paragraph_id);
4910                        assign_paragraph_figures(&mut builder, paragraph, paragraph_id, item);
4911                    } else {
4912                        lists.clear();
4913                        let role = paragraph
4914                            .heading_level
4915                            .map(|level| StructureRole::Heading(level.min(6) as u8))
4916                            .unwrap_or(StructureRole::Paragraph);
4917                        let paragraph_id = builder.add(role, Some(root));
4918                        paragraph.structure_id = Some(paragraph_id);
4919                        assign_paragraph_figures(&mut builder, paragraph, paragraph_id, root);
4920                    }
4921                }
4922                LayoutBlock::Table(table) => {
4923                    lists.clear();
4924                    let table_id = builder.add(StructureRole::Table, Some(root));
4925                    table.structure_id = Some(table_id);
4926                    for row in &mut table.rows {
4927                        let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4928                        row.structure_id = Some(row_id);
4929                        for cell in &mut row.cells {
4930                            let role = if row.is_header {
4931                                StructureRole::TableHeaderCell
4932                            } else {
4933                                StructureRole::TableCell
4934                            };
4935                            let cell_id = builder.add(role, Some(row_id));
4936                            cell.structure_id = Some(cell_id);
4937                            for block in &mut cell.blocks {
4938                                assign_cell_block_structure(&mut builder, block, cell_id);
4939                            }
4940                        }
4941                    }
4942                }
4943            }
4944        }
4945    }
4946
4947    DocumentStructure {
4948        root,
4949        nodes: builder.nodes,
4950    }
4951}
4952
4953fn assign_cell_block_structure(
4954    builder: &mut StructureBuilder,
4955    block: &mut table::CellBlock,
4956    parent: StructureId,
4957) {
4958    match block {
4959        table::CellBlock::Paragraph(paragraph) => {
4960            let role = paragraph
4961                .heading_level
4962                .map(|level| StructureRole::Heading(level.min(6) as u8))
4963                .unwrap_or(StructureRole::Paragraph);
4964            let paragraph_id = builder.add(role, Some(parent));
4965            paragraph.structure_id = Some(paragraph_id);
4966            assign_paragraph_figures(builder, paragraph, paragraph_id, parent);
4967        }
4968        table::CellBlock::Table(table) => {
4969            let table_id = builder.add(StructureRole::Table, Some(parent));
4970            table.structure_id = Some(table_id);
4971            for row in &mut table.rows {
4972                let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4973                row.structure_id = Some(row_id);
4974                for cell in &mut row.cells {
4975                    let role = if row.is_header {
4976                        StructureRole::TableHeaderCell
4977                    } else {
4978                        StructureRole::TableCell
4979                    };
4980                    let cell_id = builder.add(role, Some(row_id));
4981                    cell.structure_id = Some(cell_id);
4982                    for child in &mut cell.blocks {
4983                        assign_cell_block_structure(builder, child, cell_id);
4984                    }
4985                }
4986            }
4987        }
4988    }
4989}
4990
4991fn assign_paragraph_figures(
4992    builder: &mut StructureBuilder,
4993    paragraph: &mut ParagraphBlock,
4994    inline_parent: StructureId,
4995    anchored_parent: StructureId,
4996) {
4997    for line in &mut paragraph.lines {
4998        for item in &mut line.items {
4999            let (alternate_text, structure_id) = match item {
5000                LineItem::Figure {
5001                    alternate_text,
5002                    structure_id,
5003                    ..
5004                } => (alternate_text, structure_id),
5005                _ => continue,
5006            };
5007            let figure = builder.add(StructureRole::Figure, Some(inline_parent));
5008            builder.set_alternate_text(figure, alternate_text.clone());
5009            *structure_id = Some(figure);
5010        }
5011    }
5012    for drawing in &mut paragraph.anchored {
5013        if let Some(text) = drawing
5014            .alternate_text
5015            .as_deref()
5016            .map(str::trim)
5017            .filter(|text| !text.is_empty())
5018        {
5019            let figure = builder.add(StructureRole::Figure, Some(anchored_parent));
5020            builder.set_alternate_text(figure, text.to_owned());
5021            drawing.structure_id = Some(figure);
5022        }
5023    }
5024}
5025
5026/// Detect if a paragraph has a heading style, returning the level (1-9).
5027fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
5028    let style_id = para.properties.as_ref()?.style_id.as_deref()?;
5029    // Check if style ID matches "Heading1" .. "Heading9"
5030    if let Some(rest) = style_id.strip_prefix("Heading") {
5031        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
5032    }
5033    // Also check style name in the styles definitions
5034    if let Some(style_def) = styles.get_by_id(style_id)
5035        && let Some(ref name) = style_def.name
5036        && let Some(rest) = name.strip_prefix("heading ")
5037    {
5038        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
5039    }
5040    None
5041}
5042
5043/// Lay out a single paragraph into a ParagraphBlock.
5044pub fn layout_paragraph(
5045    para: &CT_P,
5046    available_width: f64,
5047    styles: &CT_Styles,
5048    input: &LayoutInput,
5049    media: &MediaRegistry,
5050    fm: &mut FontManager,
5051    num_state: &mut NumberingState,
5052    diagnostics: &mut Vec<Diagnostic>,
5053) -> Result<ParagraphBlock> {
5054    layout_paragraph_with_source(
5055        para,
5056        available_width,
5057        styles,
5058        input,
5059        media,
5060        fm,
5061        num_state,
5062        diagnostics,
5063        None,
5064    )
5065}
5066
5067pub(crate) fn layout_paragraph_with_source(
5068    para: &CT_P,
5069    available_width: f64,
5070    styles: &CT_Styles,
5071    input: &LayoutInput,
5072    media: &MediaRegistry,
5073    fm: &mut FontManager,
5074    num_state: &mut NumberingState,
5075    diagnostics: &mut Vec<Diagnostic>,
5076    source_node: Option<SourceNodeId>,
5077) -> Result<ParagraphBlock> {
5078    layout_paragraph_with_source_and_table(
5079        para,
5080        available_width,
5081        styles,
5082        input,
5083        media,
5084        fm,
5085        num_state,
5086        diagnostics,
5087        source_node,
5088        None,
5089        None,
5090    )
5091}
5092
5093#[allow(clippy::too_many_arguments)]
5094pub(crate) fn layout_paragraph_with_source_and_direction(
5095    para: &CT_P,
5096    available_width: f64,
5097    styles: &CT_Styles,
5098    input: &LayoutInput,
5099    media: &MediaRegistry,
5100    fm: &mut FontManager,
5101    num_state: &mut NumberingState,
5102    diagnostics: &mut Vec<Diagnostic>,
5103    source_node: Option<SourceNodeId>,
5104) -> Result<(ParagraphBlock, TextDirection)> {
5105    let mut direction = TextDirection::Auto;
5106    let block = layout_paragraph_with_source_and_table(
5107        para,
5108        available_width,
5109        styles,
5110        input,
5111        media,
5112        fm,
5113        num_state,
5114        diagnostics,
5115        source_node,
5116        None,
5117        Some(&mut direction),
5118    )?;
5119    Ok((block, direction))
5120}
5121
5122#[allow(clippy::too_many_arguments)]
5123pub(crate) fn layout_paragraph_with_source_in_table(
5124    para: &CT_P,
5125    available_width: f64,
5126    styles: &CT_Styles,
5127    input: &LayoutInput,
5128    media: &MediaRegistry,
5129    fm: &mut FontManager,
5130    num_state: &mut NumberingState,
5131    diagnostics: &mut Vec<Diagnostic>,
5132    source_node: Option<SourceNodeId>,
5133    table_properties: Option<&rdocx_oxml::properties::CT_PPr>,
5134) -> Result<(ParagraphBlock, TextDirection)> {
5135    let mut direction = TextDirection::Auto;
5136    let block = layout_paragraph_with_source_and_table(
5137        para,
5138        available_width,
5139        styles,
5140        input,
5141        media,
5142        fm,
5143        num_state,
5144        diagnostics,
5145        source_node,
5146        table_properties,
5147        Some(&mut direction),
5148    )?;
5149    Ok((block, direction))
5150}
5151
5152#[allow(clippy::too_many_arguments)]
5153fn layout_paragraph_with_source_and_table(
5154    para: &CT_P,
5155    available_width: f64,
5156    styles: &CT_Styles,
5157    input: &LayoutInput,
5158    media: &MediaRegistry,
5159    fm: &mut FontManager,
5160    num_state: &mut NumberingState,
5161    diagnostics: &mut Vec<Diagnostic>,
5162    source_node: Option<SourceNodeId>,
5163    table_properties: Option<&rdocx_oxml::properties::CT_PPr>,
5164    reflow_direction_out: Option<&mut TextDirection>,
5165) -> Result<ParagraphBlock> {
5166    // Resolve paragraph properties
5167    let para_style_id = para.properties.as_ref().and_then(|p| p.style_id.as_deref());
5168
5169    let resolved_ppr = style_resolver::resolve_paragraph_properties_in_table(
5170        para_style_id,
5171        styles,
5172        table_properties,
5173    );
5174
5175    let mut effective_ppr = resolved_ppr;
5176
5177    // A numbering level carries paragraph properties of its own, mainly the
5178    // indentation for that level. They sit between the style and direct
5179    // formatting, so merge them before the direct properties rather than
5180    // after. Without this every level of a list draws at the same indent.
5181    let direct_ppr = para.properties.as_ref();
5182    let list_num_id = direct_ppr.and_then(|p| p.num_id).or(effective_ppr.num_id);
5183    let list_ilvl = direct_ppr
5184        .and_then(|p| p.num_ilvl)
5185        .or(effective_ppr.num_ilvl)
5186        .unwrap_or(0);
5187    if let (Some(num_id), Some(numbering)) = (list_num_id, input.numbering.as_ref())
5188        && let Some(lvl_ppr) =
5189            style_resolver::level_paragraph_properties(num_id, list_ilvl, numbering)
5190    {
5191        merge_direct_ppr(&mut effective_ppr, lvl_ppr);
5192    }
5193
5194    // Merge direct paragraph properties
5195    if let Some(direct_ppr) = direct_ppr {
5196        merge_direct_ppr(&mut effective_ppr, direct_ppr);
5197    }
5198
5199    // Convert paragraph properties to layout values
5200    let space_before = effective_ppr.space_before.map(|t| t.to_pt()).unwrap_or(0.0);
5201    let space_after = effective_ppr.space_after.map(|t| t.to_pt()).unwrap_or(0.0);
5202    let base_direction = match effective_ppr.bidi {
5203        Some(true) => TextDirection::RightToLeft,
5204        Some(false) => TextDirection::LeftToRight,
5205        None => TextDirection::Auto,
5206    };
5207    let keep_next = effective_ppr.keep_next.unwrap_or(false);
5208    let keep_lines = effective_ppr.keep_lines.unwrap_or(false);
5209    let page_break_before = effective_ppr.page_break_before.unwrap_or(false);
5210    let widow_control = effective_ppr.widow_control.unwrap_or(true);
5211    let automatic_hyphenation =
5212        input.automatic_hyphenation && effective_ppr.suppress_auto_hyphens != Some(true);
5213
5214    // Parse shading color
5215    let shading = effective_ppr
5216        .shading
5217        .as_ref()
5218        .and_then(|shd| shd.fill.as_ref())
5219        .filter(|f| f != &"auto")
5220        .map(|f| Color::from_hex(f));
5221
5222    // Convert runs to inline items
5223    let mut inline_items = Vec::new();
5224    let mut multilingual_styles = HashMap::<usize, WordMultilingualStyle>::new();
5225
5226    // Handle numbering marker
5227    if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) {
5228        let ilvl = effective_ppr.num_ilvl.unwrap_or(0);
5229        if let Some(marker) = style_resolver::generate_marker(num_id, ilvl, numbering, num_state) {
5230            // Shape the marker text
5231            let marker_rpr = marker.marker_rpr;
5232            let marker_font_size = marker_rpr.sz.map(|hp| hp.to_pt()).unwrap_or_else(|| {
5233                style_resolver::resolve_run_properties(para_style_id, None, styles)
5234                    .sz
5235                    .map(|hp| hp.to_pt())
5236                    .unwrap_or(11.0)
5237            });
5238            let marker_bold = marker_rpr.bold.unwrap_or(false);
5239            let marker_italic = marker_rpr.italic.unwrap_or(false);
5240            let marker_font_family = marker_rpr.font_ascii.as_deref();
5241
5242            // Bullet glyphs are not in every font either, so the marker gets
5243            // the same coverage check as body text.
5244            if let Ok(font_id) = fm.resolve_font_for_text(
5245                marker_font_family,
5246                marker_bold,
5247                marker_italic,
5248                &marker.marker_text,
5249            ) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size)
5250            {
5251                let metrics = fm.metrics(font_id, marker_font_size)?;
5252                let color = marker_rpr
5253                    .color
5254                    .as_ref()
5255                    .map(|c| Color::from_hex(c))
5256                    .unwrap_or(Color::BLACK);
5257
5258                inline_items.push(InlineItem::Marker(TextSegment {
5259                    text: marker.marker_text,
5260                    direction: TextDirection::Auto,
5261                    source: None,
5262                    font_id,
5263                    font_size: marker_font_size,
5264                    glyph_ids: shaped.glyph_ids,
5265                    advances: shaped.advances,
5266                    width: shaped.width,
5267                    ascent: metrics.ascent,
5268                    descent: metrics.descent,
5269                    line_gap: 0.0,
5270                    color,
5271                    bold: marker_bold,
5272                    italic: marker_italic,
5273                    underline: None,
5274                    strike: false,
5275                    dstrike: false,
5276                    highlight: None,
5277                    baseline_offset: 0.0,
5278                    hyperlink_url: None,
5279                    field_kind: None,
5280                    note: None,
5281                }));
5282
5283                match marker.suffix {
5284                    ST_LvlSuffix::Tab => inline_items.push(InlineItem::Tab),
5285                    ST_LvlSuffix::Space => {
5286                        let shaped = fm.shape_text(font_id, " ", marker_font_size)?;
5287                        inline_items.push(InlineItem::Text(TextSegment {
5288                            text: " ".to_owned(),
5289                            direction: TextDirection::Auto,
5290                            source: None,
5291                            font_id,
5292                            font_size: marker_font_size,
5293                            glyph_ids: shaped.glyph_ids,
5294                            advances: shaped.advances,
5295                            width: shaped.width,
5296                            ascent: metrics.ascent,
5297                            descent: metrics.descent,
5298                            line_gap: 0.0,
5299                            color,
5300                            bold: marker_bold,
5301                            italic: marker_italic,
5302                            underline: None,
5303                            strike: false,
5304                            dstrike: false,
5305                            highlight: None,
5306                            baseline_offset: 0.0,
5307                            hyperlink_url: None,
5308                            field_kind: None,
5309                            note: None,
5310                        }));
5311                    }
5312                    ST_LvlSuffix::Nothing => {}
5313                }
5314            }
5315        }
5316    }
5317
5318    // Build hyperlink URL map: run index → URL
5319    let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
5320        std::collections::HashMap::new();
5321    for hl in &para.hyperlinks {
5322        if let Some(ref rel_id) = hl.rel_id
5323            && let Some(url) = input.hyperlink_urls.get(rel_id)
5324        {
5325            for run_idx in hl.run_start..hl.run_end {
5326                run_hyperlink_url.insert(run_idx, url.clone());
5327            }
5328        }
5329    }
5330
5331    // Process ordinary and revision-wrapped runs in their preserved order.
5332    let mut projection_char_offset = 0usize;
5333    let mut equation_cursor = 0usize;
5334    let projected_runs = project_paragraph_runs(para, input.revision_view);
5335    let projected_run_count = projected_runs.len();
5336    for (projected_index, projected) in projected_runs.into_iter().enumerate() {
5337        let run = projected.run;
5338        let projected_run_start = projection_char_offset;
5339        projection_char_offset += run.text().chars().count();
5340        push_targeted_bookmark_markers(&mut inline_items, para, projected_index, input, fm)?;
5341        let current_hyperlink_url = projected
5342            .ordinary_run_index
5343            .and_then(|run_index| run_hyperlink_url.get(&run_index).cloned())
5344            .or_else(|| {
5345                projected
5346                    .hyperlink_index
5347                    .and_then(|index| para.hyperlinks.get(index))
5348                    .and_then(|hyperlink| hyperlink.rel_id.as_deref())
5349                    .and_then(|rel_id| input.hyperlink_urls.get(rel_id).cloned())
5350            });
5351
5352        let run_style_id = run.properties.as_ref().and_then(|p| p.style_id.as_deref());
5353
5354        let resolved_rpr =
5355            style_resolver::resolve_run_properties(para_style_id, run_style_id, styles);
5356
5357        // Merge direct run properties
5358        let mut effective_rpr = resolved_rpr;
5359        if let Some(ref direct_rpr) = run.properties {
5360            effective_rpr.merge_from(direct_rpr);
5361        }
5362
5363        push_equations_before_order(
5364            para,
5365            projected.boundary,
5366            projected.raw_order,
5367            &mut equation_cursor,
5368            &mut inline_items,
5369            fm,
5370            effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0),
5371            resolve_run_color(&effective_rpr, input.theme.as_ref()),
5372            available_width,
5373            input.math_properties.as_ref(),
5374            diagnostics,
5375        )?;
5376
5377        // Skip hidden text
5378        if effective_rpr.vanish == Some(true) {
5379            continue;
5380        }
5381
5382        let mut font_size = effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
5383        let bold = effective_rpr.bold.unwrap_or(false);
5384        let italic = effective_rpr.italic.unwrap_or(false);
5385
5386        // Resolve font family: theme font takes priority when no explicit font is set
5387        let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
5388
5389        // Resolve color: theme color takes priority over literal color value
5390        let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
5391
5392        // Decoration properties
5393        let underline = if projected.force_underline {
5394            Some(Underline::Single)
5395        } else {
5396            convert::underline(effective_rpr.underline)
5397        };
5398        let strike = projected.force_strike || effective_rpr.strike.unwrap_or(false);
5399        let dstrike = effective_rpr.dstrike.unwrap_or(false);
5400        let highlight = effective_rpr.highlight.and_then(highlight_to_color);
5401
5402        // Superscript/subscript handling
5403        let mut baseline_offset = 0.0;
5404        if let Some(ref va) = effective_rpr.vert_align {
5405            match va.as_str() {
5406                "superscript" => {
5407                    // Reduce font size to ~58% and raise baseline
5408                    let original_size = font_size;
5409                    font_size *= 0.58;
5410                    baseline_offset = original_size * 0.33; // raise by 1/3 of original size
5411                }
5412                "subscript" => {
5413                    // Reduce font size to ~58% and lower baseline
5414                    let original_size = font_size;
5415                    font_size *= 0.58;
5416                    baseline_offset = -(original_size * 0.14); // lower
5417                }
5418                _ => {}
5419            }
5420        }
5421
5422        // Position offset (in half-points, positive=raise)
5423        if let Some(pos) = effective_rpr.position {
5424            baseline_offset += pos as f64 / 2.0; // half-points to points
5425        }
5426
5427        // Resolved against the run's own text, so a family without glyphs for
5428        // this script is replaced by one that has them.
5429        let font_id =
5430            fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?;
5431        let metrics = fm.metrics(font_id, font_size)?;
5432
5433        let content_char_starts = projected_content_char_starts(run);
5434        for (content_index, content) in run.content.iter().enumerate() {
5435            let content_char_start = projected_run_start + content_char_starts[content_index];
5436            match content {
5437                RunContent::Text(ct_text) | RunContent::DeletedText(ct_text) => {
5438                    let text = if effective_rpr.caps == Some(true) {
5439                        ct_text.text.to_uppercase()
5440                    } else {
5441                        ct_text.text.clone()
5442                    };
5443
5444                    if text.is_empty() {
5445                        continue;
5446                    }
5447
5448                    let mut shaped = fm.shape_text(font_id, &text, font_size)?;
5449                    let source = if text == ct_text.text {
5450                        source_node.and_then(|node| {
5451                            let char_start = u32::try_from(content_char_start).ok()?;
5452                            let char_end =
5453                                u32::try_from(content_char_start + ct_text.text.chars().count())
5454                                    .ok()?;
5455                            Some(SourceSpan {
5456                                node,
5457                                char_start,
5458                                char_end,
5459                            })
5460                        })
5461                    } else {
5462                        None
5463                    };
5464
5465                    // Apply character spacing from run properties (in twips)
5466                    if let Some(spacing) = effective_rpr.spacing {
5467                        let extra = spacing.to_pt();
5468                        for advance in &mut shaped.advances {
5469                            *advance += extra;
5470                        }
5471                        shaped.width += extra * shaped.advances.len() as f64;
5472                    }
5473
5474                    let segment = TextSegment {
5475                        text,
5476                        direction: TextDirection::Auto,
5477                        source,
5478                        font_id,
5479                        font_size,
5480                        glyph_ids: shaped.glyph_ids,
5481                        advances: shaped.advances,
5482                        width: shaped.width,
5483                        ascent: metrics.ascent,
5484                        descent: metrics.descent,
5485                        line_gap: 0.0,
5486                        color,
5487                        bold,
5488                        italic,
5489                        underline,
5490                        strike,
5491                        dstrike,
5492                        highlight,
5493                        baseline_offset,
5494                        hyperlink_url: current_hyperlink_url.clone(),
5495                        field_kind: None,
5496                        note: None,
5497                    };
5498                    let item_index = inline_items.len();
5499                    multilingual_styles.insert(
5500                        item_index,
5501                        WordMultilingualStyle {
5502                            language: effective_rpr.language.clone(),
5503                            language_east_asia: effective_rpr.language_east_asia.clone(),
5504                            language_bidi: effective_rpr.language_bidi.clone(),
5505                            direction: word_text_direction(effective_rpr.rtl),
5506                            spacing: effective_rpr.spacing.map_or(0.0, |value| value.to_pt()),
5507                        },
5508                    );
5509                    if automatic_hyphenation && let Some(language) = effective_rpr.language.as_ref()
5510                    {
5511                        inline_items.push(InlineItem::HyphenatedText {
5512                            segment,
5513                            language: language.clone(),
5514                        });
5515                    } else {
5516                        inline_items.push(InlineItem::Text(segment));
5517                    }
5518                }
5519                RunContent::Tab => {
5520                    inline_items.push(InlineItem::Tab);
5521                }
5522                RunContent::Break(bt) => match bt {
5523                    BreakType::Line => inline_items.push(InlineItem::LineBreak),
5524                    BreakType::Page => inline_items.push(InlineItem::PageBreak),
5525                    BreakType::Column => inline_items.push(InlineItem::ColumnBreak),
5526                },
5527                RunContent::Drawing(drawing) => {
5528                    if let Some(ref inline) = drawing.inline {
5529                        let width = inline.extent_cx.to_pt();
5530                        let height = inline.extent_cy.to_pt();
5531                        let item = if let Some(relationship_id) = inline.chart_rel_id.as_deref() {
5532                            InlineItem::Group {
5533                                width,
5534                                height,
5535                                baseline: None,
5536                                group: render_word_chart(
5537                                    relationship_id,
5538                                    width,
5539                                    height,
5540                                    input,
5541                                    fm,
5542                                    diagnostics,
5543                                )?,
5544                            }
5545                        } else {
5546                            InlineItem::Image {
5547                                width,
5548                                height,
5549                                media_id: media.id_for_relationship(&inline.embed_id),
5550                            }
5551                        };
5552                        if let Some(alternate_text) = inline
5553                            .description
5554                            .as_deref()
5555                            .map(str::trim)
5556                            .filter(|text| !text.is_empty())
5557                        {
5558                            inline_items.push(InlineItem::Figure {
5559                                item: Box::new(item),
5560                                alternate_text: alternate_text.to_owned(),
5561                                structure_id: None,
5562                            });
5563                        } else {
5564                            inline_items.push(item);
5565                        }
5566                    }
5567                }
5568                RunContent::Field(field) => {
5569                    let (computed_value, field_kind) = match field.instruction.name.as_str() {
5570                        "PAGE" => (Some("99".to_owned()), Some(FieldKind::Page)),
5571                        "NUMPAGES" => (Some("99".to_owned()), Some(FieldKind::NumPages)),
5572                        "REF" => {
5573                            let Some(bookmark) = field_text_argument(field, 0) else {
5574                                continue;
5575                            };
5576                            if let Some(text) = bookmark_text(input, bookmark) {
5577                                (Some(text), None)
5578                            } else {
5579                                diagnostics.push(Diagnostic {
5580                                    message: format!(
5581                                        "REF target {bookmark} was not found, stored display retained"
5582                                    ),
5583                                });
5584                                (None, None)
5585                            }
5586                        }
5587                        "PAGEREF" => {
5588                            let Some(bookmark) = field_text_argument(field, 0) else {
5589                                continue;
5590                            };
5591                            if bookmark_text(input, bookmark).is_none() {
5592                                diagnostics.push(Diagnostic {
5593                                    message: format!(
5594                                        "PAGEREF target {bookmark} was not found, stored display retained"
5595                                    ),
5596                                });
5597                                (None, None)
5598                            } else if let Some(target) = page_ref_id(input, bookmark) {
5599                                (Some("99".to_owned()), Some(FieldKind::TargetPage(target)))
5600                            } else {
5601                                (None, None)
5602                            }
5603                        }
5604                        _ => (None, None),
5605                    };
5606                    let stored_segments = field.cached_display_segments();
5607                    let segments = if let Some(value) = computed_value.as_deref() {
5608                        let stored_properties = stored_segments
5609                            .first()
5610                            .and_then(|(_, properties)| *properties);
5611                        vec![(value, stored_properties)]
5612                    } else {
5613                        stored_segments
5614                    };
5615                    for (value, stored_properties) in segments {
5616                        let segment_style_id =
5617                            stored_properties.and_then(|properties| properties.style_id.as_deref());
5618                        let mut segment_rpr = if stored_properties.is_some() {
5619                            style_resolver::resolve_run_properties(
5620                                para_style_id,
5621                                segment_style_id,
5622                                styles,
5623                            )
5624                        } else {
5625                            effective_rpr.clone()
5626                        };
5627                        if let Some(properties) = stored_properties {
5628                            segment_rpr.merge_from(properties);
5629                        }
5630                        if segment_rpr.vanish == Some(true) {
5631                            continue;
5632                        }
5633                        let mut segment_font_size =
5634                            segment_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
5635                        let segment_bold = segment_rpr.bold.unwrap_or(false);
5636                        let segment_italic = segment_rpr.italic.unwrap_or(false);
5637                        let segment_font_family =
5638                            resolve_font_family(&segment_rpr, input.theme.as_ref());
5639                        let segment_color = resolve_run_color(&segment_rpr, input.theme.as_ref());
5640                        let segment_underline = if projected.force_underline {
5641                            Some(Underline::Single)
5642                        } else {
5643                            convert::underline(segment_rpr.underline)
5644                        };
5645                        let segment_strike =
5646                            projected.force_strike || segment_rpr.strike.unwrap_or(false);
5647                        let segment_dstrike = segment_rpr.dstrike.unwrap_or(false);
5648                        let segment_highlight = segment_rpr.highlight.and_then(highlight_to_color);
5649                        let mut segment_baseline_offset = 0.0;
5650                        if let Some(vertical) = segment_rpr.vert_align.as_deref() {
5651                            match vertical {
5652                                "superscript" => {
5653                                    let original_size = segment_font_size;
5654                                    segment_font_size *= 0.58;
5655                                    segment_baseline_offset = original_size * 0.33;
5656                                }
5657                                "subscript" => {
5658                                    let original_size = segment_font_size;
5659                                    segment_font_size *= 0.58;
5660                                    segment_baseline_offset = -(original_size * 0.14);
5661                                }
5662                                _ => {}
5663                            }
5664                        }
5665                        if let Some(position) = segment_rpr.position {
5666                            segment_baseline_offset += position as f64 / 2.0;
5667                        }
5668                        let segment_font_id = fm.resolve_font_for_text(
5669                            segment_font_family.as_deref(),
5670                            segment_bold,
5671                            segment_italic,
5672                            value,
5673                        )?;
5674                        let segment_metrics = fm.metrics(segment_font_id, segment_font_size)?;
5675
5676                        let mut start = 0usize;
5677                        for (index, character) in value
5678                            .char_indices()
5679                            .chain(std::iter::once((value.len(), '\0')))
5680                        {
5681                            let control = match character {
5682                                '\t' => Some(InlineItem::Tab),
5683                                '\n' => Some(InlineItem::LineBreak),
5684                                '\u{000c}' => Some(InlineItem::PageBreak),
5685                                '\u{000b}' => Some(InlineItem::ColumnBreak),
5686                                '\0' if index == value.len() => None,
5687                                _ => continue,
5688                            };
5689                            if start < index {
5690                                let mut text = value[start..index].to_owned();
5691                                if segment_rpr.caps == Some(true) {
5692                                    text = text.to_uppercase();
5693                                }
5694                                let mut shaped =
5695                                    fm.shape_text(segment_font_id, &text, segment_font_size)?;
5696                                if let Some(spacing) = segment_rpr.spacing {
5697                                    let extra = spacing.to_pt();
5698                                    for advance in &mut shaped.advances {
5699                                        *advance += extra;
5700                                    }
5701                                    shaped.width += extra * shaped.advances.len() as f64;
5702                                }
5703                                let item_index = inline_items.len();
5704                                multilingual_styles.insert(
5705                                    item_index,
5706                                    WordMultilingualStyle {
5707                                        language: segment_rpr.language.clone(),
5708                                        language_east_asia: segment_rpr.language_east_asia.clone(),
5709                                        language_bidi: segment_rpr.language_bidi.clone(),
5710                                        direction: word_text_direction(segment_rpr.rtl),
5711                                        spacing: segment_rpr
5712                                            .spacing
5713                                            .map_or(0.0, |value| value.to_pt()),
5714                                    },
5715                                );
5716                                inline_items.push(InlineItem::Text(TextSegment {
5717                                    text,
5718                                    direction: word_text_direction(segment_rpr.rtl),
5719                                    source: None,
5720                                    font_id: segment_font_id,
5721                                    font_size: segment_font_size,
5722                                    glyph_ids: shaped.glyph_ids,
5723                                    advances: shaped.advances,
5724                                    width: shaped.width,
5725                                    ascent: segment_metrics.ascent,
5726                                    descent: segment_metrics.descent,
5727                                    line_gap: 0.0,
5728                                    color: segment_color,
5729                                    bold: segment_bold,
5730                                    italic: segment_italic,
5731                                    underline: segment_underline,
5732                                    strike: segment_strike,
5733                                    dstrike: segment_dstrike,
5734                                    highlight: segment_highlight,
5735                                    baseline_offset: segment_baseline_offset,
5736                                    hyperlink_url: current_hyperlink_url.clone(),
5737                                    field_kind,
5738                                    note: None,
5739                                }));
5740                            }
5741                            if let Some(control) = control {
5742                                inline_items.push(control);
5743                                start = index + character.len_utf8();
5744                            }
5745                        }
5746                    }
5747                }
5748                RunContent::FootnoteRef { id } | RunContent::EndnoteRef { id } => {
5749                    // The two streams number independently, so the marker has
5750                    // to carry which one it came from.
5751                    let stream = match content {
5752                        RunContent::EndnoteRef { .. } => NoteStream::Endnote,
5753                        _ => NoteStream::Footnote,
5754                    };
5755                    // Render as superscript number
5756                    let marker = id.to_string();
5757                    let sup_size = font_size * 0.58;
5758                    let sup_offset = font_size * 0.33; // raise baseline
5759                    let shaped = fm.shape_text(font_id, &marker, sup_size)?;
5760                    let sup_metrics = fm.metrics(font_id, sup_size)?;
5761                    let revision_marker = input.revision_view == RevisionView::Tracked
5762                        && projected.ordinary_run_index.is_none();
5763                    inline_items.push(InlineItem::Text(TextSegment {
5764                        text: marker,
5765                        direction: TextDirection::Auto,
5766                        source: None,
5767                        font_id,
5768                        font_size: sup_size,
5769                        glyph_ids: shaped.glyph_ids,
5770                        advances: shaped.advances,
5771                        width: shaped.width,
5772                        ascent: sup_metrics.ascent,
5773                        descent: sup_metrics.descent,
5774                        line_gap: 0.0,
5775                        color,
5776                        bold,
5777                        italic,
5778                        underline: revision_marker.then_some(underline).flatten(),
5779                        strike: revision_marker && strike,
5780                        dstrike: revision_marker && dstrike,
5781                        highlight: revision_marker.then_some(highlight).flatten(),
5782                        baseline_offset: sup_offset,
5783                        hyperlink_url: None,
5784                        field_kind: None,
5785                        note: Some(NoteRef { stream, id: *id }),
5786                    }));
5787                }
5788                RunContent::CommentReference { .. } => {}
5789            }
5790        }
5791    }
5792
5793    let mut equation_rpr = style_resolver::resolve_run_properties(para_style_id, None, styles);
5794    if let Some(paragraph_mark_rpr) = direct_ppr.and_then(|ppr| ppr.rpr.as_ref()) {
5795        equation_rpr.merge_from(paragraph_mark_rpr);
5796    }
5797    push_equations_before_order(
5798        para,
5799        usize::MAX,
5800        RawOrder::AfterRaw,
5801        &mut equation_cursor,
5802        &mut inline_items,
5803        fm,
5804        equation_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0),
5805        resolve_run_color(&equation_rpr, input.theme.as_ref()),
5806        available_width,
5807        input.math_properties.as_ref(),
5808        diagnostics,
5809    )?;
5810
5811    push_targeted_bookmark_markers(&mut inline_items, para, projected_run_count, input, fm)?;
5812
5813    let attributed_empty_paragraph = inline_items.is_empty();
5814    if attributed_empty_paragraph {
5815        let mut caret_rpr = style_resolver::resolve_run_properties(para_style_id, None, styles);
5816        if let Some(paragraph_mark_rpr) = direct_ppr.and_then(|ppr| ppr.rpr.as_ref()) {
5817            caret_rpr.merge_from(paragraph_mark_rpr);
5818        }
5819        let font_size = caret_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
5820        let bold = caret_rpr.bold.unwrap_or(false);
5821        let italic = caret_rpr.italic.unwrap_or(false);
5822        let font_family = resolve_font_family(&caret_rpr, input.theme.as_ref());
5823        let font_id = fm.resolve_font_for_metrics(font_family.as_deref(), bold, italic)?;
5824        let metrics = fm.metrics(font_id, font_size)?;
5825        inline_items.push(InlineItem::Text(TextSegment {
5826            text: String::new(),
5827            direction: TextDirection::Auto,
5828            source: source_node.map(|node| SourceSpan {
5829                node,
5830                char_start: 0,
5831                char_end: 0,
5832            }),
5833            font_id,
5834            font_size,
5835            glyph_ids: Vec::new(),
5836            advances: Vec::new(),
5837            width: 0.0,
5838            ascent: metrics.ascent,
5839            descent: metrics.descent,
5840            line_gap: 0.0,
5841            color: resolve_run_color(&caret_rpr, input.theme.as_ref()),
5842            bold,
5843            italic,
5844            underline: None,
5845            strike: false,
5846            dstrike: false,
5847            highlight: None,
5848            baseline_offset: 0.0,
5849            hyperlink_url: None,
5850            field_kind: None,
5851            note: None,
5852        }));
5853    }
5854
5855    let layout_direction = match base_direction {
5856        TextDirection::Auto => inferred_word_base_direction(&inline_items),
5857        direction => direction,
5858    };
5859    if let Some(reflow_direction_out) = reflow_direction_out {
5860        *reflow_direction_out = layout_direction;
5861    }
5862    let logical_left = match layout_direction {
5863        TextDirection::RightToLeft => effective_ppr.ind_end,
5864        TextDirection::Auto | TextDirection::LeftToRight => effective_ppr.ind_start,
5865    };
5866    let logical_right = match layout_direction {
5867        TextDirection::RightToLeft => effective_ppr.ind_start,
5868        TextDirection::Auto | TextDirection::LeftToRight => effective_ppr.ind_end,
5869    };
5870    let ind_left = effective_ppr
5871        .ind_left
5872        .or(logical_left)
5873        .map(|t| t.to_pt())
5874        .unwrap_or(0.0);
5875    let ind_right = effective_ppr
5876        .ind_right
5877        .or(logical_right)
5878        .map(|t| t.to_pt())
5879        .unwrap_or(0.0);
5880    let jc = convert::alignment_for_direction(effective_ppr.jc, layout_direction);
5881
5882    // Line breaking
5883    let mut line_params = convert::line_break_params(&effective_ppr, available_width);
5884    line_params.ind_left = ind_left;
5885    line_params.ind_right = ind_right;
5886    line_params.jc = jc;
5887
5888    let legacy_empty_line = if attributed_empty_paragraph
5889        && direct_ppr
5890            .and_then(|properties| properties.rpr.as_ref())
5891            .is_none()
5892    {
5893        let mut lines = break_into_lines(&[], &line_params, fm)?;
5894        convert::restore_word_line_heights(&mut lines, &effective_ppr);
5895        lines.pop()
5896    } else {
5897        None
5898    };
5899
5900    let uses_multilingual_layout = base_direction != TextDirection::Auto
5901        || multilingual_styles
5902            .values()
5903            .any(|style| style.direction != TextDirection::Auto)
5904        || inline_items.iter().any(|item| {
5905            multilingual_candidate(item)
5906                .is_some_and(|segment| needs_word_multilingual_layout(&segment.text))
5907        });
5908    let uses_exact_word_baseline = uses_multilingual_layout
5909        && effective_ppr.line_rule.as_deref() == Some("exact")
5910        && effective_ppr.line_spacing.is_some();
5911    if uses_multilingual_layout {
5912        inline_items = shape_word_multilingual_items(
5913            fm,
5914            inline_items,
5915            &multilingual_styles,
5916            layout_direction,
5917            !line_params.wrap,
5918            uses_exact_word_baseline,
5919        )?;
5920    }
5921    let mut lines = if uses_multilingual_layout {
5922        break_multilingual_into_lines(&inline_items, &line_params, fm, layout_direction)?
5923    } else {
5924        break_into_lines(&inline_items, &line_params, fm)?
5925    };
5926    convert::restore_word_line_heights(&mut lines, &effective_ppr);
5927    if let (Some(line), Some(legacy)) = (lines.first_mut(), legacy_empty_line) {
5928        line.ascent = legacy.ascent;
5929        line.descent = legacy.descent;
5930        line.line_gap = legacy.line_gap;
5931        line.height = legacy.height;
5932    }
5933
5934    let mut result = block::build_paragraph_block(
5935        lines,
5936        space_before,
5937        space_after,
5938        effective_ppr.borders,
5939        shading,
5940        ind_left,
5941        ind_right,
5942        jc,
5943        keep_next,
5944        keep_lines,
5945        page_break_before,
5946        widow_control,
5947    );
5948    result.has_visible_revision =
5949        input.revision_view == RevisionView::Tracked && paragraph_has_visible_revision(para);
5950    result.list = list_num_id.map(|num_id| (num_id, list_ilvl.min(8) as u8));
5951    result.anchored =
5952        collect_anchored_drawings(para, styles, input, media, fm, num_state, diagnostics)?;
5953    // `inline_items` is finished with here and would otherwise be dropped, so
5954    // handing it to the reflow costs nothing but the memory it already holds.
5955    // `Engine::layout` frees it again unless the document wraps.
5956    result.reflow = Some(Box::new(block::ParagraphReflow {
5957        items: inline_items,
5958        params: line_params,
5959    }));
5960    Ok(result)
5961}
5962
5963fn push_targeted_bookmark_markers(
5964    items: &mut Vec<InlineItem>,
5965    paragraph: &CT_P,
5966    projected_run_index: usize,
5967    input: &LayoutInput,
5968    fm: &mut FontManager,
5969) -> Result<()> {
5970    let mut font_id = None;
5971    for marker in paragraph.bookmark_markers.iter().filter(|marker| {
5972        let marker_run_index = match input.revision_view {
5973            RevisionView::Accepted => marker.projected_run_index(),
5974            RevisionView::Tracked => marker.tracked_run_index(),
5975        };
5976        marker.is_start()
5977            && marker_run_index == projected_run_index
5978            && marker.name().is_some_and(|name| {
5979                document_has_page_ref(input, name) && bookmark_text(input, name).is_some()
5980            })
5981    }) {
5982        if let Some(target) = marker.name().and_then(|name| page_ref_id(input, name)) {
5983            let resolved_font = match font_id {
5984                Some(font_id) => font_id,
5985                None => {
5986                    let resolved = fm.resolve_font_for_text(None, false, false, " ")?;
5987                    font_id = Some(resolved);
5988                    resolved
5989                }
5990            };
5991            push_bookmark_marker(items, target, resolved_font);
5992        }
5993    }
5994    Ok(())
5995}
5996
5997fn push_bookmark_marker(items: &mut Vec<InlineItem>, target: usize, font_id: oxml_layout::FontId) {
5998    items.push(InlineItem::Text(TextSegment {
5999        text: "\u{2060}".to_owned(),
6000        direction: TextDirection::Auto,
6001        source: None,
6002        font_id,
6003        font_size: 1.0,
6004        glyph_ids: vec![0],
6005        advances: vec![0.0],
6006        width: 0.0,
6007        ascent: 0.0,
6008        descent: 0.0,
6009        line_gap: 0.0,
6010        color: Color::BLACK,
6011        bold: false,
6012        italic: false,
6013        underline: None,
6014        strike: false,
6015        dstrike: false,
6016        highlight: None,
6017        baseline_offset: 0.0,
6018        hyperlink_url: None,
6019        field_kind: Some(FieldKind::Target(target)),
6020        note: None,
6021    }));
6022}
6023
6024fn page_ref_id(input: &LayoutInput, name: &str) -> Option<usize> {
6025    page_reference_names(input)
6026        .iter()
6027        .position(|candidate| candidate == name)
6028}
6029
6030pub(crate) fn page_reference_names(input: &LayoutInput) -> Vec<String> {
6031    let mut names = Vec::<String>::new();
6032    visit_document_paragraphs(input, &mut |paragraph| {
6033        for projected in project_paragraph_runs(paragraph, input.revision_view) {
6034            let run = projected.run;
6035            for content in &run.content {
6036                let RunContent::Field(field) = content else {
6037                    continue;
6038                };
6039                if field.instruction.name != "PAGEREF" {
6040                    continue;
6041                }
6042                let Some(bookmark) = field_text_argument(field, 0) else {
6043                    continue;
6044                };
6045                if !names.iter().any(|candidate| candidate == bookmark) {
6046                    names.push(bookmark.to_owned());
6047                }
6048            }
6049        }
6050    });
6051    names
6052}
6053
6054fn field_text_argument(field: &Field, index: usize) -> Option<&str> {
6055    match field.instruction.arguments.get(index) {
6056        Some(FieldArgument::Text(value)) => Some(value),
6057        Some(FieldArgument::Nested(_)) | None => None,
6058    }
6059}
6060
6061fn document_has_page_ref(input: &LayoutInput, name: &str) -> bool {
6062    page_ref_id(input, name).is_some()
6063}
6064
6065fn visit_document_paragraphs<'a>(input: &'a LayoutInput, visit: &mut impl FnMut(&'a CT_P)) {
6066    for content in &input.document.body.content {
6067        match content {
6068            BodyContent::Paragraph(paragraph) => visit(paragraph),
6069            BodyContent::Table(table) => visit_table_paragraphs(table, visit),
6070            BodyContent::ContentControl(control) => {
6071                visit_control_paragraphs(control, BlockControlOwner::Body, visit)
6072            }
6073            BodyContent::RawXml(_) => {}
6074        }
6075    }
6076}
6077
6078fn visit_table_paragraphs<'a>(table: &'a CT_Tbl, visit: &mut impl FnMut(&'a CT_P)) {
6079    for boundary in 0..=table.rows.len() {
6080        for (_, _, control) in table
6081            .content_controls
6082            .iter()
6083            .filter(|(position, _, _)| *position == boundary)
6084        {
6085            visit_control_paragraphs(control, BlockControlOwner::Table, visit);
6086        }
6087        if let Some(row) = table.rows.get(boundary) {
6088            visit_row_paragraphs(row, visit);
6089        }
6090    }
6091}
6092
6093fn visit_row_paragraphs<'a>(row: &'a CT_Row, visit: &mut impl FnMut(&'a CT_P)) {
6094    for boundary in 0..=row.cells.len() {
6095        for (_, _, control) in row
6096            .content_controls
6097            .iter()
6098            .filter(|(position, _, _)| *position == boundary)
6099        {
6100            visit_control_paragraphs(control, BlockControlOwner::Row, visit);
6101        }
6102        if let Some(cell) = row.cells.get(boundary) {
6103            visit_cell_paragraphs(cell, visit);
6104        }
6105    }
6106}
6107
6108fn visit_cell_paragraphs<'a>(cell: &'a CT_Tc, visit: &mut impl FnMut(&'a CT_P)) {
6109    for content in &cell.content {
6110        match content {
6111            CellContent::Paragraph(paragraph) => visit(paragraph),
6112            CellContent::Table(table) => visit_table_paragraphs(table, visit),
6113            CellContent::ContentControl(control) => {
6114                visit_control_paragraphs(control, BlockControlOwner::Cell, visit)
6115            }
6116        }
6117    }
6118}
6119
6120fn visit_control_paragraphs<'a>(
6121    control: &'a CT_Sdt,
6122    owner: BlockControlOwner,
6123    visit: &mut impl FnMut(&'a CT_P),
6124) {
6125    for content in &control.content {
6126        match (owner, content) {
6127            (
6128                BlockControlOwner::Body | BlockControlOwner::Cell,
6129                SdtContent::Paragraph(paragraph),
6130            ) => visit(paragraph),
6131            (BlockControlOwner::Body | BlockControlOwner::Cell, SdtContent::Table(table)) => {
6132                visit_table_paragraphs(table, visit)
6133            }
6134            (BlockControlOwner::Table, SdtContent::Row(row)) => visit_row_paragraphs(row, visit),
6135            (BlockControlOwner::Row, SdtContent::Cell(cell)) => visit_cell_paragraphs(cell, visit),
6136            (_, SdtContent::ContentControl(control)) => {
6137                visit_control_paragraphs(control, owner, visit)
6138            }
6139            _ => {}
6140        }
6141    }
6142}
6143
6144fn bookmark_text(input: &LayoutInput, name: &str) -> Option<String> {
6145    type OrderedBodyRunPosition = (usize, usize, usize);
6146    type BookmarkStart<'a> = (Option<&'a str>, OrderedBodyRunPosition);
6147
6148    let mut starts: HashMap<i32, Vec<BookmarkStart<'_>>> = HashMap::new();
6149    let mut ends: HashMap<i32, Vec<OrderedBodyRunPosition>> = HashMap::new();
6150    let mut paragraphs = Vec::new();
6151    let mut encounter = 0usize;
6152    visit_document_paragraphs(input, &mut |paragraph| paragraphs.push(paragraph));
6153    for (paragraph_index, paragraph) in paragraphs.iter().enumerate() {
6154        let projected_run_count = project_paragraph_runs(paragraph, input.revision_view).len();
6155        for marker in &paragraph.bookmark_markers {
6156            let Some(id) = marker.id() else {
6157                continue;
6158            };
6159            let marker_run_index = match input.revision_view {
6160                RevisionView::Accepted => marker.projected_run_index(),
6161                RevisionView::Tracked => marker.tracked_run_index(),
6162            };
6163            if marker_run_index > projected_run_count {
6164                return None;
6165            }
6166            let position = (paragraph_index, marker_run_index, encounter);
6167            encounter += 1;
6168            if marker.is_start() {
6169                starts
6170                    .entry(id)
6171                    .or_default()
6172                    .push((marker.name(), position));
6173            } else {
6174                ends.entry(id).or_default().push(position);
6175            }
6176        }
6177    }
6178    let candidates = starts
6179        .iter()
6180        .filter_map(|(id, starts)| {
6181            let ends = ends.get(id)?;
6182            (starts.len() == 1 && starts[0].0 == Some(name) && ends.len() == 1)
6183                .then_some((starts[0].1, ends[0]))
6184        })
6185        .collect::<Vec<_>>();
6186    if candidates.len() != 1 {
6187        return None;
6188    }
6189    let (ordered_start, ordered_end) = candidates[0];
6190    if ordered_start > ordered_end {
6191        return None;
6192    }
6193    let start = (ordered_start.0, ordered_start.1);
6194    let end = (ordered_end.0, ordered_end.1);
6195    let mut parts = Vec::new();
6196    for (paragraph_index, paragraph) in paragraphs.iter().enumerate().take(end.0 + 1).skip(start.0)
6197    {
6198        parts.push(
6199            project_paragraph_runs(paragraph, input.revision_view)
6200                .into_iter()
6201                .enumerate()
6202                .filter(|(projected_index, _)| {
6203                    let position = (paragraph_index, *projected_index);
6204                    position >= start && position < end
6205                })
6206                .map(|(_, projected)| projected.run.text())
6207                .collect::<String>(),
6208        );
6209    }
6210    Some(parts.join("\n"))
6211}
6212
6213/// Whether any drawing in the document body wraps text around itself.
6214///
6215/// A document without one can never reach the reflow path, so it does not pay
6216/// for it.
6217fn document_has_wrapping_drawing(input: &LayoutInput) -> bool {
6218    fn paragraph_wraps(para: &CT_P, view: RevisionView) -> bool {
6219        fn run_wraps(run: &CT_R) -> bool {
6220            run.content
6221                .iter()
6222                .filter_map(|rc| match rc {
6223                    RunContent::Drawing(d) => Some(d),
6224                    _ => None,
6225                })
6226                .chain(run.alt_drawings.iter())
6227                .any(|drawing| {
6228                    drawing
6229                        .anchor
6230                        .as_ref()
6231                        .is_some_and(|anchor| anchor.wrap != WrapType::None)
6232                })
6233        }
6234
6235        if para.revisions.is_empty() && para.content_controls.is_empty() {
6236            para.runs.iter().any(run_wraps)
6237        } else {
6238            project_paragraph_runs(para, view)
6239                .iter()
6240                .any(|projected| run_wraps(projected.run))
6241        }
6242    }
6243
6244    input
6245        .document
6246        .body
6247        .content
6248        .iter()
6249        .any(|content| match content {
6250            BodyContent::Paragraph(para) => paragraph_wraps(para, input.revision_view),
6251            BodyContent::Table(table) => table
6252                .rows
6253                .iter()
6254                .flat_map(|row| row.cells.iter())
6255                .flat_map(|cell| cell.content.iter())
6256                .any(|content| match content {
6257                    rdocx_oxml::table::CellContent::Paragraph(para) => {
6258                        paragraph_wraps(para, input.revision_view)
6259                    }
6260                    // A drawing inside a nested table is rare enough that the
6261                    // conservative answer is to look no deeper.
6262                    rdocx_oxml::table::CellContent::Table(_) => false,
6263                    rdocx_oxml::table::CellContent::ContentControl(_) => false,
6264                }),
6265            _ => false,
6266        })
6267}
6268
6269fn render_word_chart(
6270    relationship_id: &str,
6271    width: f64,
6272    height: f64,
6273    input: &LayoutInput,
6274    fm: &mut FontManager,
6275    diagnostics: &mut Vec<Diagnostic>,
6276) -> Result<GroupElement> {
6277    let bounds = Rect {
6278        x: 0.0,
6279        y: 0.0,
6280        width,
6281        height,
6282    };
6283    let rendered = match input.charts.get(relationship_id) {
6284        Some(Ok(chart)) => oxml_chart::render_chart(
6285            &chart.chart,
6286            bounds,
6287            &input.chart_theme,
6288            &input.chart_color_map,
6289            fm,
6290        )
6291        .map_err(|error| error.to_string()),
6292        Some(Err(message)) => Err(message.clone()),
6293        None => Err("relationship was not resolved from the document part".to_owned()),
6294    };
6295    match rendered {
6296        Ok(group) => Ok(group),
6297        Err(detail) => {
6298            diagnostics.push(Diagnostic {
6299                message: format!("Word chart relationship {relationship_id}: {detail}"),
6300            });
6301            oxml_chart::render_chart_placeholder(bounds, fm)
6302                .map_err(|error| oxml_layout::LayoutError::Layout(error.to_string()))
6303        }
6304    }
6305}
6306
6307/// Collect the floating drawings anchored to a paragraph.
6308///
6309/// The offsets stay paired with the frame they are measured from. Resolving
6310/// them here is not possible: a paragraph-relative offset needs the laid-out
6311/// position of the paragraph, which only the paginator knows.
6312///
6313/// A shape's text box is laid out here rather than later, because breaking it
6314/// into lines needs the font manager.
6315fn collect_anchored_drawings(
6316    para: &CT_P,
6317    styles: &CT_Styles,
6318    input: &LayoutInput,
6319    media: &MediaRegistry,
6320    fm: &mut FontManager,
6321    num_state: &mut NumberingState,
6322    diagnostics: &mut Vec<Diagnostic>,
6323) -> Result<Vec<block::AnchoredDrawing>> {
6324    let mut out = Vec::new();
6325
6326    // Drawings written plainly, and drawings recovered from an
6327    // mc:AlternateContent block, are both anchored the same way.
6328    for projected in project_paragraph_runs(para, input.revision_view) {
6329        let run = projected.run;
6330        let plain = run.content.iter().filter_map(|rc| match rc {
6331            RunContent::Drawing(d) => Some(d),
6332            _ => None,
6333        });
6334        for drawing in plain.chain(run.alt_drawings.iter()) {
6335            let Some(anchor) = drawing.anchor.as_ref() else {
6336                continue;
6337            };
6338
6339            // A picture also carries a pic:spPr, so a parsed shape alone does
6340            // not mean this is a shape. An embed id is what makes it a
6341            // picture, and that takes precedence.
6342            let shape = if anchor.embed_id.is_empty() && anchor.chart_rel_id.is_none() {
6343                anchor.shape.as_ref()
6344            } else {
6345                None
6346            };
6347
6348            let content = if let Some(relationship_id) = anchor.chart_rel_id.as_deref() {
6349                block::AnchoredContent::Group(render_word_chart(
6350                    relationship_id,
6351                    anchor.extent_cx.to_pt(),
6352                    anchor.extent_cy.to_pt(),
6353                    input,
6354                    fm,
6355                    diagnostics,
6356                )?)
6357            } else {
6358                match shape {
6359                    Some(shape) => {
6360                        // A shape's text box wraps at the shape width.
6361                        let mut text = Vec::new();
6362                        for p in &shape.text {
6363                            text.push(layout_paragraph(
6364                                p,
6365                                anchor.extent_cx.to_pt(),
6366                                styles,
6367                                input,
6368                                media,
6369                                fm,
6370                                num_state,
6371                                diagnostics,
6372                            )?);
6373                        }
6374                        block::AnchoredContent::Shape {
6375                            preset: block::ShapePreset::from_prst(shape.preset.as_deref()),
6376                            fill: shape.solid_fill.as_deref().map(Color::from_hex),
6377                            text,
6378                        }
6379                    }
6380                    None if anchor.embed_id.is_empty() => continue,
6381                    None => block::AnchoredContent::Image {
6382                        media_id: media.id_for_relationship(&anchor.embed_id),
6383                    },
6384                }
6385            };
6386
6387            out.push(block::AnchoredDrawing {
6388                behind_doc: anchor.behind_doc,
6389                rel_h: anchor.pos_h_relative_from,
6390                off_h: anchor.pos_h_offset.to_pt(),
6391                rel_v: anchor.pos_v_relative_from,
6392                off_v: anchor.pos_v_offset.to_pt(),
6393                width: anchor.extent_cx.to_pt(),
6394                height: anchor.extent_cy.to_pt(),
6395                alternate_text: anchor.description.clone(),
6396                structure_id: None,
6397                wrap: anchor.wrap,
6398                dist_top: anchor.dist_t.to_pt(),
6399                dist_bottom: anchor.dist_b.to_pt(),
6400                dist_left: anchor.dist_l.to_pt(),
6401                dist_right: anchor.dist_r.to_pt(),
6402                align_h: anchor.pos_h_align,
6403                align_v: anchor.pos_v_align,
6404                content,
6405            });
6406        }
6407    }
6408    Ok(out)
6409}
6410
6411/// Merge direct paragraph properties (only fields explicitly set in the XML).
6412fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
6413    // Don't merge style_id — that was already used for resolution
6414    if direct.jc.is_some() {
6415        effective.jc = direct.jc;
6416    }
6417    if direct.space_before.is_some() {
6418        effective.space_before = direct.space_before;
6419    }
6420    if direct.space_after.is_some() {
6421        effective.space_after = direct.space_after;
6422    }
6423    if direct.line_spacing.is_some() {
6424        effective.line_spacing = direct.line_spacing;
6425    }
6426    if direct.line_rule.is_some() {
6427        effective.line_rule = direct.line_rule.clone();
6428    }
6429    if direct.ind_left.is_some() {
6430        effective.ind_left = direct.ind_left;
6431    }
6432    if direct.ind_right.is_some() {
6433        effective.ind_right = direct.ind_right;
6434    }
6435    if direct.ind_start.is_some() {
6436        effective.ind_start = direct.ind_start;
6437    }
6438    if direct.ind_end.is_some() {
6439        effective.ind_end = direct.ind_end;
6440    }
6441    if direct.ind_first_line.is_some() {
6442        effective.ind_first_line = direct.ind_first_line;
6443    }
6444    if direct.ind_hanging.is_some() {
6445        effective.ind_hanging = direct.ind_hanging;
6446    }
6447    if direct.keep_next.is_some() {
6448        effective.keep_next = direct.keep_next;
6449    }
6450    if direct.keep_lines.is_some() {
6451        effective.keep_lines = direct.keep_lines;
6452    }
6453    if direct.page_break_before.is_some() {
6454        effective.page_break_before = direct.page_break_before;
6455    }
6456    if direct.widow_control.is_some() {
6457        effective.widow_control = direct.widow_control;
6458    }
6459    if direct.suppress_auto_hyphens.is_some() {
6460        effective.suppress_auto_hyphens = direct.suppress_auto_hyphens;
6461    }
6462    if direct.bidi.is_some() {
6463        effective.bidi = direct.bidi;
6464    }
6465    if direct.borders.is_some() {
6466        effective.borders = direct.borders.clone();
6467    }
6468    if direct.tabs.is_some() {
6469        effective.tabs = direct.tabs.clone();
6470    }
6471    if direct.shading.is_some() {
6472        effective.shading = direct.shading.clone();
6473    }
6474    if direct.num_id.is_some() {
6475        effective.num_id = direct.num_id;
6476    }
6477    if direct.num_ilvl.is_some() {
6478        effective.num_ilvl = direct.num_ilvl;
6479    }
6480}
6481
6482/// Convert section properties to page geometry.
6483fn sect_pr_to_geometry(sect_pr: &CT_SectPr) -> PageGeometry {
6484    PageGeometry {
6485        page_width: sect_pr.page_width.map(|t| t.to_pt()).unwrap_or(612.0),
6486        page_height: sect_pr.page_height.map(|t| t.to_pt()).unwrap_or(792.0),
6487        margin_top: sect_pr.margin_top.map(|t| t.to_pt()).unwrap_or(72.0),
6488        margin_right: sect_pr.margin_right.map(|t| t.to_pt()).unwrap_or(72.0),
6489        margin_bottom: sect_pr.margin_bottom.map(|t| t.to_pt()).unwrap_or(72.0),
6490        margin_left: sect_pr.margin_left.map(|t| t.to_pt()).unwrap_or(72.0),
6491        header_distance: sect_pr.header_distance.map(|t| t.to_pt()).unwrap_or(36.0),
6492        footer_distance: sect_pr.footer_distance.map(|t| t.to_pt()).unwrap_or(36.0),
6493    }
6494}
6495
6496fn section_page_number_start(sect_pr: &CT_SectPr) -> Option<usize> {
6497    for raw in &sect_pr.extra_xml {
6498        let Some((name, raw_attributes)) = raw_root_start_tag(raw) else {
6499            continue;
6500        };
6501        let Some(attributes) = parse_raw_attributes(raw_attributes) else {
6502            continue;
6503        };
6504        if xml_local_name(name) != b"pgNumType"
6505            || !raw_name_has_namespace(name, &attributes, rdocx_oxml::namespace::W_NS, false)
6506        {
6507            continue;
6508        }
6509        let (_, value) = attributes.iter().find(|(attribute_name, _)| {
6510            xml_local_name(attribute_name) == b"start"
6511                && raw_name_has_namespace(
6512                    attribute_name,
6513                    &attributes,
6514                    rdocx_oxml::namespace::W_NS,
6515                    true,
6516                )
6517        })?;
6518        return decode_xml_attribute(value)?.parse().ok();
6519    }
6520    None
6521}
6522
6523fn xml_local_name(name: &[u8]) -> &[u8] {
6524    name.rsplit(|byte| *byte == b':').next().unwrap_or(name)
6525}
6526
6527fn raw_root_start_tag(raw: &[u8]) -> Option<(&[u8], &[u8])> {
6528    let mut cursor = 0usize;
6529    while cursor < raw.len() && raw[cursor].is_ascii_whitespace() {
6530        cursor += 1;
6531    }
6532    if raw.get(cursor) != Some(&b'<') {
6533        return None;
6534    }
6535    cursor += 1;
6536    if matches!(raw.get(cursor), Some(b'!' | b'?' | b'/')) {
6537        return None;
6538    }
6539    let name_start = cursor;
6540    while cursor < raw.len()
6541        && !raw[cursor].is_ascii_whitespace()
6542        && !matches!(raw[cursor], b'>' | b'/')
6543    {
6544        cursor += 1;
6545    }
6546    if cursor == name_start {
6547        return None;
6548    }
6549    let name_end = cursor;
6550    let attributes_start = cursor;
6551    let mut quote = None;
6552    while cursor < raw.len() {
6553        match (quote, raw[cursor]) {
6554            (None, b'\'' | b'"') => quote = Some(raw[cursor]),
6555            (Some(expected), found) if expected == found => quote = None,
6556            (None, b'>') => {
6557                return Some((&raw[name_start..name_end], &raw[attributes_start..cursor]));
6558            }
6559            _ => {}
6560        }
6561        cursor += 1;
6562    }
6563    None
6564}
6565
6566fn parse_raw_attributes(attributes: &[u8]) -> Option<Vec<(&[u8], &[u8])>> {
6567    let mut parsed = Vec::new();
6568    let mut cursor = 0usize;
6569    while cursor < attributes.len() {
6570        while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
6571            cursor += 1;
6572        }
6573        if cursor == attributes.len() || attributes[cursor] == b'/' {
6574            break;
6575        }
6576        let name_start = cursor;
6577        while cursor < attributes.len()
6578            && !attributes[cursor].is_ascii_whitespace()
6579            && attributes[cursor] != b'='
6580        {
6581            cursor += 1;
6582        }
6583        let name_end = cursor;
6584        while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
6585            cursor += 1;
6586        }
6587        if attributes.get(cursor) != Some(&b'=') {
6588            cursor = cursor.saturating_add(1);
6589            continue;
6590        }
6591        cursor += 1;
6592        while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
6593            cursor += 1;
6594        }
6595        let quote = *attributes.get(cursor)?;
6596        if !matches!(quote, b'\'' | b'"') {
6597            return None;
6598        }
6599        cursor += 1;
6600        let value_start = cursor;
6601        while cursor < attributes.len() && attributes[cursor] != quote {
6602            cursor += 1;
6603        }
6604        let value_end = cursor;
6605        cursor += 1;
6606        parsed.push((
6607            &attributes[name_start..name_end],
6608            &attributes[value_start..value_end],
6609        ));
6610    }
6611    Some(parsed)
6612}
6613
6614fn raw_name_has_namespace(
6615    name: &[u8],
6616    attributes: &[(&[u8], &[u8])],
6617    expected: &str,
6618    is_attribute: bool,
6619) -> bool {
6620    let prefix = name
6621        .iter()
6622        .rposition(|byte| *byte == b':')
6623        .map(|separator| &name[..separator]);
6624    let namespace = match prefix {
6625        Some(prefix) => attributes.iter().find_map(|(attribute_name, value)| {
6626            attribute_name
6627                .strip_prefix(b"xmlns:")
6628                .is_some_and(|declared| declared == prefix)
6629                .then_some(*value)
6630        }),
6631        None if !is_attribute => attributes
6632            .iter()
6633            .find_map(|(attribute_name, value)| (*attribute_name == b"xmlns").then_some(*value)),
6634        None => None,
6635    };
6636    match namespace {
6637        Some(namespace) => decode_xml_attribute(namespace).is_some_and(|value| value == expected),
6638        None => prefix == Some(b"w".as_slice()) && expected == rdocx_oxml::namespace::W_NS,
6639    }
6640}
6641
6642fn decode_xml_attribute(value: &[u8]) -> Option<String> {
6643    let value = std::str::from_utf8(value).ok()?;
6644    let mut decoded = String::with_capacity(value.len());
6645    let mut cursor = 0usize;
6646    while let Some(relative_start) = value[cursor..].find('&') {
6647        let entity_start = cursor + relative_start;
6648        decoded.push_str(&value[cursor..entity_start]);
6649        let entity_end = entity_start + value[entity_start..].find(';')?;
6650        let entity = &value[entity_start + 1..entity_end];
6651        match entity {
6652            "amp" => decoded.push('&'),
6653            "apos" => decoded.push('\''),
6654            "gt" => decoded.push('>'),
6655            "lt" => decoded.push('<'),
6656            "quot" => decoded.push('"'),
6657            numeric if numeric.starts_with("#x") => {
6658                decoded.push(char::from_u32(
6659                    u32::from_str_radix(&numeric[2..], 16).ok()?,
6660                )?);
6661            }
6662            numeric if numeric.starts_with('#') => {
6663                decoded.push(char::from_u32(numeric[1..].parse().ok()?)?);
6664            }
6665            _ => return None,
6666        }
6667        cursor = entity_end + 1;
6668    }
6669    decoded.push_str(&value[cursor..]);
6670    Some(decoded)
6671}
6672
6673/// Lay out header and footer content (both Default and First-page).
6674fn layout_header_footer(
6675    engine: &mut Engine,
6676    sect_pr: &CT_SectPr,
6677    input: &LayoutInput,
6678    styles: &CT_Styles,
6679    media: &MediaRegistry,
6680    num_state: &mut NumberingState,
6681    diagnostics: &mut Vec<Diagnostic>,
6682    sources: Option<&SourceRegistry>,
6683) -> Result<Option<(HeaderFooterContent, HeaderFooterSemantics)>> {
6684    let mut has_content = false;
6685    let mut header_blocks = Vec::new();
6686    let mut footer_blocks = Vec::new();
6687    let mut first_header_blocks = Vec::new();
6688    let mut first_footer_blocks = Vec::new();
6689    let mut even_header_blocks = Vec::new();
6690    let mut even_footer_blocks = Vec::new();
6691    let mut header_directions = Vec::new();
6692    let mut footer_directions = Vec::new();
6693    let mut first_header_directions = Vec::new();
6694    let mut first_footer_directions = Vec::new();
6695    let mut even_header_directions = Vec::new();
6696    let mut even_footer_directions = Vec::new();
6697    let mut watermark = None;
6698    let mut first_watermark = None;
6699    let mut even_watermark = None;
6700    let even_headers_active = sect_pr
6701        .header_refs
6702        .iter()
6703        .any(|reference| reference.hdr_ftr_type == HdrFtrType::Even);
6704
6705    let geometry = sect_pr_to_geometry(sect_pr);
6706    let width = geometry.content_width();
6707
6708    for href in &sect_pr.header_refs {
6709        let (target_blocks, target_directions, target_watermark) = match href.hdr_ftr_type {
6710            HdrFtrType::Default => (&mut header_blocks, &mut header_directions, &mut watermark),
6711            HdrFtrType::First => (
6712                &mut first_header_blocks,
6713                &mut first_header_directions,
6714                &mut first_watermark,
6715            ),
6716            HdrFtrType::Even => (
6717                &mut even_header_blocks,
6718                &mut even_header_directions,
6719                &mut even_watermark,
6720            ),
6721        };
6722        if let Some(hdr) = input.headers.get(&href.rel_id) {
6723            let content = layout_header_footer_variant(
6724                engine,
6725                HeaderFooterStoryKind::Header,
6726                href.hdr_ftr_type,
6727                sect_pr,
6728                &href.rel_id,
6729                hdr,
6730                input,
6731                styles,
6732                media,
6733                num_state,
6734                diagnostics,
6735                sources,
6736                width,
6737                geometry,
6738            )?;
6739            target_blocks.extend(content.blocks);
6740            target_directions.extend(content.directions);
6741            if target_watermark.is_none() {
6742                *target_watermark = content.watermark;
6743            }
6744            has_content = true;
6745        }
6746    }
6747
6748    for fref in &sect_pr.footer_refs {
6749        let (target_blocks, target_directions) = match fref.hdr_ftr_type {
6750            HdrFtrType::Default => (&mut footer_blocks, &mut footer_directions),
6751            HdrFtrType::First => (&mut first_footer_blocks, &mut first_footer_directions),
6752            HdrFtrType::Even => (&mut even_footer_blocks, &mut even_footer_directions),
6753        };
6754        if let Some(ftr) = input.footers.get(&fref.rel_id) {
6755            let content = layout_header_footer_variant(
6756                engine,
6757                HeaderFooterStoryKind::Footer,
6758                fref.hdr_ftr_type,
6759                sect_pr,
6760                &fref.rel_id,
6761                ftr,
6762                input,
6763                styles,
6764                media,
6765                num_state,
6766                diagnostics,
6767                sources,
6768                width,
6769                geometry,
6770            )?;
6771            target_blocks.extend(content.blocks);
6772            target_directions.extend(content.directions);
6773            has_content = true;
6774        }
6775    }
6776
6777    if has_content {
6778        Ok(Some((
6779            HeaderFooterContent {
6780                header_blocks,
6781                footer_blocks,
6782                first_header_blocks,
6783                first_footer_blocks,
6784                even_header_blocks,
6785                even_footer_blocks,
6786                even_headers_active,
6787                watermark,
6788                first_watermark,
6789                even_watermark,
6790            },
6791            HeaderFooterSemantics {
6792                header_directions,
6793                footer_directions,
6794                first_header_directions,
6795                first_footer_directions,
6796                even_header_directions,
6797                even_footer_directions,
6798            },
6799        )))
6800    } else {
6801        Ok(None)
6802    }
6803}
6804
6805#[allow(clippy::too_many_arguments)]
6806fn layout_header_footer_variant(
6807    engine: &mut Engine,
6808    story_kind: HeaderFooterStoryKind,
6809    variant: HdrFtrType,
6810    sect_pr: &CT_SectPr,
6811    relationship_id: &str,
6812    part: &rdocx_oxml::header_footer::CT_HdrFtr,
6813    input: &LayoutInput,
6814    styles: &CT_Styles,
6815    media: &MediaRegistry,
6816    num_state: &mut NumberingState,
6817    diagnostics: &mut Vec<Diagnostic>,
6818    sources: Option<&SourceRegistry>,
6819    width: f64,
6820    geometry: PageGeometry,
6821) -> Result<HeaderFooterVariantContent> {
6822    let cache_safe = header_footer_section_is_cache_safe(sect_pr)
6823        && header_footer_part_is_cache_safe(part, styles);
6824    let resolved_part_bytes = match story_kind {
6825        HeaderFooterStoryKind::Header => part.to_xml_header(),
6826        HeaderFooterStoryKind::Footer => part.to_xml_footer(),
6827    };
6828    if !cache_safe || resolved_part_bytes.is_err() {
6829        return layout_header_footer_variant_uncached(
6830            story_kind,
6831            relationship_id,
6832            part,
6833            input,
6834            styles,
6835            media,
6836            &mut engine.font_manager,
6837            num_state,
6838            diagnostics,
6839            sources,
6840            false,
6841            width,
6842            geometry,
6843        );
6844    }
6845
6846    let key = HeaderFooterCacheKey {
6847        story: story_kind,
6848        variant,
6849        section: sect_pr.clone(),
6850        relationship_id: relationship_id.to_owned(),
6851        part: part.clone(),
6852        resolved_part_bytes: resolved_part_bytes.expect("checked resolved part bytes"),
6853        with_provenance: sources.is_some(),
6854    };
6855    let hit = engine
6856        .header_footer_cache_reads_enabled
6857        .then(|| {
6858            engine
6859                .header_footer_cache
6860                .iter()
6861                .find(|entry| entry.key == key)
6862                .map(|entry| {
6863                    (
6864                        entry.content.clone(),
6865                        entry.diagnostics.clone(),
6866                        entry.font_trace.clone(),
6867                    )
6868                })
6869        })
6870        .flatten();
6871    if let Some((mut content, cached_diagnostics, font_trace)) = hit {
6872        rebind_header_footer_sources(
6873            story_kind,
6874            relationship_id,
6875            part,
6876            &mut content.blocks,
6877            sources,
6878        )?;
6879        diagnostics.extend(cached_diagnostics);
6880        engine.font_manager.replay_layout_font_trace(&font_trace);
6881        engine.header_footer_cache_hits += 1;
6882        return Ok(content);
6883    }
6884
6885    let diagnostics_start = diagnostics.len();
6886    engine.font_manager.begin_paragraph_font_trace();
6887    let content_result = layout_header_footer_variant_uncached(
6888        story_kind,
6889        relationship_id,
6890        part,
6891        input,
6892        styles,
6893        media,
6894        &mut engine.font_manager,
6895        num_state,
6896        diagnostics,
6897        None,
6898        true,
6899        width,
6900        geometry,
6901    );
6902    let font_trace = engine.font_manager.finish_paragraph_font_trace();
6903    let mut content = content_result?;
6904    engine.header_footer_cache_builds += 1;
6905    let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
6906    if let Some(font_trace) = font_trace {
6907        let bytes =
6908            header_footer_cache_entry_bytes(&key, &content, &cached_diagnostics, &font_trace);
6909        engine.stage_header_footer_cache_entry(HeaderFooterCacheEntry {
6910            key,
6911            content: content.clone(),
6912            diagnostics: cached_diagnostics,
6913            font_trace,
6914            bytes,
6915        });
6916    }
6917    rebind_header_footer_sources(
6918        story_kind,
6919        relationship_id,
6920        part,
6921        &mut content.blocks,
6922        sources,
6923    )?;
6924    Ok(content)
6925}
6926
6927#[allow(clippy::too_many_arguments)]
6928fn layout_header_footer_variant_uncached(
6929    story_kind: HeaderFooterStoryKind,
6930    relationship_id: &str,
6931    part: &rdocx_oxml::header_footer::CT_HdrFtr,
6932    input: &LayoutInput,
6933    styles: &CT_Styles,
6934    media: &MediaRegistry,
6935    fm: &mut FontManager,
6936    num_state: &mut NumberingState,
6937    diagnostics: &mut Vec<Diagnostic>,
6938    sources: Option<&SourceRegistry>,
6939    cache_source: bool,
6940    width: f64,
6941    geometry: PageGeometry,
6942) -> Result<HeaderFooterVariantContent> {
6943    let watermark = if story_kind == HeaderFooterStoryKind::Header {
6944        match part.watermarks().first() {
6945            Some(projected) => layout_watermark(
6946                projected,
6947                relationship_id,
6948                input,
6949                media,
6950                fm,
6951                geometry,
6952                diagnostics,
6953            )?,
6954            None => None,
6955        }
6956    } else {
6957        None
6958    };
6959    let story = match story_kind {
6960        HeaderFooterStoryKind::Header => WordStory::Header {
6961            relationship_id: relationship_id.to_owned(),
6962        },
6963        HeaderFooterStoryKind::Footer => WordStory::Footer {
6964            relationship_id: relationship_id.to_owned(),
6965        },
6966    };
6967    let mut blocks = Vec::with_capacity(part.paragraphs.len());
6968    let mut directions = Vec::with_capacity(part.paragraphs.len());
6969    for (paragraph_index, paragraph) in part.paragraphs.iter().enumerate() {
6970        let source = if cache_source {
6971            Some(CACHE_SOURCE_NODE)
6972        } else {
6973            sources.and_then(|sources| sources.id(&story, &[paragraph_index]))
6974        };
6975        let (block, direction) = layout_paragraph_with_source_and_direction(
6976            paragraph,
6977            width,
6978            styles,
6979            input,
6980            media,
6981            fm,
6982            num_state,
6983            diagnostics,
6984            source,
6985        )?;
6986        blocks.push(block);
6987        directions.push(direction);
6988    }
6989    Ok(HeaderFooterVariantContent {
6990        blocks,
6991        directions,
6992        watermark,
6993    })
6994}
6995
6996fn layout_watermark(
6997    watermark: &VmlWatermark,
6998    header_relationship_id: &str,
6999    input: &LayoutInput,
7000    media: &MediaRegistry,
7001    fm: &mut FontManager,
7002    geometry: PageGeometry,
7003    diagnostics: &mut Vec<Diagnostic>,
7004) -> Result<Option<GroupElement>> {
7005    let (width, height, rotation, opacity) = match watermark {
7006        VmlWatermark::Text {
7007            width_pt,
7008            height_pt,
7009            rotation_degrees,
7010            opacity,
7011            ..
7012        }
7013        | VmlWatermark::Image {
7014            width_pt,
7015            height_pt,
7016            rotation_degrees,
7017            opacity,
7018            ..
7019        } => (*width_pt, *height_pt, *rotation_degrees, *opacity),
7020    };
7021    let translate = Transform {
7022        e: geometry.margin_left + (geometry.content_width() - width) / 2.0,
7023        f: geometry.margin_top + (geometry.content_height() - height) / 2.0,
7024        ..Transform::IDENTITY
7025    };
7026    let transform = Transform::rotate_about(rotation, width / 2.0, height / 2.0).then(translate);
7027    let children = match watermark {
7028        VmlWatermark::Text {
7029            text,
7030            color,
7031            font_family,
7032            ..
7033        } => {
7034            let Some(color) = vml_color(color) else {
7035                diagnostics.push(Diagnostic {
7036                    message: format!("VML watermark colour {color:?} is unsupported"),
7037                });
7038                return Ok(None);
7039            };
7040            let estimated = width / (text.chars().count().max(1) as f64 * 0.62);
7041            let font_size = (height * 0.62).min(estimated).max(1.0);
7042            let font_id = fm.resolve_font_for_text(
7043                font_family.as_deref().or(Some("Calibri")),
7044                false,
7045                false,
7046                text,
7047            )?;
7048            let shaped = fm.shape_text(font_id, text, font_size)?;
7049            let metrics = fm.metrics(font_id, font_size)?;
7050            vec![PositionedElement::Text(GlyphRun {
7051                origin: Point {
7052                    x: (width - shaped.width) / 2.0,
7053                    y: (height + metrics.ascent - metrics.descent) / 2.0,
7054                },
7055                font_id,
7056                font_size,
7057                glyph_ids: shaped.glyph_ids,
7058                advances: shaped.advances,
7059                text: text.clone(),
7060                source: None,
7061                color,
7062                bold: false,
7063                italic: false,
7064                field_kind: None,
7065                note: None,
7066            })]
7067        }
7068        VmlWatermark::Image {
7069            relationship_id, ..
7070        } => {
7071            let scoped_id = format!("{header_relationship_id}\0{relationship_id}");
7072            let Some(image) = input.images.get(&scoped_id) else {
7073                diagnostics.push(Diagnostic {
7074                    message: format!(
7075                        "VML watermark image relationship {relationship_id} in header {header_relationship_id} was not resolved"
7076                    ),
7077                });
7078                return Ok(None);
7079            };
7080            let data = image.data.clone();
7081            vec![PositionedElement::Image {
7082                rect: Rect {
7083                    x: 0.0,
7084                    y: 0.0,
7085                    width,
7086                    height,
7087                },
7088                content_type: image.content_type.clone(),
7089                media_id: media.id_for_relationship(&scoped_id),
7090                data,
7091            }]
7092        }
7093    };
7094    Ok(Some(GroupElement {
7095        transform,
7096        clip: None,
7097        opacity,
7098        effects: Vec::new(),
7099        children,
7100    }))
7101}
7102
7103fn vml_color(value: &str) -> Option<Color> {
7104    let normalized = value.trim().to_ascii_lowercase();
7105    let hex = match normalized.as_str() {
7106        "black" => "000000",
7107        "silver" => "c0c0c0",
7108        "gray" | "grey" => "808080",
7109        "white" => "ffffff",
7110        "maroon" => "800000",
7111        "red" => "ff0000",
7112        "purple" => "800080",
7113        "fuchsia" | "magenta" => "ff00ff",
7114        "green" => "008000",
7115        "lime" => "00ff00",
7116        "olive" => "808000",
7117        "yellow" => "ffff00",
7118        "navy" => "000080",
7119        "blue" => "0000ff",
7120        "teal" => "008080",
7121        "aqua" | "cyan" => "00ffff",
7122        _ => normalized.trim_start_matches('#'),
7123    };
7124    (hex.len() == 6 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
7125        .then(|| Color::from_hex(hex))
7126}
7127
7128/// Resolve the effective font family for a run, considering theme fonts.
7129///
7130/// Priority: explicit font_ascii > theme font > None (use default).
7131fn resolve_font_family(
7132    rpr: &rdocx_oxml::properties::CT_RPr,
7133    theme: Option<&rdocx_oxml::theme::Theme>,
7134) -> Option<String> {
7135    // Explicit font name takes priority
7136    if rpr.font_ascii.is_some() {
7137        return rpr.font_ascii.clone();
7138    }
7139
7140    // Resolve theme font reference
7141    if let (Some(theme_ref), Some(theme)) = (&rpr.font_ascii_theme, theme) {
7142        let font = match theme_ref.as_str() {
7143            "majorAscii" | "majorHAnsi" | "majorBidi" | "majorEastAsia" => {
7144                theme.major_font.as_deref()
7145            }
7146            "minorAscii" | "minorHAnsi" | "minorBidi" | "minorEastAsia" => {
7147                theme.minor_font.as_deref()
7148            }
7149            _ => None,
7150        };
7151        if let Some(f) = font {
7152            return Some(f.to_string());
7153        }
7154    }
7155
7156    None
7157}
7158
7159/// Resolve the effective color for a run, considering theme colors.
7160///
7161/// Priority: literal color (non-auto) > theme color > black.
7162fn resolve_run_color(
7163    rpr: &rdocx_oxml::properties::CT_RPr,
7164    theme: Option<&rdocx_oxml::theme::Theme>,
7165) -> Color {
7166    // If theme color is specified, resolve it from the theme
7167    if let Some(ref theme_name) = rpr.color_theme
7168        && let Some(theme) = theme
7169        && let Some(hex) = theme.colors.get(theme_name)
7170    {
7171        return Color::from_hex(hex);
7172    }
7173
7174    // Fall back to literal color value
7175    rpr.color
7176        .as_ref()
7177        .filter(|c| c.as_str() != "auto")
7178        .map(|c| Color::from_hex(c))
7179        .unwrap_or(Color::BLACK)
7180}
7181
7182#[derive(Clone, Copy, PartialEq, Eq)]
7183enum WordLanguageSlot {
7184    Direct,
7185    EastAsia,
7186    Bidi,
7187}
7188
7189fn word_language_slot(character: char) -> Option<WordLanguageSlot> {
7190    match character as u32 {
7191        0x0590..=0x08ff | 0xfb1d..=0xfdff | 0xfe70..=0xfeff => Some(WordLanguageSlot::Bidi),
7192        0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff => Some(WordLanguageSlot::EastAsia),
7193        0x0041..=0x024f | 0x0900..=0x097f | 0x0e00..=0x0e7f | 0x1e00..=0x1eff => {
7194            Some(WordLanguageSlot::Direct)
7195        }
7196        _ => None,
7197    }
7198}
7199
7200fn word_language_for_slot(style: &WordMultilingualStyle, slot: WordLanguageSlot) -> Option<String> {
7201    match slot {
7202        WordLanguageSlot::Direct => style.language.clone(),
7203        WordLanguageSlot::EastAsia => style
7204            .language_east_asia
7205            .clone()
7206            .or_else(|| style.language.clone()),
7207        WordLanguageSlot::Bidi => style
7208            .language_bidi
7209            .clone()
7210            .or_else(|| style.language.clone()),
7211    }
7212}
7213
7214fn word_text_direction(rtl: Option<bool>) -> TextDirection {
7215    match rtl {
7216        Some(true) => TextDirection::RightToLeft,
7217        Some(false) => TextDirection::LeftToRight,
7218        None => TextDirection::Auto,
7219    }
7220}
7221
7222fn word_language_ranges(text: &str) -> Vec<(usize, usize, WordLanguageSlot)> {
7223    let mut ranges = Vec::new();
7224    let mut start = 0usize;
7225    let mut slot = WordLanguageSlot::Direct;
7226    for (offset, character) in text.char_indices() {
7227        let Some(next_slot) = word_language_slot(character) else {
7228            continue;
7229        };
7230        if next_slot != slot && offset > start {
7231            ranges.push((start, offset, slot));
7232            start = offset;
7233        }
7234        slot = next_slot;
7235    }
7236    if start < text.len() {
7237        ranges.push((start, text.len(), slot));
7238    }
7239    ranges
7240}
7241
7242fn word_multilingual_segment_slice(
7243    segment: &TextSegment,
7244    byte_start: usize,
7245    byte_end: usize,
7246) -> Result<TextSegment> {
7247    let mut slice = segment.clone();
7248    slice.text = segment.text[byte_start..byte_end].to_owned();
7249    if let Some(source) = segment.source {
7250        let char_start =
7251            u32::try_from(segment.text[..byte_start].chars().count()).map_err(|_| {
7252                oxml_layout::LayoutError::Layout(
7253                    "Word multilingual source offset exceeds the supported range".to_owned(),
7254                )
7255            })?;
7256        let char_len = u32::try_from(slice.text.chars().count()).map_err(|_| {
7257            oxml_layout::LayoutError::Layout(
7258                "Word multilingual source length exceeds the supported range".to_owned(),
7259            )
7260        })?;
7261        slice.source = Some(SourceSpan {
7262            node: source.node,
7263            char_start: source.char_start.checked_add(char_start).ok_or_else(|| {
7264                oxml_layout::LayoutError::Layout(
7265                    "Word multilingual source offset overflowed".to_owned(),
7266                )
7267            })?,
7268            char_end: source
7269                .char_start
7270                .checked_add(char_start)
7271                .and_then(|start| start.checked_add(char_len))
7272                .ok_or_else(|| {
7273                    oxml_layout::LayoutError::Layout(
7274                        "Word multilingual source range overflowed".to_owned(),
7275                    )
7276                })?,
7277        });
7278    }
7279    slice.glyph_ids.clear();
7280    slice.advances.clear();
7281    slice.width = 0.0;
7282    Ok(slice)
7283}
7284
7285fn needs_word_multilingual_layout(text: &str) -> bool {
7286    text.chars().any(|character| {
7287        matches!(
7288            character as u32,
7289            0x0590..=0x08ff
7290                | 0x0900..=0x097f
7291                | 0x0e00..=0x0e7f
7292                | 0x3000..=0x30ff
7293                | 0x3400..=0x9fff
7294                | 0xf900..=0xfaff
7295                | 0xfb1d..=0xfdff
7296                | 0xfe70..=0xfeff
7297        )
7298    })
7299}
7300
7301fn inferred_word_base_direction(items: &[InlineItem]) -> TextDirection {
7302    for character in items
7303        .iter()
7304        .filter_map(|item| match item {
7305            InlineItem::Text(segment)
7306            | InlineItem::HyphenatedText { segment, .. }
7307            | InlineItem::Marker(segment) => Some(segment.text.as_str()),
7308            _ => None,
7309        })
7310        .flat_map(str::chars)
7311    {
7312        if character == '\u{200e}' {
7313            return TextDirection::LeftToRight;
7314        }
7315        if character == '\u{200f}' {
7316            return TextDirection::RightToLeft;
7317        }
7318        if !character.is_alphabetic() {
7319            continue;
7320        }
7321        return if matches!(
7322            character as u32,
7323            0x0590..=0x08ff | 0xfb1d..=0xfdff | 0xfe70..=0xfeff
7324        ) {
7325            TextDirection::RightToLeft
7326        } else {
7327            TextDirection::LeftToRight
7328        };
7329    }
7330    TextDirection::LeftToRight
7331}
7332
7333fn multilingual_candidate(item: &InlineItem) -> Option<&TextSegment> {
7334    match item {
7335        InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. }
7336            if !segment.text.is_empty() && segment.field_kind.is_none() =>
7337        {
7338            Some(segment)
7339        }
7340        _ => None,
7341    }
7342}
7343
7344fn apply_word_multilingual_spacing(
7345    segment: oxml_layout::MultilingualTextSegment,
7346    spacing: f64,
7347    exact_word_baseline: bool,
7348) -> Result<oxml_layout::MultilingualTextSegment> {
7349    if spacing == 0.0 && !exact_word_baseline {
7350        return Ok(segment);
7351    }
7352    let mut base = segment.base().clone();
7353    let x_advances = segment
7354        .x_advances()
7355        .iter()
7356        .map(|advance| advance + spacing)
7357        .collect::<Vec<_>>();
7358    base.advances = x_advances.clone();
7359    base.width = x_advances.iter().sum();
7360    if exact_word_baseline {
7361        base.ascent = base.font_size * WORD_EXACT_LINE_BASELINE_EM;
7362    }
7363    oxml_layout::MultilingualTextSegment::new(
7364        base,
7365        segment.logical_index(),
7366        segment.language().map(str::to_owned),
7367        segment.script(),
7368        segment.direction(),
7369        segment.bidi_level(),
7370        x_advances,
7371        segment.y_advances().to_vec(),
7372        segment.x_offsets().to_vec(),
7373        segment.y_offsets().to_vec(),
7374        segment.clusters().to_vec(),
7375        segment.break_after(),
7376    )
7377}
7378
7379fn shape_word_multilingual_items(
7380    font_manager: &mut FontManager,
7381    legacy_items: Vec<InlineItem>,
7382    styles: &HashMap<usize, WordMultilingualStyle>,
7383    base_direction: TextDirection,
7384    no_wrap: bool,
7385    exact_word_baseline: bool,
7386) -> Result<Vec<InlineItem>> {
7387    let mut styled = Vec::new();
7388    for (index, item) in legacy_items.iter().enumerate() {
7389        let Some(segment) = multilingual_candidate(item) else {
7390            continue;
7391        };
7392        if let Some(style) = styles.get(&index) {
7393            for (byte_start, byte_end, slot) in word_language_ranges(&segment.text) {
7394                let mut slice = word_multilingual_segment_slice(segment, byte_start, byte_end)?;
7395                slice.direction = style.direction;
7396                styled.push((slice, word_language_for_slot(style, slot)));
7397            }
7398        } else {
7399            let language = match item {
7400                InlineItem::HyphenatedText { language, .. } => Some(language.clone()),
7401                _ => None,
7402            };
7403            styled.push((segment.clone(), language));
7404        }
7405    }
7406    let mut shaped = VecDeque::from(font_manager.shape_multilingual_paragraph(
7407        styled,
7408        base_direction,
7409        no_wrap,
7410    )?);
7411    let mut items = Vec::with_capacity(legacy_items.len());
7412    for (index, mut item) in legacy_items.into_iter().enumerate() {
7413        let Some(segment) = multilingual_candidate(&item) else {
7414            if exact_word_baseline {
7415                match &mut item {
7416                    InlineItem::Text(segment)
7417                    | InlineItem::Marker(segment)
7418                    | InlineItem::HyphenatedText { segment, .. } => {
7419                        segment.ascent = segment.font_size * WORD_EXACT_LINE_BASELINE_EM;
7420                    }
7421                    _ => {}
7422                }
7423            }
7424            items.push(item);
7425            continue;
7426        };
7427        let target_bytes = segment.text.len();
7428        let mut consumed_bytes = 0usize;
7429        while consumed_bytes < target_bytes {
7430            let span = shaped.pop_front().ok_or_else(|| {
7431                oxml_layout::LayoutError::Layout(
7432                    "multilingual Word shaping lost a styled text run".to_owned(),
7433                )
7434            })?;
7435            consumed_bytes = consumed_bytes
7436                .checked_add(span.text().len())
7437                .filter(|consumed| *consumed <= target_bytes)
7438                .ok_or_else(|| {
7439                    oxml_layout::LayoutError::Layout(
7440                        "multilingual Word shaping crossed a styled text boundary".to_owned(),
7441                    )
7442                })?;
7443            let spacing = styles.get(&index).map_or(0.0, |style| style.spacing);
7444            let span = apply_word_multilingual_spacing(span, spacing, exact_word_baseline)?;
7445            if let InlineItem::HyphenatedText { language, .. } = &item
7446                && !needs_word_multilingual_layout(span.text())
7447            {
7448                items.push(InlineItem::HyphenatedText {
7449                    segment: span.base().clone(),
7450                    language: language.clone(),
7451                });
7452            } else {
7453                items.push(InlineItem::MultilingualText(span));
7454            }
7455        }
7456    }
7457    if !shaped.is_empty() {
7458        return Err(oxml_layout::LayoutError::Layout(
7459            "multilingual Word shaping produced an unmatched text run".to_owned(),
7460        ));
7461    }
7462    Ok(items)
7463}
7464
7465/// Convert a highlight color enum to an RGBA Color.
7466fn highlight_to_color(h: ST_HighlightColor) -> Option<Color> {
7467    match h {
7468        ST_HighlightColor::None => None,
7469        ST_HighlightColor::Black => Some(Color {
7470            r: 0.0,
7471            g: 0.0,
7472            b: 0.0,
7473            a: 1.0,
7474        }),
7475        ST_HighlightColor::Blue => Some(Color {
7476            r: 0.0,
7477            g: 0.0,
7478            b: 1.0,
7479            a: 1.0,
7480        }),
7481        ST_HighlightColor::Cyan => Some(Color {
7482            r: 0.0,
7483            g: 1.0,
7484            b: 1.0,
7485            a: 1.0,
7486        }),
7487        ST_HighlightColor::DarkBlue => Some(Color {
7488            r: 0.0,
7489            g: 0.0,
7490            b: 0.545,
7491            a: 1.0,
7492        }),
7493        ST_HighlightColor::DarkCyan => Some(Color {
7494            r: 0.0,
7495            g: 0.545,
7496            b: 0.545,
7497            a: 1.0,
7498        }),
7499        ST_HighlightColor::DarkGray => Some(Color {
7500            r: 0.663,
7501            g: 0.663,
7502            b: 0.663,
7503            a: 1.0,
7504        }),
7505        ST_HighlightColor::DarkGreen => Some(Color {
7506            r: 0.0,
7507            g: 0.392,
7508            b: 0.0,
7509            a: 1.0,
7510        }),
7511        ST_HighlightColor::DarkMagenta => Some(Color {
7512            r: 0.545,
7513            g: 0.0,
7514            b: 0.545,
7515            a: 1.0,
7516        }),
7517        ST_HighlightColor::DarkRed => Some(Color {
7518            r: 0.545,
7519            g: 0.0,
7520            b: 0.0,
7521            a: 1.0,
7522        }),
7523        ST_HighlightColor::DarkYellow => Some(Color {
7524            r: 0.545,
7525            g: 0.545,
7526            b: 0.0,
7527            a: 1.0,
7528        }),
7529        ST_HighlightColor::Green => Some(Color {
7530            r: 0.0,
7531            g: 1.0,
7532            b: 0.0,
7533            a: 1.0,
7534        }),
7535        ST_HighlightColor::LightGray => Some(Color {
7536            r: 0.827,
7537            g: 0.827,
7538            b: 0.827,
7539            a: 1.0,
7540        }),
7541        ST_HighlightColor::Magenta => Some(Color {
7542            r: 1.0,
7543            g: 0.0,
7544            b: 1.0,
7545            a: 1.0,
7546        }),
7547        ST_HighlightColor::Red => Some(Color {
7548            r: 1.0,
7549            g: 0.0,
7550            b: 0.0,
7551            a: 1.0,
7552        }),
7553        ST_HighlightColor::White => Some(Color {
7554            r: 1.0,
7555            g: 1.0,
7556            b: 1.0,
7557            a: 1.0,
7558        }),
7559        ST_HighlightColor::Yellow => Some(Color {
7560            r: 1.0,
7561            g: 1.0,
7562            b: 0.0,
7563            a: 1.0,
7564        }),
7565    }
7566}
7567
7568#[cfg(test)]
7569mod tests {
7570    use super::*;
7571    use crate::input::ImageData;
7572    use oxml_layout::{MediaId, MultilingualGlyphRun, TextScript};
7573    use std::collections::HashMap;
7574
7575    const LEGACY_RESTART_CACHE_MAX_BYTES: usize = 8 * 1024 * 1024;
7576
7577    fn assert_restart_cache_within_aggregate(engine: &Engine) {
7578        let restart = engine
7579            .restart_cache
7580            .as_ref()
7581            .expect("restart state retained");
7582        let entries = engine
7583            .paragraph_cache
7584            .len()
7585            .checked_add(engine.table_cache.len())
7586            .and_then(|entries| entries.checked_add(engine.header_footer_cache.len()))
7587            .and_then(|entries| entries.checked_add(restart_cache_entries(restart)))
7588            .expect("retained cache entry accounting does not overflow");
7589        let bytes = engine
7590            .paragraph_cache_bytes
7591            .checked_add(engine.table_cache_bytes)
7592            .and_then(|bytes| bytes.checked_add(engine.header_footer_cache_bytes))
7593            .and_then(|bytes| bytes.checked_add(restart.bytes))
7594            .expect("retained cache byte accounting does not overflow");
7595        assert!(entries <= CACHE_MAX_ENTRIES, "{entries}");
7596        assert!(bytes <= CACHE_MAX_BYTES, "{bytes}");
7597    }
7598
7599    fn compatibility_elements(elements: &[PositionedElement]) -> Vec<&PositionedElement> {
7600        fn collect<'a>(
7601            elements: &'a [PositionedElement],
7602            flattened: &mut Vec<&'a PositionedElement>,
7603        ) {
7604            for element in elements {
7605                match element {
7606                    PositionedElement::MarkedContent { children, .. } => {
7607                        collect(children, flattened);
7608                    }
7609                    element => flattened.push(element),
7610                }
7611            }
7612        }
7613
7614        let mut flattened = Vec::new();
7615        collect(elements, &mut flattened);
7616        flattened
7617    }
7618
7619    fn compatibility_page_elements(page: &PageFrame) -> Vec<&PositionedElement> {
7620        compatibility_elements(&page.elements)
7621    }
7622
7623    fn multilingual_runs(layout: &LayoutResult) -> Vec<&MultilingualGlyphRun> {
7624        layout
7625            .pages
7626            .iter()
7627            .flat_map(|page| compatibility_page_elements(page))
7628            .filter_map(|element| match element {
7629                PositionedElement::MultilingualText(run) => Some(run),
7630                _ => None,
7631            })
7632            .collect()
7633    }
7634
7635    #[test]
7636    fn revision_views_project_wrapped_runs_in_document_order() {
7637        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7638            <w:r><w:t>A</w:t></w:r>
7639            <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>
7640            <w:del w:id="3" w:author="Cy"><w:r><w:delText>X</w:delText></w:r></w:del>
7641            <w:moveFrom w:id="4" w:author="Dee"><w:r><w:t>F</w:t></w:r></w:moveFrom>
7642            <w:moveTo w:id="5" w:author="Eve"><w:r><w:t>T</w:t></w:r></w:moveTo>
7643            <w:r><w:t>Z</w:t></w:r>
7644        </w:p></w:body></w:document>"#;
7645        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7646        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7647            panic!("expected paragraph");
7648        };
7649
7650        let accepted = project_paragraph_runs(paragraph, RevisionView::Accepted)
7651            .iter()
7652            .map(|projected| projected.run.text())
7653            .collect::<Vec<_>>();
7654        assert_eq!(accepted, ["A", "I1", "I2", "T", "Z"]);
7655
7656        let tracked = project_paragraph_runs(paragraph, RevisionView::Tracked)
7657            .iter()
7658            .map(|projected| projected.run.text())
7659            .collect::<Vec<_>>();
7660        assert_eq!(tracked, ["A", "I1", "D", "I2", "X", "F", "T", "Z"]);
7661    }
7662
7663    #[test]
7664    fn inline_content_controls_share_the_selected_revision_projection() {
7665        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7666            <w:sdt><w:sdtContent><w:r><w:t>control </w:t></w:r><w:ins w:id="1" w:author="Ada"><w:r><w:t>inserted </w:t></w:r></w:ins><w:del w:id="2" w:author="Ben"><w:r><w:delText>deleted </w:delText></w:r></w:del></w:sdtContent></w:sdt>
7667            <w:r><w:t>tail</w:t></w:r>
7668        </w:p></w:body></w:document>"#;
7669        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("control document parses");
7670        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7671            panic!("expected paragraph");
7672        };
7673
7674        assert_eq!(
7675            projected_paragraph_text(paragraph, RevisionView::Accepted),
7676            "control inserted tail"
7677        );
7678        assert_eq!(
7679            projected_paragraph_text(paragraph, RevisionView::Tracked),
7680            "control inserted deleted tail"
7681        );
7682    }
7683
7684    #[test]
7685    fn accepted_content_revisions_project_nested_content_controls() {
7686        for wrapper in ["ins", "moveTo"] {
7687            let xml = format!(
7688                r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7689                <w:{wrapper} w:id="1" w:author="Ada"><w:r><w:t>inserted </w:t></w:r><w:sdt><w:sdtContent><w:r><w:t>control </w:t></w:r></w:sdtContent></w:sdt></w:{wrapper}>
7690                <w:r><w:t>tail</w:t></w:r>
7691            </w:p></w:body></w:document>"#
7692            );
7693            let document = rdocx_oxml::CT_Document::from_xml(xml.as_bytes())
7694                .expect("revision document parses");
7695            let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7696                panic!("expected paragraph");
7697            };
7698
7699            assert_eq!(
7700                projected_paragraph_text(paragraph, RevisionView::Accepted),
7701                "inserted control tail"
7702            );
7703            assert_eq!(
7704                projected_paragraph_text(paragraph, RevisionView::Tracked),
7705                "inserted control tail"
7706            );
7707        }
7708    }
7709
7710    #[test]
7711    fn page_reference_names_follow_positioned_controls_and_accepted_revisions() {
7712        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:tbl>
7713            <w:sdt><w:sdtContent><w:tr><w:tc><w:p><w:fldSimple w:instr="PAGEREF before"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:tc></w:tr></w:sdtContent></w:sdt>
7714            <w:tr>
7715              <w:sdt><w:sdtContent><w:tc><w:p><w:fldSimple w:instr="PAGEREF rowBefore"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:tc></w:sdtContent></w:sdt>
7716              <w:tc><w:p><w:del w:id="1" w:author="Ada"><w:fldSimple w:instr="PAGEREF deleted"><w:r><w:delText>1</w:delText></w:r></w:fldSimple></w:del><w:fldSimple w:instr="PAGEREF row"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:tc>
7717              <w:sdt><w:sdtContent><w:tc><w:p><w:fldSimple w:instr="PAGEREF rowAfter"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:tc></w:sdtContent></w:sdt>
7718            </w:tr>
7719            <w:sdt><w:sdtContent><w:tr><w:tc><w:p><w:fldSimple w:instr="PAGEREF after"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:tc></w:tr></w:sdtContent></w:sdt>
7720        </w:tbl></w:body></w:document>"#;
7721        let mut input = make_input_with_text("");
7722        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
7723
7724        assert_eq!(
7725            page_reference_names(&input),
7726            ["before", "rowBefore", "row", "rowAfter", "after"]
7727        );
7728    }
7729
7730    #[test]
7731    fn nested_only_revision_wrappers_project_their_visible_runs() {
7732        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7733            <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>
7734        </w:p></w:body></w:document>"#;
7735        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7736        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7737            panic!("expected paragraph");
7738        };
7739        for view in [RevisionView::Accepted, RevisionView::Tracked] {
7740            assert_eq!(projected_paragraph_text(paragraph, view), "nested");
7741        }
7742        assert!(paragraph_has_visible_revision(paragraph));
7743    }
7744
7745    #[test]
7746    fn inline_control_block_children_stay_opaque_to_layout_views() {
7747        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7748          <w:sdt><w:sdtContent>
7749            <w:p><w:ins w:id="1" w:author="Ada"><w:r><w:t>opaque block</w:t></w:r></w:ins></w:p>
7750            <w:tbl><w:tr><w:tc><w:p><w:r><w:t>opaque table</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
7751            <w:r><w:t>visible inline</w:t></w:r>
7752          </w:sdtContent></w:sdt>
7753        </w:p></w:body></w:document>"#;
7754        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("document parses");
7755        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7756            panic!("expected paragraph");
7757        };
7758
7759        for view in [RevisionView::Accepted, RevisionView::Tracked] {
7760            assert_eq!(projected_paragraph_text(paragraph, view), "visible inline");
7761        }
7762        assert!(!paragraph_has_visible_revision(paragraph));
7763    }
7764
7765    #[test]
7766    fn pageref_target_follows_an_earlier_revision_at_the_same_boundary() {
7767        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7768            <w:ins w:id="1" w:author="Ada"><w:r><w:t>before</w:t></w:r></w:ins>
7769            <w:bookmarkStart w:id="7" w:name="target"/>
7770            <w:fldSimple w:instr=" PAGEREF target "><w:r><w:t>1</w:t></w:r></w:fldSimple>
7771            <w:bookmarkEnd w:id="7"/>
7772        </w:p></w:body></w:document>"#;
7773        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7774        let mut input = make_input_with_text("");
7775        input.document = document;
7776        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
7777            panic!("expected paragraph");
7778        };
7779        let media = MediaRegistry::new(&input.images);
7780        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7781        let mut numbering = NumberingState::new();
7782        let mut diagnostics = Vec::new();
7783        let block = layout_paragraph(
7784            paragraph,
7785            468.0,
7786            &input.styles,
7787            &input,
7788            &media,
7789            &mut fonts,
7790            &mut numbering,
7791            &mut diagnostics,
7792        )
7793        .expect("paragraph lays out");
7794        let items = &block.reflow.expect("reflow items retained").items;
7795        let revision_index = items
7796            .iter()
7797            .position(|item| matches!(item, InlineItem::Text(text) if text.text == "before"))
7798            .expect("revision text");
7799        let target_index = items
7800            .iter()
7801            .position(|item| {
7802                matches!(item, InlineItem::Text(text) if matches!(text.field_kind, Some(FieldKind::Target(_))))
7803            })
7804            .expect("PAGEREF target");
7805        assert!(revision_index < target_index);
7806    }
7807
7808    #[test]
7809    fn derived_revision_text_uses_the_selected_projection() {
7810        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7811            <w:bookmarkStart w:id="7" w:name="target"/>
7812            <w:ins w:id="1" w:author="Ada"><w:r><w:t>new</w:t></w:r></w:ins>
7813            <w:del w:id="2" w:author="Ben"><w:r><w:delText>old</w:delText></w:r></w:del>
7814            <w:bookmarkEnd w:id="7"/>
7815        </w:p></w:body></w:document>"#;
7816        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7817        let mut input = make_input_with_text("");
7818        input.document = document;
7819
7820        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("new"));
7821        input.revision_view = RevisionView::Tracked;
7822        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("newold"));
7823    }
7824
7825    #[test]
7826    fn equal_boundary_marker_order_qualifies_layout_bookmark_targets() {
7827        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
7828        <w:p><w:fldSimple w:instr=" PAGEREF bad "><w:r><w:t>1</w:t></w:r></w:fldSimple><w:fldSimple w:instr=" PAGEREF empty "><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p>
7829        <w:p><w:bookmarkEnd w:id="7"/><w:bookmarkStart w:id="7" w:name="bad"/><w:bookmarkStart w:id="8" w:name="empty"/><w:bookmarkEnd w:id="8"/><w:r><w:t>after</w:t></w:r></w:p>
7830        </w:body></w:document>"#;
7831        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("document parses");
7832        let mut input = make_input_with_text("");
7833        input.document = document;
7834
7835        assert_eq!(bookmark_text(&input, "bad"), None);
7836        assert_eq!(bookmark_text(&input, "empty").as_deref(), Some(""));
7837        let BodyContent::Paragraph(paragraph) = &input.document.body.content[1] else {
7838            panic!("expected target paragraph");
7839        };
7840        let media = MediaRegistry::new(&input.images);
7841        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7842        let mut numbering = NumberingState::new();
7843        let mut diagnostics = Vec::new();
7844        let block = layout_paragraph(
7845            paragraph,
7846            468.0,
7847            &input.styles,
7848            &input,
7849            &media,
7850            &mut fonts,
7851            &mut numbering,
7852            &mut diagnostics,
7853        )
7854        .expect("paragraph lays out");
7855        let targets = block
7856            .reflow
7857            .expect("reflow items retained")
7858            .items
7859            .iter()
7860            .filter_map(|item| match item {
7861                InlineItem::Text(text) => match text.field_kind {
7862                    Some(FieldKind::Target(target)) => Some(target),
7863                    _ => None,
7864                },
7865                _ => None,
7866            })
7867            .collect::<Vec<_>>();
7868        assert_eq!(targets, [1]);
7869    }
7870
7871    #[test]
7872    fn unresolved_pageref_target_is_diagnosed_and_not_exposed_as_resolved() {
7873        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
7874        <w:p><w:fldSimple w:instr=" PAGEREF hidden "><w:r><w:t>stored page</w:t></w:r></w:fldSimple></w:p>
7875        <w:tbl><w:tblGrid><w:gridCol w:w="8000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:vMerge w:val="continue"/></w:tcPr><w:p><w:bookmarkStart w:id="7" w:name="hidden"/><w:r><w:t>unlaid target</w:t></w:r><w:bookmarkEnd w:id="7"/></w:p></w:tc></w:tr></w:tbl>
7876        </w:body></w:document>"#;
7877        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("document parses");
7878        let mut input = make_input_with_text("");
7879        input.document = document;
7880
7881        assert_eq!(
7882            bookmark_text(&input, "hidden").as_deref(),
7883            Some("unlaid target")
7884        );
7885        let output = Engine::new_deterministic()
7886            .expect("bundled fonts load")
7887            .layout(&input)
7888            .expect("document lays out");
7889        assert!(output.diagnostics.iter().any(|diagnostic| {
7890            diagnostic.message
7891                == "PAGEREF target hidden did not reach pagination, unresolved placeholder retained"
7892        }));
7893        for page in &output.pages {
7894            oxml_layout::walk(&page.elements, &mut |element, _| {
7895                assert!(
7896                    !matches!(
7897                        element,
7898                        PositionedElement::Text(run)
7899                            if matches!(run.field_kind, Some(FieldKind::TargetPage(_)))
7900                    ),
7901                    "an unresolved TargetPage must not be exposed as resolved"
7902                );
7903            });
7904        }
7905    }
7906
7907    #[test]
7908    fn tracked_bookmark_text_keeps_nested_control_and_revision_boundaries() {
7909        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
7910        <w:p><w:sdt><w:sdtContent>
7911          <w:del w:id="1" w:author="Ada"><w:r><w:delText>old</w:delText></w:r></w:del>
7912          <w:bookmarkStart w:id="7" w:name="controlTarget"/><w:r><w:t>control inside</w:t></w:r><w:bookmarkEnd w:id="7"/><w:r><w:t>control after</w:t></w:r>
7913        </w:sdtContent></w:sdt></w:p>
7914        <w:p><w:ins w:id="2" w:author="Ben"><w:r><w:t>revision before</w:t></w:r><w:bookmarkStart w:id="8" w:name="revisionTarget"/><w:r><w:t>revision inside</w:t></w:r><w:bookmarkEnd w:id="8"/></w:ins></w:p>
7915        </w:body></w:document>"#;
7916        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("document parses");
7917        let mut input = make_input_with_text("");
7918        input.document = document;
7919
7920        assert_eq!(
7921            bookmark_text(&input, "controlTarget").as_deref(),
7922            Some("control inside")
7923        );
7924        assert_eq!(
7925            bookmark_text(&input, "revisionTarget").as_deref(),
7926            Some("revision inside")
7927        );
7928        input.revision_view = RevisionView::Tracked;
7929        assert_eq!(
7930            bookmark_text(&input, "controlTarget").as_deref(),
7931            Some("control inside")
7932        );
7933        assert_eq!(
7934            bookmark_text(&input, "revisionTarget").as_deref(),
7935            Some("revision inside")
7936        );
7937    }
7938
7939    #[test]
7940    fn collapsed_complex_field_keeps_ref_and_pageref_target_boundaries() {
7941        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
7942        <w:p><w:fldSimple w:instr="REF afterField"><w:r><w:t>stale ref</w:t></w:r></w:fldSimple><w:fldSimple w:instr="PAGEREF afterField"><w:r><w:t>stale page</w:t></w:r></w:fldSimple></w:p>
7943        <w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>AUTHOR</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>cached author</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:bookmarkStart w:id="9" w:name="afterField"/><w:r><w:t>target text</w:t></w:r><w:bookmarkEnd w:id="9"/></w:p>
7944        </w:body></w:document>"#;
7945        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("document parses");
7946        let mut input = make_input_with_text("");
7947        input.document = document;
7948
7949        assert_eq!(
7950            bookmark_text(&input, "afterField").as_deref(),
7951            Some("target text")
7952        );
7953        let output = Engine::new_deterministic()
7954            .expect("bundled fonts load")
7955            .layout(&input)
7956            .expect("REF and PAGEREF target layout");
7957        assert!(output.diagnostics.iter().all(|diagnostic| {
7958            !diagnostic.message.contains("afterField")
7959                && !diagnostic.message.contains("stored display retained")
7960        }));
7961    }
7962
7963    #[test]
7964    fn bookmark_after_a_terminal_hyperlink_revision_excludes_that_revision() {
7965        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>
7966            <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>
7967            <w:bookmarkStart w:id="7" w:name="target"/><w:r><w:t>inside</w:t></w:r><w:bookmarkEnd w:id="7"/>
7968        </w:p></w:body></w:document>"#;
7969        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7970        let mut input = make_input_with_text("");
7971        input.document = document;
7972
7973        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("inside"));
7974    }
7975
7976    #[test]
7977    fn revision_only_hyperlink_keeps_its_link_annotation() {
7978        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>
7979            <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>
7980        </w:p></w:body></w:document>"#;
7981        let mut document =
7982            rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7983        let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
7984            panic!("expected paragraph");
7985        };
7986        paragraph.hyperlinks[0].rel_id = Some("rId2".to_owned());
7987        let serialized = String::from_utf8(document.to_xml().expect("document serializes"))
7988            .expect("document XML is UTF-8");
7989        assert!(serialized.contains("r:id=\"rId2\""), "{serialized}");
7990        assert!(!serialized.contains("r:id=\"rId1\""), "{serialized}");
7991        let mut input = make_input_with_text("");
7992        input.document = document;
7993        input
7994            .hyperlink_urls
7995            .insert("rId2".to_owned(), "https://example.com".to_owned());
7996
7997        let output = Engine::new_deterministic()
7998            .expect("bundled fonts load")
7999            .layout(&input)
8000            .expect("revision hyperlink lays out");
8001        assert!(
8002            compatibility_page_elements(&output.pages[0])
8003                .into_iter()
8004                .any(|element| {
8005                    matches!(element, PositionedElement::LinkAnnotation { url, .. }
8006                if url == "https://example.com")
8007                })
8008        );
8009    }
8010
8011    #[test]
8012    fn derived_revision_text_keeps_order_after_comment_run_removal() {
8013        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
8014            <w:bookmarkStart w:id="7" w:name="target"/>
8015            <w:commentRangeStart w:id="5"/><w:r><w:commentReference w:id="5"/></w:r>
8016            <w:ins w:id="1" w:author="Ada"><w:r><w:t>inside</w:t></w:r></w:ins>
8017            <w:bookmarkEnd w:id="7"/>
8018        </w:p></w:body></w:document>"#;
8019        let mut document =
8020            rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
8021        let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
8022            panic!("expected paragraph");
8023        };
8024        paragraph.remove_comment_anchors(&[5]);
8025        let mut input = make_input_with_text("");
8026        input.document = document;
8027
8028        assert_eq!(bookmark_text(&input, "target").as_deref(), Some("inside"));
8029    }
8030
8031    #[test]
8032    fn heading_text_uses_the_selected_revision_projection() {
8033        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
8034            <w:ins w:id="1" w:author="Ada"><w:r><w:t>new</w:t></w:r></w:ins>
8035            <w:del w:id="2" w:author="Ben"><w:r><w:delText>old</w:delText></w:r></w:del>
8036        </w:p></w:body></w:document>"#;
8037        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
8038        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
8039            panic!("expected paragraph");
8040        };
8041        assert_eq!(
8042            projected_paragraph_text(paragraph, RevisionView::Accepted),
8043            "new"
8044        );
8045        assert_eq!(
8046            projected_paragraph_text(paragraph, RevisionView::Tracked),
8047            "newold"
8048        );
8049    }
8050
8051    #[test]
8052    fn revised_floating_anchors_follow_the_selected_projection() {
8053        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
8054            xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
8055            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
8056            xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><w:body><w:p>
8057            <w:ins w:id="1" w:author="Ada"><w:r><w:drawing><wp:anchor behindDoc="0">
8058              <wp:positionH relativeFrom="margin"><wp:align>right</wp:align></wp:positionH>
8059              <wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>
8060              <wp:extent cx="914400" cy="457200"/><wp:wrapSquare wrapText="bothSides"/>
8061              <a:graphic><a:graphicData><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic>
8062            </wp:anchor></w:drawing></w:r></w:ins>
8063        </w:p></w:body></w:document>"#;
8064        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
8065        let mut input = make_input_with_text("");
8066        input.document = document;
8067        assert!(document_has_wrapping_drawing(&input));
8068
8069        input.revision_view = RevisionView::Tracked;
8070        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8071            panic!("expected paragraph");
8072        };
8073        let media = MediaRegistry::new(&input.images);
8074        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8075        let mut numbering = NumberingState::new();
8076        let mut diagnostics = Vec::new();
8077        let anchored = collect_anchored_drawings(
8078            paragraph,
8079            &input.styles,
8080            &input,
8081            &media,
8082            &mut fonts,
8083            &mut numbering,
8084            &mut diagnostics,
8085        )
8086        .expect("tracked anchor collection succeeds");
8087        assert_eq!(anchored.len(), 1);
8088    }
8089
8090    #[test]
8091    fn tracked_revision_decorations_override_only_underline_and_strike() {
8092        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
8093            <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>
8094            <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>
8095            <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>
8096            <w:del w:id="4" w:author="Ben"><w:r><w:endnoteReference w:id="12"/></w:r></w:del>
8097        </w:p></w:body></w:document>"#;
8098        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
8099        let mut input = make_input_with_text("");
8100        input.document = document;
8101        input.revision_view = RevisionView::Tracked;
8102        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8103            panic!("expected paragraph");
8104        };
8105        let media = MediaRegistry::new(&input.images);
8106        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8107        let mut numbering = NumberingState::new();
8108        let mut diagnostics = Vec::new();
8109        let block = layout_paragraph(
8110            paragraph,
8111            468.0,
8112            &input.styles,
8113            &input,
8114            &media,
8115            &mut fonts,
8116            &mut numbering,
8117            &mut diagnostics,
8118        )
8119        .expect("tracked paragraph lays out");
8120        let segments = block
8121            .lines
8122            .iter()
8123            .flat_map(|line| &line.items)
8124            .filter_map(|item| match item {
8125                oxml_layout::LineItem::Text(segment) => Some(segment),
8126                _ => None,
8127            })
8128            .collect::<Vec<_>>();
8129        let inserted = segments
8130            .iter()
8131            .find(|segment| segment.text == "inserted")
8132            .expect("inserted segment");
8133        assert_eq!(inserted.underline, Some(Underline::Single));
8134        assert!(!inserted.strike);
8135        assert!(inserted.dstrike);
8136        assert!(inserted.bold && inserted.italic);
8137        assert_eq!(inserted.color, Color::from_hex("AA0000"));
8138        assert_eq!(inserted.highlight, Some(Color::from_hex("FFFF00")));
8139
8140        let deleted = segments
8141            .iter()
8142            .find(|segment| segment.text == "deleted")
8143            .expect("deleted segment");
8144        assert_eq!(deleted.underline, Some(Underline::Double));
8145        assert!(deleted.strike);
8146        assert_eq!(deleted.color, Color::from_hex("0000AA"));
8147
8148        let inserted_note = segments
8149            .iter()
8150            .find(|segment| segment.text == "11")
8151            .expect("inserted note marker");
8152        assert_eq!(inserted_note.underline, Some(Underline::Single));
8153        assert_eq!(inserted_note.highlight, Some(Color::from_hex("FFFF00")));
8154        let deleted_note = segments
8155            .iter()
8156            .find(|segment| segment.text == "12")
8157            .expect("deleted note marker");
8158        assert!(deleted_note.strike);
8159
8160        let mut accepted_input = input.clone();
8161        accepted_input.revision_view = RevisionView::Accepted;
8162        let BodyContent::Paragraph(accepted_paragraph) = &accepted_input.document.body.content[0]
8163        else {
8164            panic!("expected paragraph");
8165        };
8166        let accepted_media = MediaRegistry::new(&accepted_input.images);
8167        let accepted_block = layout_paragraph(
8168            accepted_paragraph,
8169            468.0,
8170            &accepted_input.styles,
8171            &accepted_input,
8172            &accepted_media,
8173            &mut fonts,
8174            &mut numbering,
8175            &mut diagnostics,
8176        )
8177        .expect("accepted paragraph lays out");
8178        let accepted_note = accepted_block
8179            .lines
8180            .iter()
8181            .flat_map(|line| &line.items)
8182            .filter_map(|item| match item {
8183                oxml_layout::LineItem::Text(segment) if segment.text == "11" => Some(segment),
8184                _ => None,
8185            })
8186            .next()
8187            .expect("accepted note marker");
8188        assert_eq!(accepted_note.underline, None);
8189        assert!(!accepted_note.strike && !accepted_note.dstrike);
8190        assert_eq!(accepted_note.highlight, None);
8191    }
8192
8193    #[test]
8194    fn a_split_changed_paragraph_draws_one_margin_bar_on_each_page() {
8195        let changed = "changed ".repeat(3_000);
8196        let xml = format!(
8197            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>"#
8198        );
8199        let document =
8200            rdocx_oxml::CT_Document::from_xml(xml.as_bytes()).expect("revision document parses");
8201        let mut input = make_input_with_text("");
8202        input.document = document;
8203        let accepted = Engine::new_deterministic()
8204            .expect("bundled fonts load")
8205            .layout(&input)
8206            .expect("accepted document lays out");
8207        input.revision_view = RevisionView::Tracked;
8208        let output = Engine::new_deterministic()
8209            .expect("bundled fonts load")
8210            .layout(&input)
8211            .expect("tracked document lays out");
8212        let geometry = PageGeometry::default();
8213        assert!(output.pages.len() > 1);
8214        assert_eq!(accepted.pages.len(), output.pages.len());
8215        for (accepted_page, page) in accepted.pages.iter().zip(&output.pages) {
8216            let accepted_text = compatibility_page_elements(accepted_page)
8217                .into_iter()
8218                .filter_map(|element| match element {
8219                    PositionedElement::Text(text) => Some(text),
8220                    _ => None,
8221                })
8222                .collect::<Vec<_>>();
8223            let tracked_text = compatibility_page_elements(page)
8224                .into_iter()
8225                .filter_map(|element| match element {
8226                    PositionedElement::Text(text) => Some(text),
8227                    _ => None,
8228                })
8229                .collect::<Vec<_>>();
8230            assert_eq!(accepted_text, tracked_text);
8231            let bars = compatibility_page_elements(page)
8232                .into_iter()
8233                .filter_map(|element| match element {
8234                    PositionedElement::Line {
8235                        start,
8236                        end,
8237                        width,
8238                        dash_pattern,
8239                        ..
8240                    } if (*width - 1.5).abs() < f64::EPSILON
8241                        && dash_pattern.is_none()
8242                        && start.x == end.x
8243                        && (start.x < geometry.margin_left
8244                            || start.x > geometry.page_width - geometry.margin_right) =>
8245                    {
8246                        Some((*start, *end))
8247                    }
8248                    _ => None,
8249                })
8250                .collect::<Vec<_>>();
8251            assert_eq!(bars.len(), 1, "page {}", page.page_number);
8252            let (start, end) = bars[0];
8253            assert!(start.x.is_finite() && start.y.is_finite() && end.y.is_finite());
8254            assert!(end.y > start.y);
8255            if page.page_number.is_multiple_of(2) {
8256                assert!(start.x < geometry.margin_left);
8257            } else {
8258                assert!(start.x > geometry.page_width - geometry.margin_right);
8259            }
8260        }
8261    }
8262
8263    fn page_change_bar_count(page: &PageFrame) -> usize {
8264        let geometry = PageGeometry::default();
8265        compatibility_page_elements(page)
8266            .into_iter()
8267            .filter(|element| {
8268                matches!(element, PositionedElement::Line { start, end, width, .. }
8269                    if (*width - 1.5).abs() < f64::EPSILON
8270                        && start.x == end.x
8271                        && (start.x < geometry.margin_left
8272                            || start.x > geometry.page_width - geometry.margin_right))
8273            })
8274            .count()
8275    }
8276
8277    #[test]
8278    fn tracked_nested_control_revisions_draw_change_bars_in_both_orders() {
8279        let cases = [
8280            (
8281                "controlled insertion",
8282                r#"<w:sdt><w:sdtContent><w:ins w:id="1" w:author="Ada"><w:r><w:t>controlled insertion</w:t></w:r></w:ins></w:sdtContent></w:sdt>"#,
8283            ),
8284            (
8285                "inserted control",
8286                r#"<w:ins w:id="2" w:author="Ben"><w:sdt><w:sdtContent><w:r><w:t>inserted control</w:t></w:r></w:sdtContent></w:sdt></w:ins>"#,
8287            ),
8288        ];
8289        for (expected, content) in cases {
8290            let xml = format!(
8291                r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>{content}</w:p></w:body></w:document>"#
8292            );
8293            let document = rdocx_oxml::CT_Document::from_xml(xml.as_bytes())
8294                .expect("nested revision document parses");
8295            let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
8296                panic!("expected paragraph");
8297            };
8298            assert_eq!(
8299                projected_paragraph_text(paragraph, RevisionView::Tracked),
8300                expected
8301            );
8302
8303            let mut input = make_input_with_text("");
8304            input.document = document;
8305            input.revision_view = RevisionView::Tracked;
8306            let output = Engine::new_deterministic()
8307                .expect("bundled fonts load")
8308                .layout(&input)
8309                .expect("nested revision lays out");
8310            let rendered_text = compatibility_page_elements(&output.pages[0])
8311                .into_iter()
8312                .filter_map(|element| match element {
8313                    PositionedElement::Text(run) => Some(run.text.as_str()),
8314                    PositionedElement::MultilingualText(run) => Some(run.logical_text.as_str()),
8315                    _ => None,
8316                })
8317                .collect::<String>();
8318            assert_eq!(rendered_text, expected);
8319            assert_eq!(page_change_bar_count(&output.pages[0]), 1);
8320        }
8321    }
8322
8323    #[test]
8324    fn tracked_control_run_property_revision_draws_a_change_bar() {
8325        let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:sdt><w:sdtContent><w:r><w:rPr><w:rPrChange w:id="3" w:author="Ada"><w:rPr><w:b/></w:rPr></w:rPrChange></w:rPr><w:t>controlled property</w:t></w:r></w:sdtContent></w:sdt></w:p></w:body></w:document>"#;
8326        let document = rdocx_oxml::CT_Document::from_xml(xml)
8327            .expect("controlled property revision document parses");
8328        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
8329            panic!("expected paragraph");
8330        };
8331        assert_eq!(
8332            projected_paragraph_text(paragraph, RevisionView::Tracked),
8333            "controlled property"
8334        );
8335
8336        let mut input = make_input_with_text("");
8337        input.document = document;
8338        input.revision_view = RevisionView::Tracked;
8339        let output = Engine::new_deterministic()
8340            .expect("bundled fonts load")
8341            .layout(&input)
8342            .expect("controlled property revision lays out");
8343        let rendered_text = compatibility_page_elements(&output.pages[0])
8344            .into_iter()
8345            .filter_map(|element| match element {
8346                PositionedElement::Text(run) => Some(run.text.as_str()),
8347                PositionedElement::MultilingualText(run) => Some(run.logical_text.as_str()),
8348                _ => None,
8349            })
8350            .collect::<String>();
8351        assert_eq!(rendered_text, "controlled property");
8352        assert_eq!(page_change_bar_count(&output.pages[0]), 1);
8353    }
8354
8355    #[test]
8356    fn tracked_header_paragraph_draws_a_change_bar() {
8357        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
8358
8359        let mut input = make_input_with_text("body");
8360        input.revision_view = RevisionView::Tracked;
8361        input.document.body.sect_pr = Some(CT_SectPr::default_letter());
8362        input
8363            .document
8364            .body
8365            .sect_pr
8366            .as_mut()
8367            .expect("section properties")
8368            .header_refs
8369            .push(HdrFtrRef {
8370                hdr_ftr_type: HdrFtrType::Default,
8371                rel_id: "rIdHeader".to_owned(),
8372            });
8373        let header = CT_HdrFtr::from_xml(
8374            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>"#,
8375        )
8376        .expect("header parses");
8377        input.headers.insert("rIdHeader".to_owned(), header);
8378
8379        let output = Engine::new_deterministic()
8380            .expect("bundled fonts load")
8381            .layout(&input)
8382            .expect("tracked header lays out");
8383        assert_eq!(page_change_bar_count(&output.pages[0]), 1);
8384    }
8385
8386    #[test]
8387    fn tracked_note_paragraph_draws_a_change_bar() {
8388        let mut input = make_input_with_footnote(&["plain"]);
8389        input.revision_view = RevisionView::Tracked;
8390        let changed_note = rdocx_oxml::CT_Document::from_xml(
8391            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>"#,
8392        )
8393        .expect("note paragraph parses");
8394        let BodyContent::Paragraph(paragraph) = &changed_note.body.content[0] else {
8395            panic!("expected paragraph");
8396        };
8397        input.footnotes.as_mut().expect("footnote stream").footnotes[0].paragraphs =
8398            vec![paragraph.clone()];
8399
8400        let output = Engine::new_deterministic()
8401            .expect("bundled fonts load")
8402            .layout(&input)
8403            .expect("tracked note lays out");
8404        assert_eq!(page_change_bar_count(&output.pages[0]), 1);
8405        let decoration_widths = compatibility_page_elements(&output.pages[0])
8406            .into_iter()
8407            .filter_map(|element| match element {
8408                PositionedElement::Line {
8409                    start, end, width, ..
8410                } if start.y == end.y && (*width - 0.5).abs() > f64::EPSILON => Some(*width),
8411                _ => None,
8412            })
8413            .collect::<Vec<_>>();
8414        assert!(
8415            decoration_widths
8416                .iter()
8417                .any(|width| (*width - 11.0 / 18.0).abs() < 0.001),
8418            "tracked insertion underline missing: {decoration_widths:?}"
8419        );
8420        assert!(
8421            decoration_widths
8422                .iter()
8423                .any(|width| (*width - 11.0 / 24.0).abs() < 0.001),
8424            "tracked deletion strike missing: {decoration_widths:?}"
8425        );
8426    }
8427
8428    #[test]
8429    fn property_only_revisions_mark_the_tracked_paragraph() {
8430        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>"#;
8431        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
8432        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
8433            panic!("expected paragraph");
8434        };
8435        assert!(paragraph_has_visible_revision(paragraph));
8436    }
8437
8438    #[test]
8439    fn empty_revision_wrappers_do_not_mark_the_tracked_paragraph() {
8440        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>"#;
8441        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
8442        for content in &document.body.content {
8443            let BodyContent::Paragraph(paragraph) = content else {
8444                panic!("expected paragraph");
8445            };
8446            assert!(!paragraph_has_visible_revision(paragraph));
8447        }
8448    }
8449
8450    fn make_input_with_text(text: &str) -> LayoutInput {
8451        let mut doc = rdocx_oxml::document::CT_Document::new();
8452        let mut p = CT_P::new();
8453        p.add_run(text);
8454        doc.body.add_paragraph(p);
8455
8456        LayoutInput {
8457            revision_view: crate::input::RevisionView::Accepted,
8458            automatic_hyphenation: false,
8459            math_properties: None,
8460            document: doc,
8461            styles: CT_Styles::new_default(),
8462            numbering: None,
8463            headers: HashMap::new(),
8464            footers: HashMap::new(),
8465            images: HashMap::new(),
8466            charts: HashMap::new(),
8467            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
8468            chart_color_map: oxml_drawing::color::ColorMap::default(),
8469            core_properties: None,
8470            hyperlink_urls: HashMap::new(),
8471            footnotes: None,
8472            endnotes: None,
8473            theme: None,
8474            fonts: Vec::new(),
8475        }
8476    }
8477
8478    fn five_large_caller_fonts() -> Vec<oxml_layout::FontFile> {
8479        const TOTAL_BYTES: usize = 22 * 1024 * 1024;
8480        let bundled = oxml_layout::bundled_fonts::bundled_font_data();
8481        [0, 4, 8, 12, 16]
8482            .into_iter()
8483            .enumerate()
8484            .map(|(index, bundled_index)| {
8485                let (family, source) = bundled[bundled_index];
8486                let mut data = source.to_vec();
8487                let target = TOTAL_BYTES / 5 + usize::from(index < TOTAL_BYTES % 5);
8488                data.resize(
8489                    target,
8490                    u8::try_from(index).expect("five font indices fit in u8"),
8491                );
8492                oxml_layout::FontFile {
8493                    family: family.to_owned(),
8494                    data,
8495                }
8496            })
8497            .collect()
8498    }
8499
8500    #[test]
8501    fn warm_layout_does_not_repeat_retained_context_font_byte_equality() {
8502        let mut input = make_input_with_text("warm caller-font comparison");
8503        input.fonts = five_large_caller_fonts();
8504        let aliases = (0..40)
8505            .map(|index| {
8506                (
8507                    format!("Editor Family {index}"),
8508                    input.fonts[index % input.fonts.len()].family.clone(),
8509                )
8510            })
8511            .collect::<Vec<_>>();
8512        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8513        engine.set_caller_font_aliases(&aliases);
8514        engine.layout(&input).expect("prime caller-font layout");
8515
8516        reset_retained_context_font_bytes_compared();
8517        engine.layout(&input).expect("warm caller-font layout");
8518
8519        assert_eq!(retained_context_font_bytes_compared(), 0);
8520    }
8521
8522    #[test]
8523    fn same_length_changed_font_bytes_still_invalidate_reusable_work() {
8524        let mut input = make_input_with_text("changed caller-font bytes");
8525        input.fonts = five_large_caller_fonts();
8526        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8527        engine.layout(&input).expect("prime caller-font layout");
8528        assert_eq!(engine.paragraph_cache_counts(), (0, 1));
8529
8530        let last = input.fonts[0]
8531            .data
8532            .last_mut()
8533            .expect("generated font has padding");
8534        *last ^= 1;
8535        let warm = engine.layout(&input).expect("changed-font layout");
8536        let fresh = Engine::new_deterministic()
8537            .expect("bundled fonts load")
8538            .layout(&input)
8539            .expect("fresh changed-font layout");
8540
8541        assert_eq!(engine.paragraph_cache_counts(), (0, 2));
8542        assert_layout_results_equal(&warm, &fresh);
8543    }
8544
8545    #[test]
8546    fn checked_transfer_keeps_exact_ordered_caller_font_bytes() {
8547        let mut input = make_input_with_text("checked caller-font transfer");
8548        input.fonts = five_large_caller_fonts();
8549        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8550        engine.layout(&input).expect("prime caller-font layout");
8551        let mut source = Some(engine);
8552
8553        let last = input.fonts[0]
8554            .data
8555            .last_mut()
8556            .expect("generated font has padding");
8557        *last ^= 1;
8558
8559        reset_retained_context_font_bytes_compared();
8560        assert!(Engine::take_if_compatible(&mut source, &input).is_none());
8561        assert!(source.is_some(), "rejected transfer preserves its source");
8562        assert_eq!(retained_context_font_bytes_compared(), 22 * 1024 * 1024);
8563    }
8564
8565    fn hyphenation_input(enabled: bool, language: Option<&str>, suppressed: bool) -> LayoutInput {
8566        let mut input = make_input_with_text("representation");
8567        input.automatic_hyphenation = enabled;
8568        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8569            panic!("expected paragraph")
8570        };
8571        paragraph.properties = Some(CT_PPr {
8572            ind_right: Some(rdocx_oxml::units::Twips(8_500)),
8573            suppress_auto_hyphens: suppressed.then_some(true),
8574            ..Default::default()
8575        });
8576        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
8577            language: language.map(str::to_owned),
8578            ..Default::default()
8579        });
8580        input
8581    }
8582
8583    #[test]
8584    fn document_enablement_language_and_paragraph_suppression_gate_hyphenation() {
8585        let enabled = output_text(&deterministic_layout(&hyphenation_input(
8586            true,
8587            Some("en-US"),
8588            false,
8589        )))
8590        .concat();
8591        assert_eq!(enabled, "repre-sentation");
8592
8593        for input in [
8594            hyphenation_input(false, Some("en-US"), false),
8595            hyphenation_input(true, None, false),
8596            hyphenation_input(true, Some("it-IT"), false),
8597            hyphenation_input(true, Some("en-US"), true),
8598        ] {
8599            let text = output_text(&deterministic_layout(&input)).concat();
8600            assert_eq!(text, "representation");
8601        }
8602    }
8603
8604    #[test]
8605    fn rtl_first_rich_paragraph_keeps_hyphenatable_english_in_visual_order() {
8606        let mut input = make_input_with_text("");
8607        input.automatic_hyphenation = true;
8608        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8609            panic!("expected paragraph")
8610        };
8611        let mut arabic = CT_R::new("العربية");
8612        arabic.properties = Some(rdocx_oxml::properties::CT_RPr {
8613            language: Some("ar-SA".to_owned()),
8614            language_bidi: Some("ar-SA".to_owned()),
8615            ..Default::default()
8616        });
8617        let mut english = CT_R::new(" representation");
8618        english.properties = Some(rdocx_oxml::properties::CT_RPr {
8619            language: Some("en-US".to_owned()),
8620            ..Default::default()
8621        });
8622        paragraph.runs = vec![arabic, english];
8623
8624        let output = crate::layout_document_deterministic_with_provenance(&input)
8625            .expect("hybrid bidi layout with sources")
8626            .layout;
8627        let arabic_x = multilingual_runs(&output)
8628            .into_iter()
8629            .find(|run| run.logical_text == "العربية")
8630            .expect("Arabic run uses rich shaping")
8631            .origin
8632            .x;
8633        let english_x = output
8634            .pages
8635            .iter()
8636            .flat_map(|page| compatibility_page_elements(page))
8637            .find_map(|element| match element {
8638                PositionedElement::Text(run) if run.text.contains("representation") => {
8639                    Some(run.origin.x)
8640                }
8641                _ => None,
8642            })
8643            .expect("hyphenatable English run stays in the line");
8644        assert!(
8645            english_x < arabic_x,
8646            "RTL paragraph paints English left of Arabic: English {english_x}, Arabic {arabic_x}"
8647        );
8648        let extraction_order = output
8649            .pages
8650            .iter()
8651            .flat_map(|page| compatibility_page_elements(page))
8652            .filter_map(|element| match element {
8653                PositionedElement::Text(run) if run.text.contains("representation") => {
8654                    Some(run.text.as_str())
8655                }
8656                PositionedElement::MultilingualText(run)
8657                    if run.logical_text.contains("العربية") =>
8658                {
8659                    Some(run.logical_text.as_str())
8660                }
8661                _ => None,
8662            })
8663            .collect::<Vec<_>>();
8664        assert_eq!(extraction_order, ["العربية", "representation"]);
8665    }
8666
8667    #[test]
8668    fn explicit_rtl_hyphenatable_latin_spans_keep_resolved_even_level_order() {
8669        let mut input = make_input_with_text("ABC 123");
8670        input.automatic_hyphenation = true;
8671        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8672            panic!("expected paragraph")
8673        };
8674        paragraph.properties = Some(CT_PPr {
8675            bidi: Some(false),
8676            ..Default::default()
8677        });
8678        paragraph.runs[0].properties = Some(CT_RPr {
8679            rtl: Some(true),
8680            language: Some("en-US".to_owned()),
8681            ..Default::default()
8682        });
8683
8684        let output = deterministic_layout(&input);
8685        let positions = output
8686            .pages
8687            .iter()
8688            .flat_map(|page| compatibility_page_elements(page))
8689            .filter_map(|element| match element {
8690                PositionedElement::Text(run) if run.text.contains("ABC") => {
8691                    Some(("ABC", run.origin.x))
8692                }
8693                PositionedElement::Text(run) if run.text.contains("123") => {
8694                    Some(("123", run.origin.x))
8695                }
8696                _ => None,
8697            })
8698            .collect::<Vec<_>>();
8699        assert_eq!(positions.len(), 2, "{positions:?}");
8700        let abc_x = positions
8701            .iter()
8702            .find_map(|(text, x)| (*text == "ABC").then_some(*x))
8703            .unwrap();
8704        let digits_x = positions
8705            .iter()
8706            .find_map(|(text, x)| (*text == "123").then_some(*x))
8707            .unwrap();
8708        assert!(
8709            abc_x < digits_x,
8710            "resolved even-level spans stay LTR: {positions:?}"
8711        );
8712    }
8713
8714    #[test]
8715    fn right_to_left_paragraph_resolves_start_alignment_and_indents_from_the_right() {
8716        let mut input = make_input_with_text("123 العربية");
8717        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8718            panic!("expected paragraph")
8719        };
8720        paragraph.properties = Some(CT_PPr {
8721            bidi: Some(true),
8722            jc: Some(rdocx_oxml::shared::ST_Jc::Start),
8723            ind_start: Some(rdocx_oxml::units::Twips(720)),
8724            ind_end: Some(rdocx_oxml::units::Twips(360)),
8725            num_id: Some(1),
8726            num_ilvl: Some(0),
8727            ..Default::default()
8728        });
8729        let mut level = rdocx_oxml::numbering::CT_Lvl::new(0);
8730        level.num_fmt = Some(rdocx_oxml::numbering::ST_NumberFormat::Bullet);
8731        level.suffix = Some(rdocx_oxml::numbering::ST_LvlSuffix::Nothing);
8732        level.lvl_text = Some("•".to_owned());
8733        let mut abstract_num = rdocx_oxml::numbering::CT_AbstractNum::new(1);
8734        abstract_num.levels.push(level);
8735        input.numbering = Some(rdocx_oxml::numbering::CT_Numbering {
8736            abstract_nums: vec![abstract_num],
8737            nums: vec![rdocx_oxml::numbering::CT_Num {
8738                num_id: 1,
8739                abstract_num_id: 1,
8740                extra_xml: Vec::new(),
8741                extra_attributes: Vec::new(),
8742            }],
8743            root_attributes: Vec::new(),
8744            extra_xml: Vec::new(),
8745        });
8746        let paragraph = paragraph.clone();
8747
8748        let media = MediaRegistry::new(&input.images);
8749        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8750        let mut numbering = NumberingState::new();
8751        let mut diagnostics = Vec::new();
8752        let block = layout_paragraph(
8753            &paragraph,
8754            468.0,
8755            &input.styles,
8756            &input,
8757            &media,
8758            &mut fonts,
8759            &mut numbering,
8760            &mut diagnostics,
8761        )
8762        .expect("RTL paragraph lays out");
8763
8764        assert_eq!(block.indent_left, 18.0);
8765        assert_eq!(block.indent_right, 36.0);
8766        assert_eq!(block.jc, Some(oxml_layout::Align::End));
8767        assert!(matches!(
8768            block.lines[0].items.last(),
8769            Some(LineItem::Marker(marker)) if marker.text == "•"
8770        ));
8771        assert_eq!(
8772            block.lines[0]
8773                .items
8774                .iter()
8775                .filter_map(|item| match item {
8776                    LineItem::Text(text) => Some(text.text.as_str()),
8777                    LineItem::MultilingualText(text) => Some(text.text()),
8778                    LineItem::Marker(marker) => Some(marker.text.as_str()),
8779                    _ => None,
8780                })
8781                .collect::<String>(),
8782            "العربية 123•"
8783        );
8784    }
8785
8786    #[test]
8787    fn run_level_direction_override_shapes_the_exact_source_span() {
8788        let mut input = make_input_with_text("");
8789        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8790            panic!("expected paragraph")
8791        };
8792        paragraph.properties = Some(CT_PPr {
8793            bidi: Some(false),
8794            ..Default::default()
8795        });
8796        let mut leading = CT_R::new("left ");
8797        leading.properties = Some(CT_RPr {
8798            language: Some("en-US".to_owned()),
8799            ..Default::default()
8800        });
8801        let mut overridden = CT_R::new("123");
8802        overridden.properties = Some(CT_RPr {
8803            rtl: Some(true),
8804            language_bidi: Some("ar-SA".to_owned()),
8805            ..Default::default()
8806        });
8807        let mut trailing = CT_R::new(" right");
8808        trailing.properties = Some(CT_RPr {
8809            language: Some("en-US".to_owned()),
8810            ..Default::default()
8811        });
8812        paragraph.runs = vec![leading, overridden, trailing];
8813
8814        let output = crate::layout_document_deterministic_with_provenance(&input)
8815            .expect("directional layout with sources");
8816        let overridden = multilingual_runs(&output.layout)
8817            .into_iter()
8818            .find(|run| run.logical_text == "123")
8819            .expect("run override enters rich shaping");
8820        assert_eq!(overridden.direction, TextDirection::LeftToRight);
8821        assert_eq!(overridden.bidi_level, 2);
8822        assert_eq!(overridden.source.expect("source span").char_start, 5);
8823        assert_eq!(overridden.source.expect("source span").char_end, 8);
8824    }
8825
8826    #[test]
8827    fn computed_field_retains_its_stored_run_direction() {
8828        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>"#;
8829        let mut input = make_input_with_text("");
8830        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
8831        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8832            panic!("expected paragraph")
8833        };
8834        let media = MediaRegistry::new(&input.images);
8835        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8836        let mut numbering = NumberingState::new();
8837        let mut diagnostics = Vec::new();
8838        let block = layout_paragraph(
8839            paragraph,
8840            468.0,
8841            &input.styles,
8842            &input,
8843            &media,
8844            &mut fonts,
8845            &mut numbering,
8846            &mut diagnostics,
8847        )
8848        .expect("directional field lays out");
8849        let field = block
8850            .lines
8851            .iter()
8852            .flat_map(|line| &line.items)
8853            .find_map(|item| match item {
8854                LineItem::Text(segment) if segment.field_kind == Some(FieldKind::Page) => {
8855                    Some(segment)
8856                }
8857                _ => None,
8858            })
8859            .expect("computed field remains substitutable");
8860        assert_eq!(field.text, "99");
8861        assert_eq!(field.direction, TextDirection::RightToLeft);
8862    }
8863
8864    #[test]
8865    fn field_only_directional_paragraph_keeps_bidi_through_drawing_reflow() {
8866        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
8867        use rdocx_oxml::text::Field;
8868
8869        let mut input =
8870            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
8871        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8872            panic!("expected paragraph")
8873        };
8874        paragraph.properties = Some(CT_PPr {
8875            bidi: Some(true),
8876            ..Default::default()
8877        });
8878        let drawing = paragraph.runs.pop().expect("wrapping drawing run");
8879        let mut page = CT_R::new("");
8880        page.properties = Some(CT_RPr {
8881            rtl: Some(true),
8882            ..Default::default()
8883        });
8884        page.content = vec![RunContent::Field(Field::new("PAGE", "אבג"))];
8885        let mut pages = CT_R::new("");
8886        pages.properties = Some(CT_RPr {
8887            rtl: Some(false),
8888            ..Default::default()
8889        });
8890        pages.content = vec![RunContent::Field(Field::new("NUMPAGES", "ABC"))];
8891        paragraph.runs = vec![page, pages, drawing];
8892
8893        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8894            panic!("expected paragraph")
8895        };
8896        let media = MediaRegistry::new(&input.images);
8897        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8898        let mut numbering = NumberingState::new();
8899        let mut diagnostics = Vec::new();
8900        let (block, direction) = layout_paragraph_with_source_and_direction(
8901            paragraph,
8902            468.0,
8903            &input.styles,
8904            &input,
8905            &media,
8906            &mut fonts,
8907            &mut numbering,
8908            &mut diagnostics,
8909            None,
8910        )
8911        .expect("field-only paragraph lays out");
8912        assert_eq!(direction, TextDirection::RightToLeft);
8913        assert_eq!(
8914            block.reflow.as_ref().expect("reflow state").items.len(),
8915            2,
8916            "private direction state must not masquerade as a public inline item"
8917        );
8918
8919        let output = deterministic_layout(&input);
8920        let fields = output.pages[0]
8921            .elements
8922            .iter()
8923            .filter_map(|element| match element {
8924                PositionedElement::Text(run) => run.field_kind.map(|kind| (kind, run.origin.x)),
8925                PositionedElement::MarkedContent { children, .. } => {
8926                    children.iter().find_map(|child| match child {
8927                        PositionedElement::Text(run) => {
8928                            run.field_kind.map(|kind| (kind, run.origin.x))
8929                        }
8930                        _ => None,
8931                    })
8932                }
8933                _ => None,
8934            })
8935            .collect::<Vec<_>>();
8936        assert_eq!(
8937            fields.iter().map(|(kind, _)| *kind).collect::<Vec<_>>(),
8938            vec![FieldKind::Page, FieldKind::NumPages],
8939            "logical extraction keeps the stored field sequence"
8940        );
8941    }
8942
8943    #[test]
8944    fn word_positioned_runs_keep_logical_order_with_visual_origins() {
8945        let mut input = make_input_with_text("");
8946        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8947            panic!("expected paragraph")
8948        };
8949        paragraph.properties = Some(CT_PPr {
8950            bidi: Some(true),
8951            ..Default::default()
8952        });
8953        let mut arabic = CT_R::new("العربية ");
8954        arabic.properties = Some(CT_RPr {
8955            rtl: Some(true),
8956            language_bidi: Some("ar-SA".to_owned()),
8957            ..Default::default()
8958        });
8959        let mut latin = CT_R::new("ABC");
8960        latin.properties = Some(CT_RPr {
8961            rtl: Some(false),
8962            language: Some("en-US".to_owned()),
8963            ..Default::default()
8964        });
8965        paragraph.runs = vec![arabic, latin];
8966
8967        let output = deterministic_layout(&input);
8968        let runs = multilingual_runs(&output);
8969        assert_eq!(
8970            runs.iter()
8971                .map(|run| run.logical_text.as_str())
8972                .collect::<String>(),
8973            "العربية ABC"
8974        );
8975        assert!(
8976            runs.windows(2)
8977                .all(|pair| pair[0].logical_index < pair[1].logical_index)
8978        );
8979        let arabic_x = runs
8980            .iter()
8981            .find(|run| run.logical_text.contains("العربية"))
8982            .unwrap()
8983            .origin
8984            .x;
8985        let latin_x = runs
8986            .iter()
8987            .find(|run| run.logical_text == "ABC")
8988            .unwrap()
8989            .origin
8990            .x;
8991        assert!(latin_x < arabic_x, "visual origins remain RTL");
8992    }
8993
8994    #[test]
8995    fn absent_word_bidi_infers_one_rtl_base_for_reordering_alignment_and_indents() {
8996        let mut input = make_input_with_text("");
8997        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8998            panic!("expected paragraph")
8999        };
9000        paragraph.properties = Some(CT_PPr {
9001            jc: Some(rdocx_oxml::shared::ST_Jc::Start),
9002            ind_start: Some(rdocx_oxml::units::Twips(720)),
9003            ind_end: Some(rdocx_oxml::units::Twips(360)),
9004            ..Default::default()
9005        });
9006        let mut arabic = CT_R::new("العربية ");
9007        arabic.properties = Some(CT_RPr {
9008            language_bidi: Some("ar-SA".to_owned()),
9009            ..Default::default()
9010        });
9011        let mut latin = CT_R::new("ABC");
9012        latin.properties = Some(CT_RPr {
9013            language: Some("en-US".to_owned()),
9014            ..Default::default()
9015        });
9016        paragraph.runs = vec![arabic, latin];
9017        let paragraph = paragraph.clone();
9018
9019        let media = MediaRegistry::new(&input.images);
9020        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
9021        let mut numbering = NumberingState::new();
9022        let mut diagnostics = Vec::new();
9023        let block = layout_paragraph(
9024            &paragraph,
9025            468.0,
9026            &input.styles,
9027            &input,
9028            &media,
9029            &mut fonts,
9030            &mut numbering,
9031            &mut diagnostics,
9032        )
9033        .expect("default-direction paragraph lays out");
9034        assert_eq!(block.indent_left, 18.0);
9035        assert_eq!(block.indent_right, 36.0);
9036        assert_eq!(block.jc, Some(oxml_layout::Align::End));
9037
9038        let output = deterministic_layout(&input);
9039        let runs = multilingual_runs(&output);
9040        let arabic_x = runs
9041            .iter()
9042            .find(|run| run.logical_text.contains("العربية"))
9043            .unwrap()
9044            .origin
9045            .x;
9046        let latin_x = runs
9047            .iter()
9048            .find(|run| run.logical_text == "ABC")
9049            .unwrap()
9050            .origin
9051            .x;
9052        assert!(
9053            latin_x < arabic_x,
9054            "absent w:bidi uses one inferred RTL base"
9055        );
9056    }
9057
9058    #[test]
9059    fn changed_document_hyphenation_state_invalidates_reusable_paragraph_work() {
9060        let mut input = hyphenation_input(false, Some("en-US"), false);
9061        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9062        assert_eq!(
9063            output_text(&engine.layout(&input).expect("disabled layout")).concat(),
9064            "representation"
9065        );
9066
9067        input.automatic_hyphenation = true;
9068        let warm = engine.layout(&input).expect("enabled warm layout");
9069        let fresh = Engine::new_deterministic()
9070            .expect("bundled fonts load")
9071            .layout(&input)
9072            .expect("enabled fresh layout");
9073        assert_layout_results_equal(&warm, &fresh);
9074        assert!(output_text(&warm).concat().contains('-'));
9075    }
9076
9077    #[test]
9078    fn inherited_run_language_hyphenates_but_generated_fields_do_not() {
9079        let mut inherited = hyphenation_input(true, None, false);
9080        inherited
9081            .styles
9082            .doc_defaults
9083            .as_mut()
9084            .unwrap()
9085            .rpr
9086            .as_mut()
9087            .unwrap()
9088            .language = Some("en-GB".to_owned());
9089        assert!(
9090            output_text(&deterministic_layout(&inherited))
9091                .concat()
9092                .contains('-')
9093        );
9094
9095        let mut field = hyphenation_input(true, Some("en-US"), false);
9096        let BodyContent::Paragraph(paragraph) = &mut field.document.body.content[0] else {
9097            panic!("expected paragraph")
9098        };
9099        paragraph.runs[0].content = vec![RunContent::Field(Field::new("DATE", "representation"))];
9100        assert_eq!(
9101            output_text(&deterministic_layout(&field)).concat(),
9102            "representation"
9103        );
9104    }
9105
9106    #[test]
9107    fn mixed_languages_and_table_paragraphs_keep_hyphenation_run_local() {
9108        use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
9109        use rdocx_oxml::text::CT_R;
9110
9111        let mut paragraph = CT_P::new();
9112        paragraph.properties = Some(CT_PPr {
9113            ind_right: Some(rdocx_oxml::units::Twips(8_500)),
9114            ..Default::default()
9115        });
9116        for (text, language) in [("representation ", "en-US"), ("rappresentazione", "it-IT")] {
9117            let mut run = CT_R::new(text);
9118            run.properties = Some(rdocx_oxml::properties::CT_RPr {
9119                language: Some(language.to_owned()),
9120                ..Default::default()
9121            });
9122            paragraph.runs.push(run);
9123        }
9124
9125        let mut cell = CT_Tc::new();
9126        cell.content = vec![CellContent::Paragraph(paragraph)];
9127        let mut row = CT_Row::new();
9128        row.cells.push(cell);
9129        let mut table = CT_Tbl::new();
9130        table.rows.push(row);
9131        let mut input = make_input_with_text("");
9132        input.automatic_hyphenation = true;
9133        input.document.body.content = vec![BodyContent::Table(table)];
9134
9135        let text = output_text(&deterministic_layout(&input));
9136        assert!(text.iter().any(|item| item == "-"), "{text:?}");
9137        assert!(
9138            text.iter().any(|item| item == "rappresentazione"),
9139            "{text:?}"
9140        );
9141    }
9142
9143    #[test]
9144    fn oversized_caller_aliases_have_one_bounded_reusable_identity() {
9145        fn retained_bytes(aliases: &[(String, String)]) -> usize {
9146            aliases
9147                .iter()
9148                .map(|(requested, target)| requested.len() + target.len())
9149                .sum()
9150        }
9151
9152        fn assert_compatible_after_bound(
9153            input: &LayoutInput,
9154            first: &[(String, String)],
9155            second: &[(String, String)],
9156        ) {
9157            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9158            engine.set_caller_font_aliases(first);
9159            assert!(engine.caller_font_aliases.len() <= CALLER_ALIAS_MAX_ENTRIES);
9160            assert!(retained_bytes(&engine.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES);
9161            engine.layout(input).expect("prime reusable engine");
9162            let context = engine
9163                .paragraph_cache_context
9164                .as_ref()
9165                .expect("layout retains its context");
9166            assert_eq!(context.caller_font_aliases, engine.caller_font_aliases);
9167            assert!(context.caller_font_aliases.len() <= CALLER_ALIAS_MAX_ENTRIES);
9168            assert!(
9169                retained_bytes(&context.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES
9170            );
9171
9172            let mut source = Some(engine);
9173            assert!(
9174                Engine::take_if_compatible_with_caller_aliases(&mut source, input, second)
9175                    .is_some(),
9176                "aliases discarded by the bounds must not change reusable identity"
9177            );
9178            assert!(source.is_none());
9179        }
9180
9181        let retained_prefix = (0..CALLER_ALIAS_MAX_ENTRIES)
9182            .map(|index| (format!("Document Serif {index}"), "Caladea".to_owned()))
9183            .collect::<Vec<_>>();
9184        let mut entry_limited_a = retained_prefix.clone();
9185        entry_limited_a.push(("discarded entry a".to_owned(), "Caladea".to_owned()));
9186        let mut entry_limited_b = retained_prefix;
9187        entry_limited_b.push(("discarded entry b".to_owned(), "Carlito".to_owned()));
9188        let input = make_input_with_text("bounded caller aliases");
9189        let mut boundary_engine = Engine::new_deterministic().expect("bundled fonts load");
9190        boundary_engine.set_caller_font_aliases(&entry_limited_a);
9191        assert_eq!(
9192            boundary_engine.caller_font_aliases.as_slice(),
9193            &entry_limited_a[..CALLER_ALIAS_MAX_ENTRIES]
9194        );
9195        assert_compatible_after_bound(&input, &entry_limited_a, &entry_limited_b);
9196
9197        let retained_large = ("x".repeat(32_760), String::new());
9198        let byte_limited_a = vec![
9199            retained_large.clone(),
9200            ("discarded bytes a".to_owned(), "Caladea".to_owned()),
9201        ];
9202        let byte_limited_b = vec![
9203            retained_large,
9204            ("discarded bytes b".to_owned(), "Carlito".to_owned()),
9205        ];
9206        boundary_engine.set_caller_font_aliases(&byte_limited_a);
9207        assert_eq!(
9208            boundary_engine.caller_font_aliases.as_slice(),
9209            &byte_limited_a[..1]
9210        );
9211        assert_compatible_after_bound(&input, &byte_limited_a, &byte_limited_b);
9212
9213        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9214        engine.set_caller_font_aliases(&[("x".repeat(70_000), "Caladea".to_owned())]);
9215        assert!(retained_bytes(&engine.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES);
9216        assert!(engine.caller_font_aliases.is_empty());
9217        let context = ReusableEngineContext::for_input(&input, &engine.caller_font_aliases);
9218        assert!(retained_bytes(&context.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES);
9219    }
9220
9221    fn header_footer_part(text: &str) -> rdocx_oxml::header_footer::CT_HdrFtr {
9222        let mut part = rdocx_oxml::header_footer::CT_HdrFtr::new();
9223        let mut paragraph = CT_P::new();
9224        paragraph.add_run(text);
9225        part.paragraphs.push(paragraph);
9226        part
9227    }
9228
9229    fn image_watermark_header(text: &str, width_pt: f64) -> rdocx_oxml::header_footer::CT_HdrFtr {
9230        rdocx_oxml::header_footer::CT_HdrFtr::from_xml(
9231            format!(
9232                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>"#,
9233                rdocx_oxml::namespace::W_NS
9234            )
9235            .as_bytes(),
9236        )
9237        .expect("watermark header parses")
9238    }
9239
9240    fn cacheable_header_footer_input(body: &str) -> LayoutInput {
9241        use rdocx_oxml::header_footer::HdrFtrRef;
9242
9243        let mut input = make_input_with_text(body);
9244        let mut section = CT_SectPr::default_letter();
9245        section.title_pg = Some(true);
9246        for (variant, suffix) in [
9247            (HdrFtrType::Default, "default"),
9248            (HdrFtrType::First, "first"),
9249            (HdrFtrType::Even, "even"),
9250        ] {
9251            let header_id = format!("rId-{suffix}-header");
9252            let footer_id = format!("rId-{suffix}-footer");
9253            section.header_refs.push(HdrFtrRef {
9254                hdr_ftr_type: variant,
9255                rel_id: header_id.clone(),
9256            });
9257            section.footer_refs.push(HdrFtrRef {
9258                hdr_ftr_type: variant,
9259                rel_id: footer_id.clone(),
9260            });
9261            let header = if variant == HdrFtrType::Default {
9262                image_watermark_header(&format!("{suffix} header"), 72.0)
9263            } else {
9264                header_footer_part(&format!("{suffix} header"))
9265            };
9266            input.headers.insert(header_id, header);
9267            input
9268                .footers
9269                .insert(footer_id, header_footer_part(&format!("{suffix} footer")));
9270        }
9271        input.images.insert(
9272            "rId-default-header\0rIdWatermark".to_owned(),
9273            ImageData {
9274                data: vec![1, 2, 3, 4],
9275                content_type: "image/png".to_owned(),
9276            },
9277        );
9278        input.document.body.sect_pr = Some(section);
9279        input
9280    }
9281
9282    fn header_footer_page_text(page: &PageFrame) -> String {
9283        compatibility_page_elements(page)
9284            .into_iter()
9285            .filter_map(|element| match element {
9286                PositionedElement::Text(text) => Some(text.text.as_str()),
9287                _ => None,
9288            })
9289            .collect()
9290    }
9291
9292    fn assert_header_footer_context_miss(
9293        base: &LayoutInput,
9294        name: &str,
9295        mutate: impl FnOnce(&mut LayoutInput),
9296    ) {
9297        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9298        engine.layout(base).expect("prime exact identity");
9299        let mut changed = base.clone();
9300        mutate(&mut changed);
9301        engine
9302            .layout(&changed)
9303            .unwrap_or_else(|error| panic!("{name}: {error}"));
9304        assert_eq!(engine.header_footer_cache_counts(), (0, 12), "{name}");
9305    }
9306
9307    #[test]
9308    fn safe_header_footer_variants_reuse_exactly() {
9309        let mut input = cacheable_header_footer_input(&"body ".repeat(4_000));
9310        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9311        let (cold, cold_sources) = engine
9312            .layout_with_provenance(&input)
9313            .expect("cold header/footer layout");
9314        assert!(cold.pages.len() > 2);
9315        assert!(header_footer_page_text(&cold.pages[0]).contains("first header"));
9316        assert!(header_footer_page_text(&cold.pages[0]).contains("first footer"));
9317        assert!(header_footer_page_text(&cold.pages[1]).contains("even header"));
9318        assert!(header_footer_page_text(&cold.pages[1]).contains("even footer"));
9319        assert!(header_footer_page_text(&cold.pages[2]).contains("default header"));
9320        assert!(header_footer_page_text(&cold.pages[2]).contains("default footer"));
9321        assert_eq!(engine.header_footer_cache_counts(), (0, 6));
9322        assert_eq!(engine.header_footer_cache.len(), 6);
9323        assert!(
9324            engine
9325                .header_footer_cache
9326                .iter()
9327                .all(|entry| !entry.font_trace.is_empty())
9328        );
9329
9330        for (index, entry) in engine.header_footer_cache.iter_mut().enumerate() {
9331            entry.diagnostics = vec![Diagnostic {
9332                message: format!("cached header/footer diagnostic {index}"),
9333            }];
9334        }
9335        input.document.body.content.insert(
9336            0,
9337            BodyContent::Paragraph({
9338                let mut paragraph = CT_P::new();
9339                paragraph.add_run("inserted body source");
9340                paragraph
9341            }),
9342        );
9343        let (warm, warm_sources) = engine
9344            .layout_with_provenance(&input)
9345            .expect("warm header/footer layout");
9346        let (fresh, fresh_sources) = Engine::new_deterministic()
9347            .expect("bundled fonts load")
9348            .layout_with_provenance(&input)
9349            .expect("fresh comparison layout");
9350        assert_eq!(format!("{:?}", warm.pages), format!("{:?}", fresh.pages));
9351        assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", fresh.fonts));
9352        assert_eq!(
9353            format!("{:?}", warm.outlines),
9354            format!("{:?}", fresh.outlines)
9355        );
9356        assert_eq!(warm_sources, fresh_sources);
9357        assert_ne!(cold_sources, warm_sources);
9358        assert_eq!(engine.header_footer_cache_counts(), (6, 6));
9359        assert_eq!(warm.diagnostics.len(), 6);
9360        assert!(warm.diagnostics.iter().all(|diagnostic| {
9361            diagnostic
9362                .message
9363                .starts_with("cached header/footer diagnostic")
9364        }));
9365        let header_source = warm
9366            .pages
9367            .iter()
9368            .flat_map(|page| compatibility_page_elements(page))
9369            .filter_map(|element| match element {
9370                PositionedElement::Text(text) if text.text.contains("header") => text.source,
9371                _ => None,
9372            })
9373            .next()
9374            .expect("cached header text has provenance");
9375        assert!(matches!(
9376            warm_sources[header_source.node.get() as usize - 1].story,
9377            WordStory::Header { .. }
9378        ));
9379
9380        // F-X042 resolves inherited references onto each section before layout.
9381        // Model that exact input shape and prove both the authored first section
9382        // and inherited final section reuse their variants on the next layout.
9383        let mut inherited_input = cacheable_header_footer_input(&"second section ".repeat(2_000));
9384        let inherited_section = inherited_input
9385            .document
9386            .body
9387            .sect_pr
9388            .clone()
9389            .expect("final section");
9390        let mut first_section_end = CT_P::new();
9391        first_section_end.add_run("authored section with shared variants");
9392        first_section_end.properties.get_or_insert_default().sect_pr = Some(inherited_section);
9393        inherited_input
9394            .document
9395            .body
9396            .content
9397            .insert(0, BodyContent::Paragraph(first_section_end));
9398        let mut inherited_engine = Engine::new_deterministic().expect("bundled fonts load");
9399        inherited_engine
9400            .layout_with_provenance(&inherited_input)
9401            .expect("cold inherited layout");
9402        let (inherited_warm, inherited_sources) = inherited_engine
9403            .layout_with_provenance(&inherited_input)
9404            .expect("warm inherited layout");
9405        let (inherited_fresh, fresh_inherited_sources) = Engine::new_deterministic()
9406            .expect("bundled fonts load")
9407            .layout_with_provenance(&inherited_input)
9408            .expect("fresh inherited layout");
9409        assert_eq!(inherited_engine.header_footer_cache_counts(), (12, 12));
9410        assert_eq!(
9411            format!("{:?}", inherited_warm.pages),
9412            format!("{:?}", inherited_fresh.pages)
9413        );
9414        assert_eq!(inherited_sources, fresh_inherited_sources);
9415    }
9416
9417    #[test]
9418    fn header_footer_media_geometry_and_context_changes_miss() {
9419        use rdocx_oxml::footnotes::CT_Footnotes;
9420        use rdocx_oxml::math::MathProperties;
9421        use rdocx_oxml::numbering::CT_Numbering;
9422        use rdocx_oxml::theme::Theme;
9423        use rdocx_oxml::units::Twips;
9424
9425        let base = cacheable_header_footer_input("body");
9426        assert_header_footer_context_miss(&base, "header text", |input| {
9427            input.headers.insert(
9428                "rId-first-header".to_owned(),
9429                header_footer_part("changed first header"),
9430            );
9431        });
9432        assert_header_footer_context_miss(&base, "media bytes", |input| {
9433            input
9434                .images
9435                .get_mut("rId-default-header\0rIdWatermark")
9436                .expect("watermark image")
9437                .data
9438                .push(5);
9439        });
9440        assert_header_footer_context_miss(&base, "watermark", |input| {
9441            input.headers.insert(
9442                "rId-default-header".to_owned(),
9443                image_watermark_header("default header", 73.0),
9444            );
9445        });
9446        assert_header_footer_context_miss(&base, "same-width page height", |input| {
9447            input
9448                .document
9449                .body
9450                .sect_pr
9451                .as_mut()
9452                .expect("section")
9453                .page_height = Some(Twips(15_841));
9454        });
9455        assert_header_footer_context_miss(&base, "styles", |input| {
9456            input.styles = CT_Styles::new();
9457        });
9458        assert_header_footer_context_miss(&base, "numbering", |input| {
9459            input.numbering = Some(CT_Numbering::new());
9460        });
9461        assert_header_footer_context_miss(&base, "notes", |input| {
9462            input.footnotes = Some(CT_Footnotes::new());
9463        });
9464        assert_header_footer_context_miss(&base, "theme", |input| {
9465            input.theme = Some(Theme::default());
9466        });
9467        assert_header_footer_context_miss(&base, "math properties", |input| {
9468            input.math_properties = Some(MathProperties::new());
9469        });
9470        assert_header_footer_context_miss(&base, "revision", |input| {
9471            input.revision_view = RevisionView::Tracked;
9472        });
9473        assert_header_footer_context_miss(&base, "fonts", |input| {
9474            let (family, data) = oxml_layout::bundled_fonts::bundled_font_data()[0];
9475            input.fonts.push(oxml_layout::FontFile {
9476                family: family.to_owned(),
9477                data: data.to_vec(),
9478            });
9479        });
9480
9481        let mut source_mode = Engine::new_deterministic().expect("bundled fonts load");
9482        source_mode.layout(&base).expect("prime unsourced cache");
9483        source_mode
9484            .layout_with_provenance(&base)
9485            .expect("sourced layout misses unsourced entries");
9486        assert_eq!(source_mode.header_footer_cache_counts(), (0, 12));
9487
9488        let mut unsafe_input = base.clone();
9489        unsafe_input
9490            .headers
9491            .get_mut("rId-first-header")
9492            .expect("first header")
9493            .paragraphs[0]
9494            .properties
9495            .get_or_insert_default()
9496            .num_id = Some(1);
9497        let mut unsafe_engine = Engine::new_deterministic().expect("bundled fonts load");
9498        unsafe_engine
9499            .layout(&unsafe_input)
9500            .expect("unsafe part lays out");
9501        assert_eq!(unsafe_engine.header_footer_cache_counts(), (0, 5));
9502
9503        let mut opaque_input = base.clone();
9504        let opaque_run = &mut opaque_input
9505            .headers
9506            .get_mut("rId-first-header")
9507            .expect("first header")
9508            .paragraphs[0]
9509            .runs[0];
9510        opaque_run
9511            .extra_xml
9512            .push(br#"<w:object xmlns:w="urn:unrepresented"/>"#.to_vec());
9513        opaque_run.extra_xml_positions.push(0);
9514        let mut opaque_engine = Engine::new_deterministic().expect("bundled fonts load");
9515        opaque_engine
9516            .layout(&opaque_input)
9517            .expect("opaque producer XML lays out without reuse");
9518        assert_eq!(opaque_engine.header_footer_cache_counts(), (0, 5));
9519
9520        let foreign_wrapper = rdocx_oxml::header_footer::CT_HdrFtr::from_xml(
9521            format!(
9522                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>"#,
9523                rdocx_oxml::namespace::W_NS
9524            )
9525            .as_bytes(),
9526        )
9527        .expect("foreign pict wrapper parses");
9528        assert_eq!(foreign_wrapper.watermarks().len(), 1);
9529        assert!(!header_footer_part_is_cache_safe(
9530            &foreign_wrapper,
9531            &base.styles
9532        ));
9533        let rebound_word_prefix = rdocx_oxml::header_footer::CT_HdrFtr::from_xml(
9534            format!(
9535                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>"#,
9536                rdocx_oxml::namespace::W_NS
9537            )
9538            .as_bytes(),
9539        )
9540        .expect("rebound conventional prefix parses");
9541        assert_eq!(rebound_word_prefix.watermarks().len(), 1);
9542        assert!(!header_footer_part_is_cache_safe(
9543            &rebound_word_prefix,
9544            &base.styles
9545        ));
9546    }
9547
9548    #[test]
9549    fn header_footer_cache_publishes_transactionally_and_stays_bounded() {
9550        use rdocx_oxml::header_footer::HdrFtrRef;
9551
9552        let (valid_family, valid_bytes) = oxml_layout::bundled_fonts::bundled_font_data()[0];
9553        let (invalid_family, invalid_source) = oxml_layout::bundled_fonts::bundled_font_data()[4];
9554        let mut invalid_bytes = invalid_source.to_vec();
9555        let table_count = u16::from_be_bytes([invalid_bytes[4], invalid_bytes[5]]) as usize;
9556        let head_offset = (0..table_count)
9557            .find_map(|table| {
9558                let record = 12 + table * 16;
9559                (&invalid_bytes[record..record + 4] == b"head").then(|| {
9560                    u32::from_be_bytes(
9561                        invalid_bytes[record + 8..record + 12]
9562                            .try_into()
9563                            .expect("head offset"),
9564                    ) as usize
9565                })
9566            })
9567            .expect("font has head table");
9568        invalid_bytes[head_offset + 18..head_offset + 20].copy_from_slice(&0u16.to_be_bytes());
9569
9570        let mut failing_input = make_input_with_text("section-ending prefix");
9571        let mut section = CT_SectPr::default_letter();
9572        section.header_refs.push(HdrFtrRef {
9573            hdr_ftr_type: HdrFtrType::Default,
9574            rel_id: "rIdHeader".to_owned(),
9575        });
9576        let BodyContent::Paragraph(prefix) = &mut failing_input.document.body.content[0] else {
9577            panic!("prefix paragraph");
9578        };
9579        prefix.properties.get_or_insert_default().sect_pr = Some(section);
9580        prefix.runs[0].properties.get_or_insert_default().font_ascii =
9581            Some(valid_family.to_owned());
9582        failing_input.headers.insert(
9583            "rIdHeader".to_owned(),
9584            header_footer_part("staged header before late failure"),
9585        );
9586        let mut later = CT_P::new();
9587        later
9588            .add_run("late font failure")
9589            .properties
9590            .get_or_insert_default()
9591            .font_ascii = Some(invalid_family.to_owned());
9592        failing_input.document.body.add_paragraph(later);
9593        failing_input.fonts.push(oxml_layout::FontFile {
9594            family: invalid_family.to_owned(),
9595            data: invalid_bytes,
9596        });
9597        let mut failing = Engine::with_font_manager(FontManager::new_with_fonts(vec![(
9598            valid_family.to_owned(),
9599            valid_bytes.to_vec(),
9600        )]));
9601        assert!(failing.layout(&failing_input).is_err());
9602        assert!(failing.header_footer_cache.is_empty());
9603        assert_eq!(failing.header_footer_cache_bytes, 0);
9604        assert_eq!(failing.header_footer_cache_counts(), (0, 1));
9605
9606        let mut bounded_input = make_input_with_text("bounded body");
9607        let mut bounded_section = CT_SectPr::default_letter();
9608        for index in 0..(HEADER_FOOTER_CACHE_MAX_ENTRIES * 2) {
9609            let relationship_id = format!("rIdHeader{index:03}");
9610            bounded_section.header_refs.push(HdrFtrRef {
9611                hdr_ftr_type: HdrFtrType::Default,
9612                rel_id: relationship_id.clone(),
9613            });
9614            bounded_input.headers.insert(
9615                relationship_id,
9616                header_footer_part(&format!("bounded header {index:03}")),
9617            );
9618        }
9619        bounded_input.document.body.sect_pr = Some(bounded_section);
9620        let mut bounded = Engine::new_deterministic().expect("bundled fonts load");
9621        bounded
9622            .layout(&bounded_input)
9623            .expect("bounded pending layout succeeds");
9624        assert_eq!(
9625            bounded.header_footer_cache.len(),
9626            HEADER_FOOTER_CACHE_MAX_ENTRIES
9627        );
9628        assert!(bounded.header_footer_cache_bytes <= HEADER_FOOTER_CACHE_MAX_BYTES);
9629        assert!(
9630            bounded.pending_header_footer_cache_peak_entries <= HEADER_FOOTER_CACHE_MAX_ENTRIES
9631        );
9632        assert!(bounded.pending_header_footer_cache_peak_bytes <= HEADER_FOOTER_CACHE_MAX_BYTES);
9633        assert_eq!(
9634            bounded.header_footer_cache_bytes,
9635            bounded
9636                .header_footer_cache
9637                .iter()
9638                .map(|entry| entry.bytes)
9639                .sum::<usize>()
9640        );
9641        assert!(
9642            bounded.paragraph_cache.len()
9643                + bounded.table_cache.len()
9644                + bounded.header_footer_cache.len()
9645                + bounded
9646                    .restart_cache
9647                    .as_ref()
9648                    .map_or(0, |cache| cache.checkpoints.len())
9649                <= CACHE_MAX_ENTRIES
9650        );
9651        assert!(
9652            bounded.paragraph_cache_bytes
9653                + bounded.table_cache_bytes
9654                + bounded.header_footer_cache_bytes
9655                + bounded
9656                    .restart_cache
9657                    .as_ref()
9658                    .map_or(0, |cache| cache.bytes)
9659                <= CACHE_MAX_BYTES
9660        );
9661
9662        let one = cacheable_header_footer_input("oversized entry body");
9663        let mut oversized = Engine::new_deterministic().expect("bundled fonts load");
9664        oversized.layout(&one).expect("prime oversized template");
9665        let mut entry = oversized
9666            .header_footer_cache
9667            .pop_front()
9668            .expect("header/footer template retained");
9669        let mut oversized_key = oversized
9670            .header_footer_cache
9671            .pop_front()
9672            .expect("second header/footer template retained");
9673        oversized.header_footer_cache.clear();
9674        oversized.header_footer_cache_bytes = 0;
9675        let mut reserved_namespace = String::with_capacity(HEADER_FOOTER_CACHE_MAX_BYTES + 1);
9676        reserved_namespace.push('x');
9677        oversized_key
9678            .key
9679            .part
9680            .extra_namespaces
9681            .push((reserved_namespace, "urn:test".to_owned()));
9682        oversized_key.bytes = header_footer_cache_entry_bytes(
9683            &oversized_key.key,
9684            &oversized_key.content,
9685            &oversized_key.diagnostics,
9686            &oversized_key.font_trace,
9687        );
9688        assert!(oversized_key.bytes > HEADER_FOOTER_CACHE_MAX_BYTES);
9689        oversized.publish_header_footer_cache_entry(oversized_key);
9690        assert!(oversized.header_footer_cache.is_empty());
9691        assert_eq!(oversized.header_footer_cache_bytes, 0);
9692
9693        let text = entry.content.blocks[0]
9694            .lines
9695            .iter_mut()
9696            .flat_map(|line| &mut line.items)
9697            .find_map(|item| match item {
9698                LineItem::Text(text) => Some(text),
9699                _ => None,
9700            })
9701            .expect("template has text");
9702        text.advances = vec![0.0; HEADER_FOOTER_CACHE_MAX_BYTES / 8 + 1];
9703        entry.bytes = header_footer_cache_entry_bytes(
9704            &entry.key,
9705            &entry.content,
9706            &entry.diagnostics,
9707            &entry.font_trace,
9708        );
9709        assert!(entry.bytes > HEADER_FOOTER_CACHE_MAX_BYTES);
9710        oversized.publish_header_footer_cache_entry(entry);
9711        assert!(oversized.header_footer_cache.is_empty());
9712        assert_eq!(oversized.header_footer_cache_bytes, 0);
9713    }
9714
9715    #[test]
9716    fn word_projection_leaves_break_segmentation_to_shared_layout() {
9717        let input = make_input_with_text("financial planning");
9718        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
9719            panic!("expected paragraph");
9720        };
9721        let media = MediaRegistry::new(&input.images);
9722        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
9723        let mut numbering = NumberingState::new();
9724        let mut diagnostics = Vec::new();
9725        let block = layout_paragraph_with_source(
9726            paragraph,
9727            468.0,
9728            &input.styles,
9729            &input,
9730            &media,
9731            &mut fonts,
9732            &mut numbering,
9733            &mut diagnostics,
9734            SourceNodeId::new(1),
9735        )
9736        .expect("paragraph lays out");
9737        let text_items = block
9738            .reflow
9739            .expect("line-breaking inputs retained")
9740            .items
9741            .into_iter()
9742            .filter_map(|item| match item {
9743                InlineItem::Text(segment) => Some(segment),
9744                _ => None,
9745            })
9746            .collect::<Vec<_>>();
9747
9748        assert_eq!(text_items.len(), 1);
9749        assert_eq!(text_items[0].text, "financial planning");
9750        assert_eq!(text_items[0].source.expect("source span").char_start, 0);
9751        assert_eq!(text_items[0].source.expect("source span").char_end, 18);
9752    }
9753
9754    #[test]
9755    fn mixed_script_fallback_uses_each_covering_font_without_boxes() {
9756        let input = make_input_with_text("Latin العربية देवनागरी ภาษาไทย 你好世界");
9757        let result = crate::layout_document_deterministic_with_provenance(&input)
9758            .expect("deterministic multilingual layout");
9759        let runs = multilingual_runs(&result.layout);
9760
9761        assert!(
9762            !runs.is_empty(),
9763            "Word layout still emits only legacy glyph runs"
9764        );
9765        for script in [
9766            TextScript::Latin,
9767            TextScript::Arabic,
9768            TextScript::Devanagari,
9769            TextScript::Thai,
9770            TextScript::Han,
9771        ] {
9772            assert!(
9773                runs.iter().any(|run| run.script == script),
9774                "missing {script:?} span"
9775            );
9776        }
9777        assert!(
9778            runs.iter().all(|run| !run.glyph_ids.contains(&0)),
9779            "deterministic fallback emitted a .notdef glyph: {:?}",
9780            runs.iter()
9781                .map(|run| (&run.logical_text, run.script, &run.glyph_ids))
9782                .collect::<Vec<_>>()
9783        );
9784    }
9785
9786    #[test]
9787    fn complex_shaping_preserves_clusters_offsets_and_logical_source_spans() {
9788        let text = "سلام क्षि ภาษาไทย 你好世界";
9789        let input = make_input_with_text(text);
9790        let result = crate::layout_document_deterministic_with_provenance(&input)
9791            .expect("deterministic multilingual layout");
9792        let mut runs = multilingual_runs(&result.layout);
9793
9794        assert!(!runs.is_empty(), "Word did not consume rich shaped spans");
9795        runs.sort_by_key(|run| run.logical_index);
9796        assert_eq!(
9797            runs.iter()
9798                .map(|run| run.logical_text.as_str())
9799                .collect::<String>(),
9800            text
9801        );
9802        assert!(runs.iter().all(|run| run.is_valid()));
9803        assert!(runs.iter().all(|run| run.source.is_some()));
9804        assert!(runs.iter().any(|run| {
9805            run.script == TextScript::Devanagari
9806                && run
9807                    .clusters
9808                    .iter()
9809                    .any(|cluster| cluster.char_end - cluster.char_start > 1)
9810        }));
9811    }
9812
9813    #[test]
9814    fn rich_mixed_script_paragraph_retains_conditional_hyphenation() {
9815        let mut input = hyphenation_input(true, Some("en-US"), false);
9816        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9817            panic!("expected paragraph")
9818        };
9819        let mut arabic = CT_R::new(" العربية");
9820        arabic.properties = Some(rdocx_oxml::properties::CT_RPr {
9821            language: Some("en-US".to_owned()),
9822            language_bidi: Some("ar-SA".to_owned()),
9823            ..Default::default()
9824        });
9825        paragraph.runs.push(arabic);
9826
9827        let output = deterministic_layout(&input);
9828        let text = output_text(&output);
9829        assert!(text.iter().any(|item| item == "-"), "{text:?}");
9830        assert!(
9831            multilingual_runs(&output)
9832                .iter()
9833                .any(|run| run.script == TextScript::Arabic),
9834            "the Arabic run must still use rich shaping"
9835        );
9836    }
9837
9838    #[test]
9839    fn one_mixed_text_node_uses_each_effective_word_language_slot() {
9840        let text = "Latin العربية 你好";
9841        let mut input = make_input_with_text(text);
9842        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9843            panic!("expected paragraph")
9844        };
9845        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
9846            language: Some("en-US".to_owned()),
9847            language_east_asia: Some("zh-CN".to_owned()),
9848            language_bidi: Some("ar-SA".to_owned()),
9849            ..Default::default()
9850        });
9851        let result = crate::layout_document_deterministic_with_provenance(&input)
9852            .expect("deterministic multilingual layout");
9853        let runs = multilingual_runs(&result.layout);
9854
9855        for (script, language) in [
9856            (TextScript::Latin, "en-US"),
9857            (TextScript::Arabic, "ar-SA"),
9858            (TextScript::Han, "zh-CN"),
9859        ] {
9860            let run = runs
9861                .iter()
9862                .find(|run| run.script == script && run.language.as_deref() == Some(language))
9863                .unwrap_or_else(|| {
9864                    panic!(
9865                        "missing {script:?} with {language}: {:?}",
9866                        runs.iter()
9867                            .map(|run| (
9868                                run.script,
9869                                run.language.as_deref(),
9870                                run.logical_text.as_str()
9871                            ))
9872                            .collect::<Vec<_>>()
9873                    )
9874                });
9875            let source = run.source.unwrap_or_else(|| {
9876                panic!(
9877                    "mixed-language {script:?} span {:?} retains source: {:?}",
9878                    run.logical_text,
9879                    runs.iter()
9880                        .map(|run| (&run.logical_text, run.script, run.source))
9881                        .collect::<Vec<_>>()
9882                )
9883            });
9884            let source_text = text
9885                .chars()
9886                .skip(source.char_start as usize)
9887                .take((source.char_end - source.char_start) as usize)
9888                .collect::<String>();
9889            assert_eq!(source_text, run.logical_text);
9890        }
9891    }
9892
9893    #[test]
9894    fn rich_stored_field_retains_resolved_language_and_character_spacing() {
9895        for (xml, expected) in [
9896            (
9897                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>"#,
9898                "stored",
9899            ),
9900            (
9901                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>"#,
9902                "resolved",
9903            ),
9904        ] {
9905            let mut input = make_input_with_text("");
9906            input.document =
9907                rdocx_oxml::CT_Document::from_xml(xml.as_bytes()).expect("stored field XML parses");
9908            let output = deterministic_layout(&input);
9909            let run = multilingual_runs(&output)
9910                .into_iter()
9911                .find(|run| {
9912                    run.logical_text == expected && run.language.as_deref() == Some("en-US")
9913                })
9914                .expect("stored or resolved field is rich-shaped with its language");
9915
9916            let family = output
9917                .fonts
9918                .iter()
9919                .find(|font| font.id == run.font_id)
9920                .expect("field font is present")
9921                .family
9922                .clone();
9923            let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
9924            let font_id = fonts
9925                .resolve_font(Some(&family), run.bold, run.italic)
9926                .expect("field font resolves");
9927            let unspaced = fonts
9928                .shape_text(font_id, &run.logical_text, run.font_size)
9929                .expect("field text reshapes");
9930            assert_eq!(run.x_advances.len(), unspaced.advances.len());
9931            assert!(
9932                run.x_advances
9933                    .iter()
9934                    .zip(unspaced.advances)
9935                    .all(|(actual, unspaced)| (*actual - unspaced - 2.0).abs() < 0.001),
9936                "stored or resolved field spacing was not retained"
9937            );
9938        }
9939    }
9940
9941    #[test]
9942    fn exact_word_lines_place_every_complex_script_on_the_word_em_baseline() {
9943        for (family, language_attributes, text) in [
9944            (
9945                "Noto Sans Arabic",
9946                r#"w:val="ar-SA" w:bidi="ar-SA""#,
9947                "العربية مرحبا بالعالم",
9948            ),
9949            (
9950                "Noto Sans Devanagari",
9951                r#"w:val="hi-IN""#,
9952                "देवनागरी नमस्ते दुनिया",
9953            ),
9954            ("Noto Sans Thai", r#"w:val="th-TH""#, "ภาษาไทยยินดีต้อนรับ"),
9955            (
9956                "Noto Sans SC",
9957                r#"w:val="zh-CN" w:eastAsia="zh-CN""#,
9958                "〈中〉、你好世界",
9959            ),
9960        ] {
9961            let xml = format!(
9962                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>"#
9963            );
9964            let mut input = make_input_with_text("");
9965            input.document = rdocx_oxml::CT_Document::from_xml(xml.as_bytes())
9966                .expect("complex-script metric fixture parses");
9967            let result = crate::layout_document_deterministic_with_provenance(&input)
9968                .expect("complex-script metric fixture lays out");
9969            let runs = multilingual_runs(&result.layout);
9970            let first = runs
9971                .iter()
9972                .min_by_key(|run| run.logical_index)
9973                .expect("fixture emits rich text");
9974            assert!(
9975                (first.origin.y - 91.2).abs() < 0.001,
9976                "{family} baseline was {}, expected 91.2",
9977                first.origin.y
9978            );
9979        }
9980    }
9981
9982    #[test]
9983    fn latin_shaping_and_hash_outputs_remain_byte_identical() {
9984        let text = "financial العربية";
9985        let input = make_input_with_text(text);
9986        let result = crate::layout_document_deterministic_with_provenance(&input)
9987            .expect("deterministic Latin layout");
9988        let mut runs = multilingual_runs(&result.layout);
9989
9990        assert!(
9991            !runs.is_empty(),
9992            "Word has not migrated to the shared rich path"
9993        );
9994        runs.sort_by_key(|run| run.logical_index);
9995        assert_eq!(
9996            runs.iter()
9997                .map(|run| run.logical_text.as_str())
9998                .collect::<String>(),
9999            text
10000        );
10001        let latin = runs
10002            .iter()
10003            .find(|run| run.script == TextScript::Latin && run.logical_text == "financial")
10004            .expect("mixed-script paragraph retains its Latin span");
10005        let projection = latin.legacy_projection();
10006        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
10007        let family = result
10008            .layout
10009            .fonts
10010            .iter()
10011            .find(|font| font.id == latin.font_id)
10012            .expect("Latin font is in the result")
10013            .family
10014            .clone();
10015        let font_id = fonts
10016            .resolve_font(Some(&family), latin.bold, latin.italic)
10017            .expect("bundled Latin font resolves");
10018        let independently_shaped = fonts
10019            .shape_text(font_id, &latin.logical_text, latin.font_size)
10020            .expect("Latin span shapes independently");
10021        assert_eq!(projection.glyph_ids, independently_shaped.glyph_ids);
10022        assert_eq!(projection.advances, independently_shaped.advances);
10023    }
10024
10025    #[test]
10026    fn break_opportunities_emit_every_scalar_and_glyph_once() {
10027        let text = "financial planning ttf-parser  double  spaces e\u{301}lan allocated \u{754c} "
10028            .repeat(12);
10029        let input = make_input_with_text(&text);
10030        let result = crate::layout_document_deterministic_with_provenance(&input)
10031            .expect("deterministic layout");
10032        let runs = multilingual_runs(&result.layout);
10033
10034        assert_eq!(
10035            runs.iter()
10036                .map(|run| run.logical_text.as_str())
10037                .collect::<String>(),
10038            text
10039        );
10040        let mut expected_start = 0;
10041        for run in runs {
10042            let source = run.source.expect("filtered sourced run");
10043            assert_eq!(source.char_start, expected_start);
10044            assert_eq!(
10045                source.char_end - source.char_start,
10046                run.logical_text.chars().count() as u32
10047            );
10048            expected_start = source.char_end;
10049            assert!(run.is_valid(), "{}", run.logical_text);
10050        }
10051        assert_eq!(expected_start, text.chars().count() as u32);
10052    }
10053
10054    #[test]
10055    fn reported_words_do_not_duplicate_boundary_glyphs() {
10056        for text in [
10057            "ttf-parser follows",
10058            "double  spaces follow",
10059            "financial planning",
10060            "allocated space",
10061        ] {
10062            let input = make_input_with_text(text);
10063            let result = crate::layout_document_deterministic_with_provenance(&input)
10064                .expect("deterministic layout");
10065            let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
10066            for run in result.layout.pages.iter().flat_map(|page| {
10067                compatibility_page_elements(page)
10068                    .into_iter()
10069                    .filter_map(|element| match element {
10070                        PositionedElement::Text(run) if run.source.is_some() => Some(run),
10071                        _ => None,
10072                    })
10073            }) {
10074                let family = result
10075                    .layout
10076                    .fonts
10077                    .iter()
10078                    .find(|font| font.id == run.font_id)
10079                    .expect("run font is in result")
10080                    .family
10081                    .clone();
10082                let font_id = fonts
10083                    .resolve_font(Some(&family), run.bold, run.italic)
10084                    .expect("bundled run font resolves");
10085                let independently_shaped = fonts
10086                    .shape_text(font_id, &run.text, run.font_size)
10087                    .expect("emitted chunk reshapes");
10088                assert_eq!(run.glyph_ids, independently_shaped.glyph_ids, "{text}");
10089                assert_eq!(run.advances, independently_shaped.advances, "{text}");
10090            }
10091        }
10092    }
10093
10094    #[test]
10095    fn warm_relayout_matches_cold_and_rebuilds_only_changed_safe_paragraphs() {
10096        let mut input = make_input_with_text("first cache-safe paragraph");
10097        for text in ["second cache-safe paragraph", "third cache-safe paragraph"] {
10098            let mut paragraph = CT_P::new();
10099            paragraph.add_run(text);
10100            input.document.body.add_paragraph(paragraph);
10101        }
10102
10103        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
10104        let cold = warm_engine
10105            .layout_with_provenance(&input)
10106            .expect("cold layout succeeds");
10107        let after_cold = warm_engine.paragraph_cache_counts();
10108
10109        let BodyContent::Paragraph(changed) = &mut input.document.body.content[1] else {
10110            panic!("second body item is a paragraph");
10111        };
10112        changed.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
10113            "changed cache-safe paragraph",
10114        ))];
10115
10116        let warm = warm_engine
10117            .layout_with_provenance(&input)
10118            .expect("warm relayout succeeds");
10119        let after_warm = warm_engine.paragraph_cache_counts();
10120        let cold_after_edit = Engine::new_deterministic()
10121            .expect("bundled fonts load")
10122            .layout_with_provenance(&input)
10123            .expect("independent cold relayout succeeds");
10124
10125        assert_eq!(format!("{:?}", warm.0), format!("{:?}", cold_after_edit.0));
10126        assert_eq!(warm.1, cold_after_edit.1);
10127        assert_eq!(after_cold, (0, 3));
10128        assert_eq!(after_warm, (2, 4));
10129        assert_ne!(output_text(&cold.0), output_text(&warm.0));
10130    }
10131
10132    #[test]
10133    fn paragraph_and_table_fingerprint_collisions_require_typed_equality() {
10134        let first = make_input_with_text("first collision candidate");
10135        let second = make_input_with_text("second collision candidate");
10136        let BodyContent::Paragraph(second_paragraph) = &second.document.body.content[0] else {
10137            panic!("body item is a paragraph");
10138        };
10139        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10140        engine.layout(&first).expect("first layout succeeds");
10141
10142        let forced_fingerprint = paragraph_fingerprint(second_paragraph);
10143        let retained = engine
10144            .paragraph_cache
10145            .front_mut()
10146            .expect("first paragraph is retained");
10147        assert_ne!(retained.fingerprint, forced_fingerprint);
10148        retained.fingerprint = forced_fingerprint;
10149
10150        let output = engine.layout(&second).expect("collision layout succeeds");
10151        assert_eq!(output_text(&output).concat(), "second collision candidate");
10152        assert_eq!(engine.paragraph_cache_counts(), (0, 2));
10153
10154        let mut first = make_input_with_text("before first table");
10155        first.document.body.add_table(safe_table("first table"));
10156        let mut second = make_input_with_text("before first table");
10157        second.document.body.add_table(safe_table("second table"));
10158        let BodyContent::Table(second_table) = &second.document.body.content[1] else {
10159            panic!("body item is a table");
10160        };
10161        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10162        engine.layout(&first).expect("first table layout succeeds");
10163        let forced_fingerprint = table_fingerprint(second_table);
10164        let retained = engine
10165            .table_cache
10166            .front_mut()
10167            .expect("first table is retained");
10168        assert_ne!(retained.fingerprint, forced_fingerprint);
10169        retained.fingerprint = forced_fingerprint;
10170
10171        let output = engine
10172            .layout(&second)
10173            .expect("table collision layout succeeds");
10174        assert!(output_text(&output).concat().contains("second table"));
10175        assert_eq!(engine.table_cache_counts(), (0, 2));
10176    }
10177
10178    #[test]
10179    fn body_only_layout_and_transfer_do_not_rebuild_owned_context() {
10180        let input = restart_input();
10181        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10182        engine.layout(&input).expect("cold layout succeeds");
10183        assert_eq!(engine.owned_context_build_count(), 1);
10184
10185        let mut edited = input.clone();
10186        set_body_paragraph_text(&mut edited, 70, "body-only edit");
10187        engine.layout(&edited).expect("warm layout succeeds");
10188        assert_eq!(engine.owned_context_build_count(), 1);
10189
10190        let mut source = Some(engine);
10191        let transferred = Engine::take_if_compatible(&mut source, &edited)
10192            .expect("body-only restore accepts the retained engine");
10193        assert_eq!(transferred.owned_context_build_count(), 1);
10194        assert!(source.is_none());
10195    }
10196
10197    #[test]
10198    fn editor_scale_paragraph_cache_avoids_warm_thrash() {
10199        let mut input = make_input_with_text("editor paragraph 000");
10200        for index in 1..700 {
10201            let mut paragraph = CT_P::new();
10202            paragraph.add_run(&format!("editor paragraph {index:03}"));
10203            input.document.body.add_paragraph(paragraph);
10204        }
10205        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10206        engine
10207            .layout_with_provenance(&input)
10208            .expect("editor cold layout succeeds");
10209        assert_eq!(engine.paragraph_cache.len(), 700);
10210        assert_eq!(engine.paragraph_cache_counts(), (0, 700));
10211
10212        set_body_paragraph_text(&mut input, 350, "editor paragraph 350 changed");
10213        let warm = engine
10214            .layout_with_provenance(&input)
10215            .expect("editor warm layout succeeds");
10216        let cold = Engine::new_deterministic()
10217            .expect("bundled fonts load")
10218            .layout_with_provenance(&input)
10219            .expect("editor cold comparison succeeds");
10220
10221        assert_layout_results_equal(&warm.0, &cold.0);
10222        assert_eq!(warm.1, cold.1);
10223        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
10224        assert_eq!(engine.paragraph_cache.len(), 701);
10225        assert_eq!(
10226            engine
10227                .paragraph_cache
10228                .front()
10229                .expect("insertion order has a front")
10230                .key
10231                .paragraph
10232                .text(),
10233            "editor paragraph 000"
10234        );
10235        let rebuilt = engine
10236            .last_rebuilt_page_range
10237            .clone()
10238            .expect("edited layout reports a rebuilt range");
10239        assert!(
10240            rebuilt.end.saturating_sub(rebuilt.start) <= 2,
10241            "{rebuilt:?}"
10242        );
10243    }
10244
10245    fn note_reference_cache_input(stream: NoteStream, include_second_note: bool) -> LayoutInput {
10246        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
10247
10248        let mut input = make_input_with_text("note cache paragraph 000");
10249        for index in 1..700 {
10250            let mut paragraph = CT_P::new();
10251            paragraph.add_run(&format!("note cache paragraph {index:03}"));
10252            input.document.body.add_paragraph(paragraph);
10253        }
10254        let BodyContent::Paragraph(reference) = &mut input.document.body.content[20] else {
10255            panic!("note reference belongs to a paragraph");
10256        };
10257        let mut marker = CT_R::new("");
10258        marker.content = vec![match stream {
10259            NoteStream::Footnote => RunContent::FootnoteRef { id: 1 },
10260            NoteStream::Endnote => RunContent::EndnoteRef { id: 1 },
10261        }];
10262        reference.runs.push(marker);
10263
10264        let note = |id, text: &str| {
10265            let mut paragraph = CT_P::new();
10266            paragraph.add_run(text);
10267            CT_Footnote {
10268                id,
10269                note_type: NoteType::Normal,
10270                paragraphs: vec![paragraph],
10271            }
10272        };
10273        let mut notes = vec![note(1, "first note text")];
10274        if include_second_note {
10275            notes.push(note(2, "second note text"));
10276        }
10277        let part = Some(CT_Footnotes { footnotes: notes });
10278        match stream {
10279            NoteStream::Footnote => input.footnotes = part,
10280            NoteStream::Endnote => input.endnotes = part,
10281        }
10282        input
10283    }
10284
10285    fn assert_note_reference_does_not_poison_later_hits(stream: NoteStream) {
10286        let mut input = note_reference_cache_input(stream, false);
10287        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10288        engine.layout(&input).expect("cold note layout succeeds");
10289        assert_eq!(engine.paragraph_cache_counts(), (0, 700));
10290
10291        set_body_paragraph_text(&mut input, 350, "note cache paragraph 350 changed");
10292        let warm = engine.layout(&input).expect("warm note layout succeeds");
10293        let fresh = Engine::new_deterministic()
10294            .expect("bundled fonts load")
10295            .layout(&input)
10296            .expect("fresh note layout succeeds");
10297
10298        assert_layout_results_equal(&warm, &fresh);
10299        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
10300    }
10301
10302    #[test]
10303    fn note_reference_does_not_poison_later_paragraph_cache_hits() {
10304        assert_note_reference_does_not_poison_later_hits(NoteStream::Footnote);
10305    }
10306
10307    #[test]
10308    fn endnote_reference_does_not_poison_later_paragraph_cache_hits() {
10309        assert_note_reference_does_not_poison_later_hits(NoteStream::Endnote);
10310    }
10311
10312    #[test]
10313    fn changed_note_reference_or_note_part_invalidates_required_cache_entry() {
10314        let mut input = note_reference_cache_input(NoteStream::Footnote, true);
10315        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10316        engine.layout(&input).expect("cold note layout succeeds");
10317        assert_eq!(engine.paragraph_cache_counts(), (0, 700));
10318
10319        let BodyContent::Paragraph(reference) = &mut input.document.body.content[20] else {
10320            panic!("note reference belongs to a paragraph");
10321        };
10322        reference.runs.last_mut().expect("marker run").content =
10323            vec![RunContent::FootnoteRef { id: 2 }];
10324        let warm_reference = engine
10325            .layout(&input)
10326            .expect("changed reference layout succeeds");
10327        let fresh_reference = Engine::new_deterministic()
10328            .expect("bundled fonts load")
10329            .layout(&input)
10330            .expect("fresh changed reference layout succeeds");
10331        assert_layout_results_equal(&warm_reference, &fresh_reference);
10332        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
10333
10334        input.footnotes.as_mut().expect("footnotes exist").footnotes[1].paragraphs[0].runs[0]
10335            .content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
10336            "second note text changed",
10337        ))];
10338        let warm_note = engine.layout(&input).expect("changed note layout succeeds");
10339        let fresh_note = Engine::new_deterministic()
10340            .expect("bundled fonts load")
10341            .layout(&input)
10342            .expect("fresh changed note layout succeeds");
10343        assert_layout_results_equal(&warm_note, &fresh_note);
10344        assert_eq!(engine.paragraph_cache_counts(), (699, 1_401));
10345    }
10346
10347    #[test]
10348    fn note_reference_warm_layout_equals_fresh_layout() {
10349        let mut input = related_story_restart_input(700);
10350        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10351        engine
10352            .layout(&input)
10353            .expect("cold related-story layout succeeds");
10354        set_body_paragraph_text(&mut input, 350, "related note paragraph changed");
10355
10356        let warm = engine
10357            .layout(&input)
10358            .expect("warm related-story layout succeeds");
10359        let fresh = Engine::new_deterministic()
10360            .expect("bundled fonts load")
10361            .layout(&input)
10362            .expect("fresh related-story layout succeeds");
10363        assert_layout_results_equal(&warm, &fresh);
10364        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
10365    }
10366
10367    fn mixed_editor_input() -> LayoutInput {
10368        let mut input = make_input_with_text("");
10369        input.document.body.content.clear();
10370        for index in 0..700 {
10371            let mut paragraph = CT_P::new();
10372            paragraph.add_run(&format!("편집 paragraph {index:03} stable line"));
10373            input.document.body.add_paragraph(paragraph);
10374            if index % 50 == 49 {
10375                input
10376                    .document
10377                    .body
10378                    .add_table(safe_table(&format!("table {:02}", index / 50)));
10379            }
10380        }
10381        input
10382    }
10383
10384    fn mixed_editor_paragraph_mut(input: &mut LayoutInput, target: usize) -> &mut CT_P {
10385        input
10386            .document
10387            .body
10388            .content
10389            .iter_mut()
10390            .filter_map(|content| match content {
10391                BodyContent::Paragraph(paragraph) => Some(paragraph),
10392                BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
10393                    None
10394                }
10395            })
10396            .nth(target)
10397            .expect("mixed editor paragraph exists")
10398    }
10399
10400    #[test]
10401    fn mixed_editor_relayout_reuses_every_safe_unchanged_block_and_page() {
10402        let mut input = mixed_editor_input();
10403        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10404        let initial = engine.layout(&input).expect("mixed cold layout");
10405        assert_eq!(engine.paragraph_cache_counts(), (0, 700));
10406        assert_eq!(engine.table_cache_counts(), (0, 14));
10407        assert_eq!(engine.hot_path_work_counts(), (0, 0));
10408
10409        mixed_editor_paragraph_mut(&mut input, 350).runs[0].content = vec![RunContent::Text(
10410            rdocx_oxml::text::CT_Text::new("편집 paragraph 350 changed line"),
10411        )];
10412        let warm = engine.layout(&input).expect("mixed warm layout");
10413        let fresh = Engine::new_deterministic()
10414            .expect("bundled fonts load")
10415            .layout(&input)
10416            .expect("mixed fresh layout");
10417        assert_layout_results_equal(&warm, &fresh);
10418        assert_eq!(engine.paragraph_cache_counts(), (699, 701));
10419        assert_eq!(engine.table_cache_counts(), (14, 14));
10420        assert_eq!(engine.hot_path_work_counts(), (0, 0));
10421        assert_eq!(engine.owned_context_build_count(), 1);
10422        let restart = engine.restart_cache.as_ref().unwrap_or_else(|| {
10423            panic!(
10424                "mixed restart retained for {} pages, candidate {} bytes",
10425                initial.pages.len(),
10426                engine.last_restart_candidate_bytes
10427            )
10428        });
10429        assert_restart_cache_within_aggregate(&engine);
10430        assert!(restart.checkpoints.len() <= RESTART_CACHE_MAX_ENTRIES);
10431        let rebuilt = engine
10432            .last_rebuilt_page_range
10433            .clone()
10434            .expect("mixed rebuilt range recorded");
10435        assert!(
10436            warm.pages
10437                .iter()
10438                .zip(&initial.pages)
10439                .take(rebuilt.start)
10440                .all(|(current, previous)| Arc::ptr_eq(current, previous))
10441        );
10442        assert!(
10443            warm.pages
10444                .iter()
10445                .zip(&initial.pages)
10446                .skip(rebuilt.end)
10447                .all(|(current, previous)| Arc::ptr_eq(current, previous))
10448        );
10449    }
10450
10451    #[test]
10452    fn mixed_editor_table_mutation_rebuilds_only_the_changed_table() {
10453        let mut input = mixed_editor_input();
10454        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10455        engine.layout(&input).expect("mixed cold layout");
10456        let BodyContent::Table(changed) = input
10457            .document
10458            .body
10459            .content
10460            .iter_mut()
10461            .filter(|content| matches!(content, BodyContent::Table(_)))
10462            .nth(7)
10463            .expect("eighth mixed table")
10464        else {
10465            panic!("mixed body item is a table");
10466        };
10467        changed.rows[0].cells[0].paragraphs_mut()[0].runs[0].content = vec![RunContent::Text(
10468            rdocx_oxml::text::CT_Text::new("changed table 07"),
10469        )];
10470
10471        let warm = engine.layout(&input).expect("mixed warm table layout");
10472        let fresh = Engine::new_deterministic()
10473            .expect("bundled fonts load")
10474            .layout(&input)
10475            .expect("mixed fresh table layout");
10476        assert_layout_results_equal(&warm, &fresh);
10477        assert_eq!(engine.paragraph_cache_counts(), (700, 700));
10478        assert_eq!(engine.table_cache_counts(), (13, 15));
10479        assert_eq!(engine.hot_path_work_counts(), (0, 0));
10480    }
10481
10482    #[test]
10483    fn unsafe_prefix_still_disables_later_paragraph_hits() {
10484        let mut field = CT_P::new();
10485        let mut field_run = CT_R::new("");
10486        field_run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
10487        field.runs.push(field_run);
10488
10489        let mut numbered = CT_P::new();
10490        numbered.add_run("numbered prefix");
10491        numbered.properties.get_or_insert_default().num_id = Some(1);
10492
10493        let mut drawing = CT_P::new();
10494        let mut drawing_run = CT_R::new("");
10495        drawing_run.content = vec![RunContent::Drawing(rdocx_oxml::drawing::CT_Drawing {
10496            inline: None,
10497            anchor: None,
10498        })];
10499        drawing.runs.push(drawing_run);
10500
10501        let mut raw = CT_P::new();
10502        raw.extra_xml.push((
10503            0,
10504            br#"<w:unknown xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>"#
10505                .to_vec(),
10506        ));
10507
10508        for (name, unsafe_paragraph) in [
10509            ("field", field),
10510            ("numbering", numbered),
10511            ("drawing", drawing),
10512            ("raw child", raw),
10513        ] {
10514            let mut input = make_input_with_text("safe cached suffix");
10515            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10516            engine.layout(&input).expect("prime safe suffix");
10517            input
10518                .document
10519                .body
10520                .content
10521                .insert(0, BodyContent::Paragraph(unsafe_paragraph));
10522
10523            let warm = engine.layout(&input).expect("warm unsafe-prefix layout");
10524            let cold = Engine::new_deterministic()
10525                .expect("bundled fonts load")
10526                .layout(&input)
10527                .expect("cold unsafe-prefix layout");
10528            assert_layout_results_equal(&warm, &cold);
10529            assert_eq!(engine.paragraph_cache_counts(), (0, 2), "{name}");
10530        }
10531    }
10532
10533    #[test]
10534    fn scaled_paragraph_cache_warm_equals_cold() {
10535        let mut input = make_input_with_text("warm-cold paragraph 000");
10536        for index in 1..700 {
10537            let mut paragraph = CT_P::new();
10538            paragraph.add_run(&format!("warm-cold paragraph {index:03}"));
10539            input.document.body.add_paragraph(paragraph);
10540        }
10541        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
10542        warm_engine
10543            .layout_with_provenance(&input)
10544            .expect("prime warm state");
10545        set_body_paragraph_text(&mut input, 349, "warm-cold paragraph 349 changed");
10546
10547        let warm = warm_engine
10548            .layout_with_provenance(&input)
10549            .expect("warm edited layout");
10550        let cold = Engine::new_deterministic()
10551            .expect("bundled fonts load")
10552            .layout_with_provenance(&input)
10553            .expect("cold edited layout");
10554        assert_layout_results_equal(&warm.0, &cold.0);
10555        assert_eq!(warm.1, cold.1);
10556        assert_eq!(format!("{:?}", warm.0), format!("{:?}", cold.0));
10557    }
10558
10559    #[test]
10560    fn warm_relayout_rebinds_font_tables_and_ids_to_the_current_result() {
10561        let mut input = make_input_with_text("font identity changes");
10562        {
10563            let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
10564                panic!("body paragraph");
10565            };
10566            paragraph.runs[0]
10567                .properties
10568                .get_or_insert_default()
10569                .font_ascii = Some("Carlito".to_owned());
10570        }
10571
10572        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
10573        warm_engine.layout(&input).expect("prime warm font state");
10574        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
10575            panic!("body paragraph");
10576        };
10577        paragraph.runs[0]
10578            .properties
10579            .get_or_insert_default()
10580            .font_ascii = Some("Caladea".to_owned());
10581
10582        let warm = warm_engine.layout(&input).expect("warm relayout succeeds");
10583        let cold = Engine::new_deterministic()
10584            .expect("bundled fonts load")
10585            .layout(&input)
10586            .expect("cold relayout succeeds");
10587        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
10588        assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", cold.fonts));
10589    }
10590
10591    #[test]
10592    fn warm_relayout_canonicalizes_the_same_fonts_in_new_resolution_order() {
10593        let mut input = make_input_with_text("first family");
10594        let BodyContent::Paragraph(first) = &mut input.document.body.content[0] else {
10595            panic!("body paragraph");
10596        };
10597        first.runs[0].properties.get_or_insert_default().font_ascii = Some("Carlito".to_owned());
10598        let mut second = CT_P::new();
10599        second
10600            .add_run("second family")
10601            .properties
10602            .get_or_insert_default()
10603            .font_ascii = Some("Caladea".to_owned());
10604        input.document.body.add_paragraph(second);
10605
10606        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
10607        warm_engine.layout(&input).expect("prime original order");
10608        input.document.body.content.swap(0, 1);
10609
10610        let warm = warm_engine.layout(&input).expect("warm reordered layout");
10611        let cold = Engine::new_deterministic()
10612            .expect("bundled fonts load")
10613            .layout(&input)
10614            .expect("cold reordered layout");
10615        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
10616        assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", cold.fonts));
10617    }
10618
10619    #[test]
10620    fn shared_layout_context_changes_cannot_serve_stale_blocks() {
10621        let mut input = make_input_with_text("context-sensitive cache identity");
10622        let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
10623        warm_engine.layout(&input).expect("prime context cache");
10624
10625        let normal = input
10626            .styles
10627            .styles
10628            .iter_mut()
10629            .find(|style| style.is_default)
10630            .expect("default style");
10631        normal.rpr.get_or_insert_default().font_ascii = Some("Caladea".to_owned());
10632        let warm = warm_engine.layout(&input).expect("warm style mutation");
10633        let cold = Engine::new_deterministic()
10634            .expect("bundled fonts load")
10635            .layout(&input)
10636            .expect("cold style mutation");
10637        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
10638
10639        input.numbering = Some(rdocx_oxml::numbering::CT_Numbering::new());
10640        let warm = warm_engine.layout(&input).expect("warm numbering mutation");
10641        let cold = Engine::new_deterministic()
10642            .expect("bundled fonts load")
10643            .layout(&input)
10644            .expect("cold numbering mutation");
10645        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
10646
10647        input.theme = Some(rdocx_oxml::theme::Theme::default());
10648        let warm = warm_engine.layout(&input).expect("warm theme mutation");
10649        let cold = Engine::new_deterministic()
10650            .expect("bundled fonts load")
10651            .layout(&input)
10652            .expect("cold theme mutation");
10653        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
10654
10655        input
10656            .hyperlink_urls
10657            .insert("rIdLink".to_owned(), "https://example.com".to_owned());
10658        input.images.insert(
10659            "rIdImage".to_owned(),
10660            crate::input::ImageData {
10661                data: vec![1, 2, 3],
10662                content_type: "image/png".to_owned(),
10663            },
10664        );
10665        let warm = warm_engine
10666            .layout(&input)
10667            .expect("warm relationship and image mutation");
10668        let cold = Engine::new_deterministic()
10669            .expect("bundled fonts load")
10670            .layout(&input)
10671            .expect("cold relationship and image mutation");
10672        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
10673
10674        input.fonts.push(oxml_layout::FontFile {
10675            family: "Embedded".to_owned(),
10676            data: oxml_layout::bundled_fonts::bundled_font_data()[0]
10677                .1
10678                .to_vec(),
10679        });
10680        let warm = warm_engine.layout(&input).expect("warm font mutation");
10681        let cold = Engine::new_deterministic()
10682            .expect("bundled fonts load")
10683            .layout(&input)
10684            .expect("cold font mutation");
10685        assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
10686
10687        let contextual = rdocx_oxml::CT_Document::from_xml(
10688            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>"#,
10689        )
10690        .expect("contextual paragraphs parse");
10691        for content in &contextual.body.content {
10692            let BodyContent::Paragraph(paragraph) = content else {
10693                continue;
10694            };
10695            assert!(!paragraph_is_cache_safe(paragraph, &input.styles));
10696        }
10697    }
10698
10699    #[test]
10700    fn compatible_engine_take_reuses_normal_layout_work() {
10701        let mut source_input = make_input_with_text("unchanged paragraph");
10702        let mut changed = CT_P::new();
10703        changed.add_run("old second paragraph");
10704        source_input.document.body.add_paragraph(changed);
10705
10706        let mut source_engine = Engine::new_deterministic().expect("bundled fonts load");
10707        source_engine
10708            .layout(&source_input)
10709            .expect("prime reusable engine");
10710        assert_eq!(source_engine.paragraph_cache_counts(), (0, 2));
10711
10712        let mut receiver_input = source_input.clone();
10713        let BodyContent::Paragraph(second) = &mut receiver_input.document.body.content[1] else {
10714            panic!("second body paragraph");
10715        };
10716        second.runs[0].content[0] =
10717            RunContent::Text(rdocx_oxml::text::CT_Text::new("new second paragraph"));
10718
10719        let mut source = Some(source_engine);
10720        let mut transferred = Engine::take_if_compatible(&mut source, &receiver_input)
10721            .expect("matching context transfers");
10722        assert!(source.is_none());
10723        transferred
10724            .layout(&receiver_input)
10725            .expect("transferred layout succeeds");
10726        assert_eq!(transferred.paragraph_cache_counts(), (1, 3));
10727    }
10728
10729    #[test]
10730    fn incompatible_or_failed_engine_take_preserves_the_source() {
10731        fn assert_rejected(label: &str, mut receiver: LayoutInput) {
10732            let source_input = make_input_with_text("retained paragraph");
10733            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10734            engine.layout(&source_input).expect("prime reusable engine");
10735            let mut source = Some(engine);
10736            assert!(
10737                Engine::take_if_compatible(&mut source, &receiver).is_none(),
10738                "{label} must reject transfer"
10739            );
10740            assert!(source.is_some());
10741            receiver.document.body.content.clear();
10742        }
10743
10744        let base = make_input_with_text("retained paragraph");
10745        let mut changed = base.clone();
10746        changed.revision_view = RevisionView::Tracked;
10747        assert_rejected("revision view", changed);
10748
10749        let wrapping = make_wrapping_document(
10750            WrapType::Square,
10751            Some(rdocx_oxml::drawing::AnchorAlignH::Left),
10752            100.0,
10753            40.0,
10754            5.0,
10755        );
10756        let mut changed = base.clone();
10757        changed.document = wrapping.document;
10758        assert_rejected("document wrapping state", changed);
10759
10760        let mut changed = base.clone();
10761        changed.styles = CT_Styles::new();
10762        assert_rejected("styles", changed);
10763
10764        let mut changed = base.clone();
10765        changed.numbering = Some(rdocx_oxml::numbering::CT_Numbering::new());
10766        assert_rejected("numbering", changed);
10767
10768        let mut changed = base.clone();
10769        changed.headers.insert(
10770            "rIdHeader".to_owned(),
10771            rdocx_oxml::header_footer::CT_HdrFtr::new(),
10772        );
10773        assert_rejected("headers", changed);
10774
10775        let mut changed = base.clone();
10776        changed.footers.insert(
10777            "rIdFooter".to_owned(),
10778            rdocx_oxml::header_footer::CT_HdrFtr::new(),
10779        );
10780        assert_rejected("footers", changed);
10781
10782        let mut changed = base.clone();
10783        changed.images.insert(
10784            "rIdImage".to_owned(),
10785            crate::input::ImageData {
10786                data: vec![1, 2, 3],
10787                content_type: "image/png".to_owned(),
10788            },
10789        );
10790        assert_rejected("images", changed);
10791
10792        let mut changed = base.clone();
10793        changed
10794            .charts
10795            .insert("rIdChart".to_owned(), Err("missing chart".to_owned()));
10796        assert_rejected("charts", changed);
10797
10798        let mut changed = base.clone();
10799        changed.chart_theme.name = Some("Changed".to_owned());
10800        assert_rejected("chart theme", changed);
10801
10802        let mut changed = base.clone();
10803        changed.core_properties = Some(rdocx_oxml::core_properties::CoreProperties {
10804            title: Some("Changed".to_owned()),
10805            ..Default::default()
10806        });
10807        assert_rejected("core properties", changed);
10808
10809        let mut changed = base.clone();
10810        changed
10811            .hyperlink_urls
10812            .insert("rIdLink".to_owned(), "https://example.com".to_owned());
10813        assert_rejected("hyperlinks", changed);
10814
10815        let mut changed = base.clone();
10816        changed.footnotes = Some(rdocx_oxml::footnotes::CT_Footnotes::new());
10817        assert_rejected("footnotes", changed);
10818
10819        let mut changed = base.clone();
10820        changed.endnotes = Some(rdocx_oxml::footnotes::CT_Footnotes::new());
10821        assert_rejected("endnotes", changed);
10822
10823        let mut changed = base.clone();
10824        changed.theme = Some(rdocx_oxml::theme::Theme::default());
10825        assert_rejected("theme", changed);
10826
10827        let mut changed = base.clone();
10828        changed.fonts.push(oxml_layout::FontFile {
10829            family: "Changed".to_owned(),
10830            data: vec![1, 2, 3],
10831        });
10832        assert_rejected("fonts", changed);
10833
10834        let mut changed = base;
10835        changed
10836            .document
10837            .body
10838            .sect_pr
10839            .get_or_insert_with(CT_SectPr::default_letter)
10840            .page_width = Some(rdocx_oxml::units::Twips(10_000));
10841        assert_rejected("sections", changed);
10842    }
10843
10844    #[test]
10845    fn alternate_content_drawings_bypass_paragraph_reuse() {
10846        let document = rdocx_oxml::CT_Document::from_xml(
10847            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>"#,
10848        )
10849        .expect("AlternateContent drawing parses");
10850        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
10851            panic!("body paragraph");
10852        };
10853        assert!(!paragraph.runs[1].alt_drawings.is_empty());
10854        assert!(!paragraph_is_cache_safe(
10855            paragraph,
10856            &CT_Styles::new_default()
10857        ));
10858    }
10859
10860    #[test]
10861    fn warm_provenance_rebinds_to_current_word_source_nodes() {
10862        let mut input = make_input_with_text("first paragraph");
10863        for text in ["second paragraph", "third paragraph"] {
10864            let mut paragraph = CT_P::new();
10865            paragraph.add_run(text);
10866            input.document.body.add_paragraph(paragraph);
10867        }
10868        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10869        engine
10870            .layout_with_provenance(&input)
10871            .expect("prime paragraph cache");
10872
10873        let moved = input.document.body.content.remove(2);
10874        input.document.body.content.insert(0, moved);
10875        let mut inserted = CT_P::new();
10876        inserted.add_run("new paragraph");
10877        input
10878            .document
10879            .body
10880            .content
10881            .insert(1, BodyContent::Paragraph(inserted));
10882        let (layout, sources) = engine
10883            .layout_with_provenance(&input)
10884            .expect("warm provenance layout");
10885
10886        for page in &layout.pages {
10887            oxml_layout::walk(&page.elements, &mut |element, _| {
10888                let PositionedElement::Text(run) = element else {
10889                    return;
10890                };
10891                let Some(span) = run.source else {
10892                    return;
10893                };
10894                let path = &sources[span.node.get() as usize - 1];
10895                assert_eq!(path.story, WordStory::Document);
10896                let BodyContent::Paragraph(paragraph) =
10897                    &input.document.body.content[path.children[0]]
10898                else {
10899                    panic!("source path resolves to a body paragraph");
10900                };
10901                let text = paragraph.text();
10902                let resolved = text
10903                    .chars()
10904                    .skip(span.char_start as usize)
10905                    .take((span.char_end - span.char_start) as usize)
10906                    .collect::<String>();
10907                assert_eq!(resolved, run.text);
10908            });
10909        }
10910        assert_eq!(engine.paragraph_cache_counts(), (3, 4));
10911    }
10912
10913    #[test]
10914    fn cached_heading_keeps_result_local_provenance() {
10915        fn heading_path(layout: &LayoutResult, sources: &[WordSourcePath]) -> Vec<usize> {
10916            layout
10917                .pages
10918                .iter()
10919                .flat_map(|page| compatibility_page_elements(page))
10920                .find_map(|element| match element {
10921                    PositionedElement::Text(run) if run.text.contains("cached") => run
10922                        .source
10923                        .map(|source| sources[source.node.get() as usize - 1].children.clone()),
10924                    _ => None,
10925                })
10926                .expect("cached heading keeps a source path")
10927        }
10928
10929        let mut input = make_input_with_text("ordinary first paragraph");
10930        let mut heading = CT_P::new();
10931        heading.properties = Some(CT_PPr {
10932            style_id: Some("Heading1".to_owned()),
10933            ..CT_PPr::default()
10934        });
10935        heading.add_run("cached heading");
10936        input.document.body.add_paragraph(heading);
10937
10938        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10939        let cold = engine
10940            .layout_with_provenance(&input)
10941            .expect("cold heading layout");
10942        assert_eq!(heading_path(&cold.0, &cold.1), vec![1]);
10943
10944        let mut inserted = CT_P::new();
10945        inserted.add_run("inserted before heading");
10946        input
10947            .document
10948            .body
10949            .content
10950            .insert(0, BodyContent::Paragraph(inserted));
10951        let warm = engine
10952            .layout_with_provenance(&input)
10953            .expect("warm heading layout");
10954        let fresh = Engine::new_deterministic()
10955            .expect("bundled fonts load")
10956            .layout_with_provenance(&input)
10957            .expect("fresh heading layout");
10958
10959        assert_layout_results_equal(&warm.0, &fresh.0);
10960        assert_eq!(warm.1, fresh.1);
10961        assert_eq!(heading_path(&warm.0, &warm.1), vec![2]);
10962        assert_eq!(engine.paragraph_cache_counts(), (2, 3));
10963    }
10964
10965    #[test]
10966    fn complex_heading_rebinds_rich_line_and_reflow_sources() {
10967        let mut input = make_input_with_text("العنوان المخزن");
10968        if let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] {
10969            paragraph.properties = Some(CT_PPr {
10970                style_id: Some("Heading1".to_owned()),
10971                ..CT_PPr::default()
10972            });
10973        } else {
10974            panic!("expected paragraph")
10975        }
10976        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
10977            unreachable!("paragraph checked above")
10978        };
10979        let media = MediaRegistry::new(&input.images);
10980        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
10981        let mut numbering = NumberingState::new();
10982        let mut diagnostics = Vec::new();
10983        let mut block = layout_paragraph_with_source(
10984            paragraph,
10985            468.0,
10986            &input.styles,
10987            &input,
10988            &media,
10989            &mut fonts,
10990            &mut numbering,
10991            &mut diagnostics,
10992            Some(CACHE_SOURCE_NODE),
10993        )
10994        .expect("complex heading lays out");
10995
10996        let rebound = SourceNodeId::new(2).expect("source ID");
10997        rebind_paragraph_source(&mut block, Some(rebound)).expect("source rebinding succeeds");
10998        let line_sources = block
10999            .lines
11000            .iter()
11001            .flat_map(|line| &line.items)
11002            .filter_map(|item| match item {
11003                LineItem::MultilingualText(segment) => Some(segment.base().source),
11004                _ => None,
11005            })
11006            .collect::<Vec<_>>();
11007        assert!(!line_sources.is_empty());
11008        assert!(
11009            line_sources
11010                .iter()
11011                .all(|source| source.is_some_and(|source| source.node == rebound))
11012        );
11013        assert!(block
11014            .reflow
11015            .as_ref()
11016            .expect("heading retains reflow inputs")
11017            .items
11018            .iter()
11019            .all(|item| {
11020                !matches!(item, InlineItem::MultilingualText(segment) if !segment.base().source.is_some_and(|source| source.node == rebound))
11021            }));
11022    }
11023
11024    #[test]
11025    fn cached_header_rebinds_hyphenated_reflow_sources() {
11026        let input = hyphenation_input(true, Some("en-US"), false);
11027        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
11028            panic!("expected paragraph")
11029        };
11030        let media = MediaRegistry::new(&input.images);
11031        let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
11032        let mut numbering = NumberingState::new();
11033        let mut diagnostics = Vec::new();
11034        let mut block = layout_paragraph_with_source(
11035            paragraph,
11036            468.0,
11037            &input.styles,
11038            &input,
11039            &media,
11040            &mut fonts,
11041            &mut numbering,
11042            &mut diagnostics,
11043            Some(CACHE_SOURCE_NODE),
11044        )
11045        .expect("hyphenated header paragraph lays out");
11046        assert!(
11047            block
11048                .reflow
11049                .as_ref()
11050                .expect("header retains reflow inputs")
11051                .items
11052                .iter()
11053                .any(|item| matches!(item, InlineItem::HyphenatedText { .. }))
11054        );
11055
11056        let rebound = SourceNodeId::new(74).expect("header source ID");
11057        rebind_paragraph_source(&mut block, Some(rebound)).expect("source rebinding succeeds");
11058        assert!(
11059            block
11060                .reflow
11061                .as_ref()
11062                .expect("header retains reflow inputs")
11063                .items
11064                .iter()
11065                .all(|item| {
11066                    !matches!(
11067                        item,
11068                        InlineItem::HyphenatedText { segment, .. }
11069                            if !segment.source.is_some_and(|source| source.node == rebound)
11070                    )
11071                })
11072        );
11073    }
11074
11075    #[test]
11076    fn overflowed_table_font_trace_keeps_result_local_provenance() {
11077        let mut input = make_input_with_text("before overflow table");
11078        let mut table = safe_table("");
11079        let paragraph = &mut table.rows[0].cells[0].paragraphs_mut()[0];
11080        paragraph.runs.clear();
11081        for _ in 0..4_100 {
11082            paragraph.runs.push(CT_R::new("x"));
11083        }
11084        input.document.body.add_table(table);
11085
11086        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11087        let (layout, sources) = engine
11088            .layout_with_provenance(&input)
11089            .expect("overflowed table trace still lays out");
11090
11091        assert!(engine.table_cache.is_empty());
11092        let source = layout
11093            .pages
11094            .iter()
11095            .flat_map(|page| compatibility_page_elements(page))
11096            .find_map(|element| match element {
11097                PositionedElement::Text(run) if run.text.contains('x') => run.source,
11098                _ => None,
11099            })
11100            .expect("table glyph keeps provenance after trace overflow");
11101        assert_eq!(
11102            sources[source.node.get() as usize - 1].children,
11103            vec![1, 0, 0, 0]
11104        );
11105    }
11106
11107    #[test]
11108    fn restart_body_accounting_charges_cache_safe_property_payloads() {
11109        let mut paragraph = CT_P::new();
11110        paragraph.properties = Some(CT_PPr {
11111            style_id: Some("p".repeat(LEGACY_RESTART_CACHE_MAX_BYTES + 1)),
11112            ..CT_PPr::default()
11113        });
11114        let paragraph_entry = RestartBodyEntry::for_content(
11115            &BodyContent::Paragraph(paragraph),
11116            RevisionView::Accepted,
11117        )
11118        .expect("paragraph has restart identity");
11119        assert!(paragraph_entry.bytes() > LEGACY_RESTART_CACHE_MAX_BYTES);
11120
11121        let mut table = safe_table("property accounting");
11122        table.properties = Some(rdocx_oxml::table::CT_TblPr {
11123            style_id: Some("t".repeat(4_096)),
11124            ..rdocx_oxml::table::CT_TblPr::default()
11125        });
11126        table.rows[0].properties = Some(rdocx_oxml::table::CT_TrPr {
11127            height: Some(rdocx_oxml::units::Twips(1)),
11128            height_rule: Some("r".repeat(4_096)),
11129            ..rdocx_oxml::table::CT_TrPr::default()
11130        });
11131        table.rows[0].cells[0].properties = Some(rdocx_oxml::table::CT_TcPr {
11132            text_direction: Some("c".repeat(4_096)),
11133            ..rdocx_oxml::table::CT_TcPr::default()
11134        });
11135        let table_entry =
11136            RestartBodyEntry::for_content(&BodyContent::Table(table), RevisionView::Accepted)
11137                .expect("table has restart identity");
11138        assert!(table_entry.bytes() >= 3 * 4_096);
11139    }
11140
11141    #[test]
11142    fn restart_body_identity_is_exact_for_all_run_language_state() {
11143        let mut paragraph = CT_P::new();
11144        let mut run = CT_R::new("representation");
11145        run.properties = Some(CT_RPr {
11146            language: Some("en-US".to_owned()),
11147            language_east_asia: Some("ja-JP".to_owned()),
11148            language_bidi: Some("ar-SA".to_owned()),
11149            language_extra_attributes: vec![("data".to_owned(), "one".to_owned())],
11150            ..CT_RPr::default()
11151        });
11152        paragraph.runs.push(run);
11153        let retained = RestartBodyEntry::for_content(
11154            &BodyContent::Paragraph(paragraph.clone()),
11155            RevisionView::Accepted,
11156        )
11157        .expect("paragraph has restart identity");
11158
11159        let mut changed = Vec::new();
11160        for field in 0..4 {
11161            let mut candidate = paragraph.clone();
11162            let properties = candidate.runs[0]
11163                .properties
11164                .as_mut()
11165                .expect("run properties exist");
11166            match field {
11167                0 => properties.language = Some("en-GB".to_owned()),
11168                1 => properties.language_east_asia = Some("zh-CN".to_owned()),
11169                2 => properties.language_bidi = Some("he-IL".to_owned()),
11170                3 => properties.language_extra_attributes[0].1 = "two".to_owned(),
11171                _ => unreachable!(),
11172            }
11173            changed.push(candidate);
11174        }
11175
11176        for candidate in changed {
11177            assert_eq!(
11178                paragraph_fingerprint(&paragraph),
11179                paragraph_fingerprint(&candidate)
11180            );
11181            assert!(!retained.matches(&BodyContent::Paragraph(candidate)));
11182        }
11183    }
11184
11185    #[test]
11186    fn shared_cached_blocks_keep_result_local_semantics_exact() {
11187        fn has_semantic_mark(elements: &[PositionedElement]) -> bool {
11188            elements.iter().any(|element| match element {
11189                PositionedElement::MarkedContent {
11190                    structure: Some(_), ..
11191                } => true,
11192                PositionedElement::MarkedContent { children, .. } => has_semantic_mark(children),
11193                PositionedElement::Group(group) => has_semantic_mark(&group.children),
11194                _ => false,
11195            })
11196        }
11197
11198        let mut input = make_input_with_text("before shared table");
11199        input
11200            .document
11201            .body
11202            .add_table(safe_nested_table("outer cell", "nested cell"));
11203        let mut after = CT_P::new();
11204        after.add_run("after shared table");
11205        input.document.body.add_paragraph(after);
11206
11207        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11208        engine
11209            .layout_with_provenance(&input)
11210            .expect("prime shared cache payloads");
11211        assert_eq!(engine.last_shared_block_counts, (2, 1));
11212
11213        let mut inserted = CT_P::new();
11214        inserted.add_run("inserted before shared payloads");
11215        input
11216            .document
11217            .body
11218            .content
11219            .insert(0, BodyContent::Paragraph(inserted));
11220        let warm = engine
11221            .layout_with_provenance(&input)
11222            .expect("warm shared layout");
11223        let fresh = Engine::new_deterministic()
11224            .expect("bundled fonts load")
11225            .layout_with_provenance(&input)
11226            .expect("fresh shared comparison");
11227
11228        assert_layout_results_equal(&warm.0, &fresh.0);
11229        assert_eq!(warm.0.structure, fresh.0.structure);
11230        assert_eq!(warm.1, fresh.1);
11231        assert_eq!(format!("{:?}", warm.0), format!("{:?}", fresh.0));
11232        assert_eq!(engine.last_shared_block_counts, (3, 1));
11233        assert_eq!(engine.paragraph_cache_counts(), (2, 3));
11234        assert_eq!(engine.table_cache_counts(), (1, 1));
11235
11236        let sourced_runs = warm
11237            .0
11238            .pages
11239            .iter()
11240            .flat_map(|page| compatibility_page_elements(page))
11241            .filter_map(|element| match element {
11242                PositionedElement::Text(run) => Some((run.text.as_str(), run.source)),
11243                _ => None,
11244            })
11245            .collect::<Vec<_>>();
11246        for (text, expected_path) in [
11247            ("outer ", vec![2, 0, 0, 0]),
11248            ("nested ", vec![2, 0, 0, 1, 0, 0, 0]),
11249        ] {
11250            let source = sourced_runs
11251                .iter()
11252                .find_map(|(run_text, source)| (*run_text == text).then_some(*source).flatten())
11253                .unwrap_or_else(|| panic!("{text:?} keeps result-local provenance"));
11254            assert_eq!(
11255                warm.1[source.node.get() as usize - 1].children,
11256                expected_path
11257            );
11258        }
11259        assert!(
11260            warm.0
11261                .pages
11262                .iter()
11263                .any(|page| has_semantic_mark(&page.elements))
11264        );
11265    }
11266
11267    fn safe_table(text: &str) -> CT_Tbl {
11268        let mut table = CT_Tbl::new();
11269        let mut row = CT_Row::new();
11270        let mut cell = CT_Tc::new();
11271        cell.paragraphs_mut()[0].add_run(text);
11272        row.cells.push(cell);
11273        table.rows.push(row);
11274        table
11275    }
11276
11277    fn safe_nested_table(outer_text: &str, nested_text: &str) -> CT_Tbl {
11278        let mut table = safe_table(outer_text);
11279        table.rows[0].cells[0]
11280            .content
11281            .push(CellContent::Table(safe_table(nested_text)));
11282        table
11283    }
11284
11285    fn rtl_multi_leader_paragraph(parts: [&str; 4]) -> CT_P {
11286        use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
11287        use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
11288        use rdocx_oxml::units::Twips;
11289
11290        let mut paragraph = CT_P::new();
11291        paragraph.properties = Some(CT_PPr {
11292            bidi: Some(true),
11293            tabs: Some(CT_Tabs {
11294                tabs: vec![
11295                    CT_TabStop {
11296                        val: ST_TabJc::Left,
11297                        pos: Twips(1_200),
11298                        leader: None,
11299                        source_occurrence: None,
11300                    },
11301                    CT_TabStop {
11302                        val: ST_TabJc::Left,
11303                        pos: Twips(2_400),
11304                        leader: Some(ST_TabLeader::Dot),
11305                        source_occurrence: None,
11306                    },
11307                    CT_TabStop {
11308                        val: ST_TabJc::Left,
11309                        pos: Twips(3_600),
11310                        leader: Some(ST_TabLeader::Hyphen),
11311                        source_occurrence: None,
11312                    },
11313                ],
11314            }),
11315            ..Default::default()
11316        });
11317        let mut run = CT_R::new("");
11318        run.content = vec![
11319            RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[0])),
11320            RunContent::Tab,
11321            RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[1])),
11322            RunContent::Tab,
11323            RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[2])),
11324            RunContent::Tab,
11325            RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[3])),
11326        ];
11327        paragraph.runs = vec![run];
11328        paragraph
11329    }
11330
11331    #[test]
11332    fn cached_table_and_header_keep_resolved_rtl_for_logical_extraction() {
11333        let mut table = CT_Tbl::new();
11334        let mut row = CT_Row::new();
11335        let mut cell = CT_Tc::new();
11336        cell.content = vec![CellContent::Paragraph(rtl_multi_leader_paragraph([
11337            "TA", "TB", "TC", "TD",
11338        ]))];
11339        row.cells.push(cell);
11340        table.rows.push(row);
11341        let mut table_input = make_input_with_text("body");
11342        let media = MediaRegistry::new(&table_input.images);
11343        let mut direction_engine = Engine::new_deterministic().expect("bundled fonts load");
11344        let mut numbering = NumberingState::new();
11345        let mut diagnostics = Vec::new();
11346        let shared = direction_engine
11347            .layout_body_table(
11348                &table,
11349                468.0,
11350                &table_input.styles,
11351                &table_input,
11352                &media,
11353                &mut numbering,
11354                &mut diagnostics,
11355                None,
11356                &WordStory::Document,
11357                &[0],
11358            )
11359            .expect("real table container lays out");
11360        let SharedLayoutBlock::Table { semantics, .. } = shared else {
11361            panic!("cache-safe table uses the shared table container")
11362        };
11363        let CellBlockSemantics::Paragraph(table_paragraph) = &semantics.rows[0].cells[0].blocks[0]
11364        else {
11365            panic!("table paragraph semantics")
11366        };
11367        assert_eq!(
11368            table_paragraph.reflow_direction,
11369            TextDirection::RightToLeft,
11370            "the production table container retains its resolved paragraph base"
11371        );
11372        table_input
11373            .document
11374            .body
11375            .content
11376            .insert(0, BodyContent::Table(table));
11377        let mut table_engine = Engine::new_deterministic().expect("bundled fonts load");
11378        table_engine
11379            .layout_with_provenance(&table_input)
11380            .expect("cold table layout");
11381        let (table_warm, table_sources) = table_engine
11382            .layout_with_provenance(&table_input)
11383            .expect("warm table layout");
11384        assert_eq!(table_engine.table_cache_counts().1, 2);
11385
11386        let mut header_input = cacheable_header_footer_input(&"body line ".repeat(4_000));
11387        for header in header_input.headers.values_mut() {
11388            header.paragraphs = vec![rtl_multi_leader_paragraph(["HA", "HB", "HC", "HD"])];
11389        }
11390        let section = header_input
11391            .document
11392            .body
11393            .sect_pr
11394            .as_ref()
11395            .expect("header section")
11396            .clone();
11397        let media = MediaRegistry::new(&header_input.images);
11398        let mut direction_header_engine = Engine::new_deterministic().expect("bundled fonts load");
11399        let mut numbering = NumberingState::new();
11400        let mut diagnostics = Vec::new();
11401        let (_, semantics) = layout_header_footer(
11402            &mut direction_header_engine,
11403            &section,
11404            &header_input,
11405            &header_input.styles,
11406            &media,
11407            &mut numbering,
11408            &mut diagnostics,
11409            None,
11410        )
11411        .expect("real header cache container lays out")
11412        .expect("header content exists");
11413        assert_eq!(
11414            semantics.first_header_directions,
11415            [TextDirection::RightToLeft],
11416            "the production header cache container retains its resolved paragraph base"
11417        );
11418        let mut header_engine = Engine::new_deterministic().expect("bundled fonts load");
11419        header_engine
11420            .layout_with_provenance(&header_input)
11421            .expect("cold header layout");
11422        let (header_warm, header_sources) = header_engine
11423            .layout_with_provenance(&header_input)
11424            .expect("warm header layout");
11425        let header_counts = header_engine.header_footer_cache_counts();
11426        assert!(
11427            header_counts.0 > 0,
11428            "header cache is traversed: {header_counts:?}"
11429        );
11430
11431        let assert_line = |warm: &LayoutResult,
11432                           sources: &[WordSourcePath],
11433                           story: &WordStory,
11434                           children: &[usize],
11435                           parts: [&str; 4]| {
11436            let prefix = parts[0];
11437            let mut located = None;
11438            for (page_index, page) in warm.pages.iter().enumerate() {
11439                for element in compatibility_page_elements(page) {
11440                    let (text, source, y) = match element {
11441                        PositionedElement::Text(run) => {
11442                            (run.text.as_str(), run.source, run.origin.y)
11443                        }
11444                        PositionedElement::MultilingualText(run) => {
11445                            (run.logical_text.as_str(), run.source, run.origin.y)
11446                        }
11447                        _ => continue,
11448                    };
11449                    if text == prefix {
11450                        located = source.map(|source| (page_index, source.node, y));
11451                        break;
11452                    }
11453                }
11454                if located.is_some() {
11455                    break;
11456                }
11457            }
11458            let (page_index, node, line_y) = located.unwrap_or_else(|| {
11459                let text = warm
11460                    .pages
11461                    .iter()
11462                    .flat_map(|page| compatibility_page_elements(page))
11463                    .filter_map(|element| match element {
11464                        PositionedElement::Text(run) => Some(run.text.clone()),
11465                        PositionedElement::MultilingualText(run) => Some(run.logical_text.clone()),
11466                        _ => None,
11467                    })
11468                    .collect::<Vec<_>>();
11469                panic!("missing {prefix}: {text:?}")
11470            });
11471            let path = &sources[node.get() as usize - 1];
11472            assert_eq!(&path.story, story);
11473            assert_eq!(path.children, children);
11474            let runs = compatibility_page_elements(&warm.pages[page_index])
11475                .into_iter()
11476                .filter_map(|element| match element {
11477                    PositionedElement::Text(run) if (run.origin.y - line_y).abs() < 0.01 => {
11478                        Some((run.text.clone(), run.origin.x))
11479                    }
11480                    PositionedElement::MultilingualText(run)
11481                        if (run.origin.y - line_y).abs() < 0.01 =>
11482                    {
11483                        Some((run.logical_text.clone(), run.origin.x))
11484                    }
11485                    _ => None,
11486                })
11487                .filter(|(text, _)| {
11488                    parts.contains(&text.as_str())
11489                        || (!text.is_empty()
11490                            && text.chars().all(|character| matches!(character, '.' | '-')))
11491                })
11492                .collect::<Vec<_>>();
11493            let text = runs
11494                .iter()
11495                .map(|(text, _)| text.as_str())
11496                .collect::<String>();
11497            let positions = parts.map(|part| {
11498                text.find(part)
11499                    .unwrap_or_else(|| panic!("missing {part} in {text:?}"))
11500            });
11501            assert!(
11502                positions.windows(2).all(|pair| pair[0] < pair[1]),
11503                "logical extraction for {story:?}: {runs:?}"
11504            );
11505            let dots = text.find('.').expect("dot leader");
11506            let dashes = text.find('-').expect("hyphen leader");
11507            assert!(
11508                positions[1] < dots
11509                    && dots < positions[2]
11510                    && positions[2] < dashes
11511                    && dashes < positions[3],
11512                "leaders keep their logical tabs for {story:?}: {runs:?}"
11513            );
11514            assert!(
11515                runs.iter().find(|run| run.0 == parts[0]).unwrap().1
11516                    > runs.iter().find(|run| run.0 == parts[3]).unwrap().1,
11517                "RTL visual origins survive for {story:?}: {runs:?}"
11518            );
11519        };
11520
11521        assert_line(
11522            &table_warm,
11523            &table_sources,
11524            &WordStory::Document,
11525            &[0, 0, 0, 0],
11526            ["TA", "TB", "TC", "TD"],
11527        );
11528        assert_line(
11529            &header_warm,
11530            &header_sources,
11531            &WordStory::Header {
11532                relationship_id: "rId-first-header".to_owned(),
11533            },
11534            &[0],
11535            ["HA", "HB", "HC", "HD"],
11536        );
11537    }
11538
11539    fn assert_layout_results_equal(left: &LayoutResult, right: &LayoutResult) {
11540        assert_eq!(left.pages.len(), right.pages.len());
11541        for (left, right) in left.pages.iter().zip(&right.pages) {
11542            assert_eq!(left.page_number, right.page_number);
11543            assert_eq!(left.width, right.width);
11544            assert_eq!(left.height, right.height);
11545            assert_eq!(left.elements, right.elements);
11546            assert_eq!(left.background, right.background);
11547        }
11548        assert_eq!(left.fonts.len(), right.fonts.len());
11549        for (left, right) in left.fonts.iter().zip(&right.fonts) {
11550            assert_eq!(left.id, right.id);
11551            assert_eq!(left.family, right.family);
11552            assert_eq!(left.data, right.data);
11553            assert_eq!(left.face_index, right.face_index);
11554            assert_eq!(left.bold, right.bold);
11555            assert_eq!(left.italic, right.italic);
11556        }
11557        match (&left.metadata, &right.metadata) {
11558            (Some(left), Some(right)) => {
11559                assert_eq!(left.title, right.title);
11560                assert_eq!(left.author, right.author);
11561                assert_eq!(left.subject, right.subject);
11562                assert_eq!(left.keywords, right.keywords);
11563                assert_eq!(left.creator, right.creator);
11564            }
11565            (None, None) => {}
11566            _ => panic!("layout metadata presence differs"),
11567        }
11568        assert_eq!(left.diagnostics, right.diagnostics);
11569        assert_eq!(left.outlines.len(), right.outlines.len());
11570        for (left, right) in left.outlines.iter().zip(&right.outlines) {
11571            assert_eq!(left.title, right.title);
11572            assert_eq!(left.level, right.level);
11573            assert_eq!(left.page_index, right.page_index);
11574            assert_eq!(left.y_position, right.y_position);
11575        }
11576        assert_eq!(left.structure, right.structure);
11577        assert_eq!(format!("{left:#?}"), format!("{right:#?}"));
11578    }
11579
11580    #[test]
11581    fn earlier_note_insertion_invalidates_later_cached_markers() {
11582        let mut input = make_input_with_text("safe prefix");
11583        let mut later = CT_P::new();
11584        later.add_run("safe suffix");
11585        input.document.body.add_paragraph(later);
11586        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11587        let original = engine.layout(&input).expect("initial layout");
11588
11589        let mut note_paragraph = CT_P::new();
11590        let mut note_run = CT_R::new("");
11591        note_run.content = vec![RunContent::FootnoteRef { id: 7 }];
11592        note_paragraph.runs.push(note_run);
11593        input
11594            .document
11595            .body
11596            .content
11597            .insert(1, BodyContent::Paragraph(note_paragraph));
11598        let warm_insert = engine.layout(&input).expect("warm insertion layout");
11599        let cold_insert = Engine::new_deterministic()
11600            .expect("bundled fonts load")
11601            .layout(&input)
11602            .expect("cold insertion layout");
11603        assert_layout_results_equal(&warm_insert, &cold_insert);
11604        assert_eq!(engine.paragraph_cache_counts(), (2, 3));
11605
11606        input.document.body.content.remove(1);
11607        let warm_delete = engine.layout(&input).expect("warm deletion layout");
11608        let cold_delete = Engine::new_deterministic()
11609            .expect("bundled fonts load")
11610            .layout(&input)
11611            .expect("cold deletion layout");
11612        assert_layout_results_equal(&warm_delete, &cold_delete);
11613        assert_layout_results_equal(&original, &warm_delete);
11614        assert_eq!(engine.paragraph_cache_counts(), (4, 3));
11615    }
11616
11617    #[test]
11618    fn dense_form_caches_are_transactional_bounded_and_exact() {
11619        let mut input = make_input_with_text("before table");
11620        input
11621            .document
11622            .body
11623            .content
11624            .push(BodyContent::Table(safe_nested_table(
11625                "cached outer cell",
11626                "cached nested cell",
11627            )));
11628        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11629        let cold = engine.layout(&input).expect("cold table layout");
11630        let warm = engine.layout(&input).expect("warm table layout");
11631        assert_layout_results_equal(&cold, &warm);
11632        assert_eq!(engine.table_cache_counts(), (1, 1));
11633
11634        let mut provenance_input = input.clone();
11635        let mut provenance_engine = Engine::new_deterministic().expect("bundled fonts load");
11636        provenance_engine
11637            .layout_with_provenance(&provenance_input)
11638            .expect("prime sourced table cache");
11639        let mut inserted = CT_P::new();
11640        inserted.add_run("inserted before table");
11641        provenance_input
11642            .document
11643            .body
11644            .content
11645            .insert(1, BodyContent::Paragraph(inserted));
11646        let (provenance_layout, sources) = provenance_engine
11647            .layout_with_provenance(&provenance_input)
11648            .expect("warm sourced table layout");
11649        let sourced_runs = provenance_layout
11650            .pages
11651            .iter()
11652            .flat_map(|page| compatibility_page_elements(page))
11653            .filter_map(|element| match element {
11654                PositionedElement::Text(run) => Some((run.text.clone(), run.source)),
11655                _ => None,
11656            })
11657            .collect::<Vec<_>>();
11658        let cached_outer_cell = sourced_runs
11659            .iter()
11660            .find_map(|(text, source)| (text == "outer ").then_some(*source).flatten())
11661            .unwrap_or_else(|| panic!("cached outer cell keeps provenance: {sourced_runs:?}"));
11662        assert_eq!(
11663            sources[cached_outer_cell.node.get() as usize - 1].children,
11664            [2, 0, 0, 0]
11665        );
11666        let cached_nested_cell = sourced_runs
11667            .iter()
11668            .find_map(|(text, source)| (text == "nested ").then_some(*source).flatten())
11669            .unwrap_or_else(|| panic!("cached nested cell keeps provenance: {sourced_runs:?}"));
11670        assert_eq!(
11671            sources[cached_nested_cell.node.get() as usize - 1].children,
11672            [2, 0, 0, 1, 0, 0, 0]
11673        );
11674        assert_eq!(provenance_engine.table_cache_counts(), (1, 1));
11675
11676        let mut bounded = make_input_with_text("bounded prefix");
11677        for index in 0..(TABLE_CACHE_MAX_ENTRIES + 8) {
11678            bounded
11679                .document
11680                .body
11681                .content
11682                .push(BodyContent::Table(safe_table(&format!("table {index}"))));
11683        }
11684        let mut bounded_engine = Engine::new_deterministic().expect("bundled fonts load");
11685        bounded_engine
11686            .layout(&bounded)
11687            .expect("bounded table layout");
11688        assert!(bounded_engine.table_cache.len() <= TABLE_CACHE_MAX_ENTRIES);
11689        assert!(bounded_engine.table_cache_bytes <= TABLE_CACHE_MAX_BYTES);
11690        assert!(bounded_engine.pending_table_cache_peak_entries <= TABLE_CACHE_MAX_ENTRIES);
11691        assert!(bounded_engine.pending_table_cache_peak_bytes <= TABLE_CACHE_MAX_BYTES);
11692
11693        let mut retained_border_block = engine
11694            .table_cache
11695            .back()
11696            .expect("safe table retained")
11697            .block
11698            .as_ref()
11699            .clone();
11700        let mut color = String::with_capacity(TABLE_CACHE_MAX_BYTES + 1);
11701        color.push_str("00");
11702        let mut edge =
11703            rdocx_oxml::borders::CT_BorderEdge::new(rdocx_oxml::shared::ST_Border::Single);
11704        edge.color = Some(color);
11705        let table::CellBlock::Table(nested_block) =
11706            &mut retained_border_block.rows[0].cells[0].blocks[1]
11707        else {
11708            panic!("cached form retains the nested table block");
11709        };
11710        nested_block.borders = Some(rdocx_oxml::table::CT_TblBorders {
11711            top: Some(edge),
11712            ..Default::default()
11713        });
11714        assert!(table_block_retained_bytes(&retained_border_block) > TABLE_CACHE_MAX_BYTES);
11715
11716        let mut unsafe_table = safe_table("numbered cell");
11717        unsafe_table.rows[0].cells[0].paragraphs_mut()[0]
11718            .properties
11719            .get_or_insert_default()
11720            .num_id = Some(1);
11721        assert!(!table_is_cache_safe(&unsafe_table, &input.styles));
11722
11723        let mut preserved_table = safe_table("preserved properties");
11724        preserved_table
11725            .properties
11726            .get_or_insert_default()
11727            .revision_xml
11728            .push(br#"<w:unknown/>"#.to_vec());
11729        assert!(!table_is_cache_safe(&preserved_table, &input.styles));
11730
11731        let mut preserved_cell = safe_table("preserved cell properties");
11732        preserved_cell.rows[0].cells[0]
11733            .properties
11734            .get_or_insert_default()
11735            .extra_xml
11736            .push((0, br#"<w:unknown/>"#.to_vec()));
11737        assert!(!table_is_cache_safe(&preserved_cell, &input.styles));
11738    }
11739
11740    fn restart_input() -> LayoutInput {
11741        let mut input = make_input_with_text("paragraph 000 stable line");
11742        for index in 1..140 {
11743            let mut paragraph = CT_P::new();
11744            paragraph.add_run(&format!("paragraph {index:03} stable line"));
11745            input.document.body.add_paragraph(paragraph);
11746        }
11747        input
11748    }
11749
11750    fn ordinary_prose_restart_input(paragraph_count: usize) -> LayoutInput {
11751        let mut input = make_input_with_text("");
11752        input.document.body.content.clear();
11753        for index in 0..paragraph_count {
11754            let mut paragraph = CT_P::new();
11755            if index == 10 {
11756                paragraph.properties = Some(CT_PPr {
11757                    style_id: Some("Heading1".to_owned()),
11758                    ..CT_PPr::default()
11759                });
11760            } else if index == 11 {
11761                paragraph.properties.get_or_insert_default().keep_next = Some(true);
11762            } else if index == 13 {
11763                paragraph.properties.get_or_insert_default().keep_lines = Some(true);
11764            }
11765            let text = if index == 20 {
11766                "ordinary multiline paragraph wraps without splitting across pages ".repeat(8)
11767            } else {
11768                format!("ordinary prose paragraph {index:03} stable line")
11769            };
11770            paragraph.add_run(&text);
11771            input.document.body.add_paragraph(paragraph);
11772        }
11773        input
11774    }
11775
11776    fn page_spanning_prose_paragraph(index: usize) -> CT_P {
11777        let mut paragraph = CT_P::new();
11778        paragraph.add_run(&format!(
11779            "Paragraph {index}: the quick brown fox jumps over the lazy dog, pack my box \
11780             with five dozen liquor jugs, and a mixed sentence that keeps going. \
11781             Sphinx of black quartz, judge my vow across line breaks and pages. \
11782             Waltz, bad nymph, for quick jigs vex. Glib jocks quiz nymph to vex dwarf. \
11783             Bright vixens jump, dozy fowl quack."
11784        ));
11785        paragraph
11786    }
11787
11788    fn page_spanning_prose_restart_input(paragraph_count: usize) -> LayoutInput {
11789        let mut input = make_input_with_text("");
11790        input.document.body.content = (0..paragraph_count)
11791            .map(|index| BodyContent::Paragraph(page_spanning_prose_paragraph(index)))
11792            .collect();
11793        input.core_properties = Some(rdocx_oxml::core_properties::CoreProperties {
11794            title: Some("Issue 67 page-spanning prose".to_owned()),
11795            creator: Some("rdocx-layout regression".to_owned()),
11796            subject: Some("restart pagination".to_owned()),
11797            keywords: Some("page-spanning,provenance".to_owned()),
11798            ..Default::default()
11799        });
11800        input
11801    }
11802
11803    fn change_page_spanning_paragraph(input: &mut LayoutInput, index: usize, revision: usize) {
11804        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[index] else {
11805            panic!("page-spanning body entry is a paragraph");
11806        };
11807        let RunContent::Text(text) = &mut paragraph.runs[0].content[0] else {
11808            panic!("page-spanning paragraph begins with text");
11809        };
11810        text.text.push_str(&format!(" edit{revision}"));
11811    }
11812
11813    #[test]
11814    fn page_spanning_prose_publishes_complete_boundary_restart_records() {
11815        let input = page_spanning_prose_restart_input(175);
11816        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11817        let result = engine.layout(&input).expect("page-spanning prose layout");
11818
11819        assert_eq!(result.pages.len(), 16, "Issue 67 source page count");
11820        assert_eq!(engine.paragraph_cache.len(), 175);
11821        assert!(
11822            engine
11823                .paragraph_cache
11824                .iter()
11825                .all(|entry| entry.block.lines.len() == 4),
11826            "every Issue 67 source paragraph must wrap to exactly four lines"
11827        );
11828        assert_eq!(
11829            engine.page_layout_invocation_count(),
11830            result.pages.len(),
11831            "the completed recorded pass must be the published pass"
11832        );
11833        let retained = engine
11834            .restart_cache
11835            .as_ref()
11836            .expect("page-spanning prose must retain restart state");
11837        let boundaries = retained
11838            .checkpoints
11839            .iter()
11840            .map(|checkpoint| (checkpoint.next_block_index, checkpoint.page_count))
11841            .collect::<Vec<_>>();
11842        assert_eq!(
11843            boundaries,
11844            [
11845                (0, 0),
11846                (23, 2),
11847                (46, 4),
11848                (69, 6),
11849                (92, 8),
11850                (115, 10),
11851                (138, 12),
11852                (161, 14),
11853            ],
11854            "the first page ends inside paragraph 11, so its first eligible complete boundary is block 23 after page 2"
11855        );
11856    }
11857
11858    #[test]
11859    fn page_spanning_prose_restarts_warm_edits_exactly() {
11860        let mut input = page_spanning_prose_restart_input(175);
11861        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11862        engine
11863            .layout_with_provenance(&input)
11864            .expect("prime sourced page-spanning prose");
11865
11866        for revision in 0..10 {
11867            let index = 80 + revision;
11868            change_page_spanning_paragraph(&mut input, index, revision);
11869            let before = engine.paragraph_cache_counts();
11870            let (warm, warm_sources) = engine
11871                .layout_with_provenance(&input)
11872                .expect("warm sourced middle edit");
11873            let after = engine.paragraph_cache_counts();
11874            assert_eq!(after.0 - before.0, 174, "warm hits for edit {revision}");
11875            assert_eq!(after.1 - before.1, 1, "warm build for edit {revision}");
11876            assert!(
11877                engine.page_layout_invocation_count() <= 2,
11878                "edit {revision} repaginated {} pages",
11879                engine.page_layout_invocation_count()
11880            );
11881            let rebuilt = engine
11882                .last_rebuilt_page_range
11883                .clone()
11884                .expect("warm edit reports its rebuilt range");
11885            assert!(
11886                rebuilt.end.saturating_sub(rebuilt.start) <= 2,
11887                "edit {revision}: {rebuilt:?}"
11888            );
11889            let (fresh, fresh_sources) = Engine::new_deterministic()
11890                .expect("bundled fonts load")
11891                .layout_with_provenance(&input)
11892                .expect("fresh sourced middle edit");
11893            assert_layout_results_equal(&warm, &fresh);
11894            assert_eq!(warm_sources, fresh_sources);
11895        }
11896    }
11897
11898    #[test]
11899    fn page_spanning_prose_edit_matrix_matches_fresh_layout() {
11900        let original_input = page_spanning_prose_restart_input(175);
11901        let mut input = original_input.clone();
11902        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11903        let original = engine.layout(&input).expect("prime page-spanning prose");
11904
11905        change_page_spanning_paragraph(&mut input, 170, 1);
11906        let warm_edit = engine.layout(&input).expect("warm late edit");
11907        let fresh_edit = Engine::new_deterministic()
11908            .expect("bundled fonts load")
11909            .layout(&input)
11910            .expect("fresh late edit");
11911        assert_layout_results_equal(&warm_edit, &fresh_edit);
11912        assert!(engine.page_layout_invocation_count() <= 2);
11913
11914        input.document.body.content.insert(
11915            160,
11916            BodyContent::Paragraph(page_spanning_prose_paragraph(999)),
11917        );
11918        let warm_insert = engine.layout(&input).expect("warm insertion");
11919        let fresh_insert = Engine::new_deterministic()
11920            .expect("bundled fonts load")
11921            .layout(&input)
11922            .expect("fresh insertion");
11923        assert_layout_results_equal(&warm_insert, &fresh_insert);
11924
11925        input.document.body.content.remove(160);
11926        let warm_delete = engine.layout(&input).expect("warm deletion");
11927        let fresh_delete = Engine::new_deterministic()
11928            .expect("bundled fonts load")
11929            .layout(&input)
11930            .expect("fresh deletion");
11931        assert_layout_results_equal(&warm_delete, &fresh_delete);
11932
11933        input = original_input;
11934        let warm_undo = engine.layout(&input).expect("warm undo");
11935        let fresh_undo = Engine::new_deterministic()
11936            .expect("bundled fonts load")
11937            .layout(&input)
11938            .expect("fresh undo");
11939        assert_layout_results_equal(&warm_undo, &fresh_undo);
11940        assert_layout_results_equal(&warm_undo, &original);
11941    }
11942
11943    #[test]
11944    fn page_spanning_note_and_page_footer_restart_only_at_clean_boundaries() {
11945        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
11946        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
11947
11948        let mut input = page_spanning_prose_restart_input(175);
11949        let BodyContent::Paragraph(split) = &mut input.document.body.content[11] else {
11950            panic!("split body entry is a paragraph");
11951        };
11952        let mut marker = CT_R::new("");
11953        marker.content = vec![RunContent::FootnoteRef { id: 1 }];
11954        split.runs.push(marker);
11955        let mut note = CT_P::new();
11956        note.add_run("page-spanning footnote");
11957        input.footnotes = Some(CT_Footnotes {
11958            footnotes: vec![CT_Footnote {
11959                id: 1,
11960                note_type: NoteType::Normal,
11961                paragraphs: vec![note],
11962            }],
11963        });
11964        let section = input
11965            .document
11966            .body
11967            .sect_pr
11968            .get_or_insert_with(CT_SectPr::default_letter);
11969        section.footer_refs.push(HdrFtrRef {
11970            hdr_ftr_type: HdrFtrType::Default,
11971            rel_id: "rIdPageFooter".to_owned(),
11972        });
11973        let mut footer = CT_HdrFtr::new();
11974        let mut footer_paragraph = CT_P::new();
11975        let mut page = CT_R::new("");
11976        page.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
11977        footer_paragraph.runs.push(page);
11978        footer.paragraphs.push(footer_paragraph);
11979        input.footers.insert("rIdPageFooter".to_owned(), footer);
11980
11981        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11982        let initial = engine.layout(&input).expect("prime note and footer layout");
11983        let retained = engine
11984            .restart_cache
11985            .as_ref()
11986            .expect("clean complete boundaries retain restart state");
11987        assert!(page_text(&initial.pages[0]).contains("Paragraph  11:"));
11988        assert!(
11989            page_text(&initial.pages[1]).starts_with("Waltz"),
11990            "page 2 must begin with paragraph 11's split continuation"
11991        );
11992        let first_complete = retained
11993            .checkpoints
11994            .iter()
11995            .find(|checkpoint| checkpoint.page_count > 0)
11996            .expect("a clean boundary follows the split paragraph");
11997        assert!(
11998            first_complete.page_count > 1 && first_complete.next_block_index > 11,
11999            "no boundary may be retained inside the split note-bearing paragraph"
12000        );
12001        for page in &initial.pages {
12002            let displayed = compatibility_page_elements(page)
12003                .into_iter()
12004                .find_map(|element| match element {
12005                    PositionedElement::Text(run)
12006                        if matches!(run.field_kind, Some(FieldKind::Page)) =>
12007                    {
12008                        Some(run.text.as_str())
12009                    }
12010                    _ => None,
12011                })
12012                .expect("each page has a displayed PAGE footer");
12013            assert_eq!(displayed, page.page_number.to_string());
12014        }
12015
12016        change_page_spanning_paragraph(&mut input, 80, 1);
12017        let warm = engine.layout(&input).expect("warm note and footer edit");
12018        let fresh = Engine::new_deterministic()
12019            .expect("bundled fonts load")
12020            .layout(&input)
12021            .expect("fresh note and footer edit");
12022        assert_layout_results_equal(&warm, &fresh);
12023        assert!(engine.page_layout_invocation_count() <= 2);
12024    }
12025
12026    #[test]
12027    fn ordinary_multiline_heading_and_keep_paragraphs_publish_restart_records() {
12028        let input = ordinary_prose_restart_input(140);
12029        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12030        let result = engine
12031            .layout(&input)
12032            .expect("ordinary prose layout succeeds");
12033
12034        let multiline = engine
12035            .paragraph_cache
12036            .iter()
12037            .find(|entry| entry.key.paragraph.text().starts_with("ordinary multiline"))
12038            .expect("multiline paragraph is cached");
12039        assert!(multiline.block.lines.len() > 2);
12040        assert_eq!(result.outlines.len(), 1);
12041        assert_eq!(result.outlines[0].level, 1);
12042        assert!(
12043            engine
12044                .restart_cache
12045                .as_ref()
12046                .is_some_and(|cache| !cache.checkpoints.is_empty()),
12047            "complete ordinary-prose block boundaries must publish restart checkpoints"
12048        );
12049    }
12050
12051    #[test]
12052    fn restart_candidate_uses_available_aggregate_cache_budget() {
12053        let mut input = make_input_with_text("aggregate candidate");
12054        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
12055            panic!("body entry is a paragraph");
12056        };
12057        paragraph.properties = Some(CT_PPr {
12058            style_id: Some("x".repeat(LEGACY_RESTART_CACHE_MAX_BYTES + 64 * 1024)),
12059            ..CT_PPr::default()
12060        });
12061
12062        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12063        engine
12064            .layout(&input)
12065            .expect("large candidate layout succeeds");
12066        assert!(engine.last_restart_candidate_bytes > LEGACY_RESTART_CACHE_MAX_BYTES);
12067        assert!(
12068            engine
12069                .paragraph_cache_bytes
12070                .checked_add(engine.table_cache_bytes)
12071                .and_then(|bytes| bytes.checked_add(engine.header_footer_cache_bytes))
12072                .and_then(|bytes| bytes.checked_add(engine.last_restart_candidate_bytes))
12073                .is_some_and(|bytes| bytes <= CACHE_MAX_BYTES)
12074        );
12075        assert!(
12076            engine.restart_cache.is_some(),
12077            "candidate above 8 MiB must use available aggregate capacity"
12078        );
12079    }
12080
12081    #[test]
12082    fn restart_candidate_over_aggregate_budget_fails_closed() {
12083        for occupied in [CACHE_MAX_BYTES, usize::MAX] {
12084            let input = restart_input();
12085            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12086            let original = engine.layout(&input).expect("prime restart state");
12087            engine.paragraph_cache_bytes = occupied;
12088
12089            let warm = engine.layout(&input).expect("pressured layout succeeds");
12090            let fresh = Engine::new_deterministic()
12091                .expect("bundled fonts load")
12092                .layout(&input)
12093                .expect("fresh pressured layout succeeds");
12094            assert_layout_results_equal(&warm, &fresh);
12095            assert_layout_results_equal(&warm, &original);
12096            assert!(
12097                engine.restart_cache.is_none(),
12098                "aggregate pressure {occupied} must reject the candidate"
12099            );
12100        }
12101    }
12102
12103    #[test]
12104    fn ordinary_prose_late_edit_insert_delete_and_undo_match_fresh_layout() {
12105        let mut input = ordinary_prose_restart_input(700);
12106        let original_input = input.clone();
12107        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12108        let original = engine.layout(&input).expect("prime ordinary prose state");
12109        assert!(engine.restart_cache.is_some());
12110
12111        set_body_paragraph_text(&mut input, 650, "ordinary prose paragraph 650 changed");
12112        let warm_edit = engine.layout(&input).expect("warm late edit");
12113        assert!(
12114            engine.page_layout_invocation_count() <= 2,
12115            "edit recomputed {} pages",
12116            engine.page_layout_invocation_count()
12117        );
12118        let fresh_edit = Engine::new_deterministic()
12119            .expect("bundled fonts load")
12120            .layout(&input)
12121            .expect("fresh late edit");
12122        assert_layout_results_equal(&warm_edit, &fresh_edit);
12123
12124        let mut inserted = CT_P::new();
12125        inserted.add_run("ordinary inserted paragraph");
12126        input
12127            .document
12128            .body
12129            .content
12130            .insert(640, BodyContent::Paragraph(inserted));
12131        let warm_insert = engine.layout(&input).expect("warm insertion");
12132        assert!(
12133            engine.page_layout_invocation_count() <= 3,
12134            "insertion recomputed {} pages",
12135            engine.page_layout_invocation_count()
12136        );
12137        let fresh_insert = Engine::new_deterministic()
12138            .expect("bundled fonts load")
12139            .layout(&input)
12140            .expect("fresh insertion");
12141        assert_layout_results_equal(&warm_insert, &fresh_insert);
12142
12143        input.document.body.content.remove(640);
12144        let warm_delete = engine.layout(&input).expect("warm deletion");
12145        assert!(
12146            engine.page_layout_invocation_count() <= 3,
12147            "deletion recomputed {} pages",
12148            engine.page_layout_invocation_count()
12149        );
12150        let fresh_delete = Engine::new_deterministic()
12151            .expect("bundled fonts load")
12152            .layout(&input)
12153            .expect("fresh deletion");
12154        assert_layout_results_equal(&warm_delete, &fresh_delete);
12155
12156        input = original_input;
12157        let warm_undo = engine.layout(&input).expect("warm undo");
12158        assert!(
12159            engine.page_layout_invocation_count() <= 3,
12160            "undo recomputed {} pages",
12161            engine.page_layout_invocation_count()
12162        );
12163        let fresh_undo = Engine::new_deterministic()
12164            .expect("bundled fonts load")
12165            .layout(&input)
12166            .expect("fresh undo");
12167        assert_layout_results_equal(&warm_undo, &fresh_undo);
12168        assert_layout_results_equal(&warm_undo, &original);
12169    }
12170
12171    #[test]
12172    fn ordinary_prose_restart_bounds_recomputed_page_work() {
12173        let mut input = ordinary_prose_restart_input(700);
12174        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12175        let initial = engine.layout(&input).expect("prime ordinary prose state");
12176        assert!(initial.pages.len() > 2);
12177        assert!(engine.restart_cache.is_some());
12178
12179        set_body_paragraph_text(&mut input, 650, "ordinary prose paragraph 650 changed");
12180        let warm = engine.layout(&input).expect("warm late edit");
12181        assert!(engine.page_layout_invocation_count() <= 2);
12182        let fresh = Engine::new_deterministic()
12183            .expect("bundled fonts load")
12184            .layout(&input)
12185            .expect("fresh late edit");
12186        assert_layout_results_equal(&warm, &fresh);
12187    }
12188
12189    #[test]
12190    fn unrepresented_restart_content_remains_rejected() {
12191        let assert_no_checkpoints = |label: &str, input: LayoutInput| {
12192            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12193            engine
12194                .layout(&input)
12195                .unwrap_or_else(|error| panic!("{label}: {error}"));
12196            assert!(
12197                engine
12198                    .restart_cache
12199                    .as_ref()
12200                    .is_none_or(|cache| cache.checkpoints.is_empty()),
12201                "{label}"
12202            );
12203        };
12204
12205        let mut numbered = restart_input();
12206        let BodyContent::Paragraph(paragraph) = &mut numbered.document.body.content[20] else {
12207            panic!("numbered body entry is a paragraph");
12208        };
12209        paragraph.properties.get_or_insert_default().num_id = Some(1);
12210        assert_no_checkpoints("numbering", numbered);
12211
12212        for instruction in ["PAGE", "DATE", "REF missing", "UNSUPPORTED"] {
12213            let mut field = restart_input();
12214            let BodyContent::Paragraph(paragraph) = &mut field.document.body.content[20] else {
12215                panic!("field body entry is a paragraph");
12216            };
12217            let mut run = CT_R::new("");
12218            run.content = vec![RunContent::Field(Field::new(instruction, "cached"))];
12219            paragraph.runs.push(run);
12220            assert_no_checkpoints(instruction, field);
12221        }
12222
12223        let mut drawing = restart_input();
12224        let BodyContent::Paragraph(paragraph) = &mut drawing.document.body.content[20] else {
12225            panic!("drawing body entry is a paragraph");
12226        };
12227        let mut run = CT_R::new("");
12228        run.content = vec![RunContent::Drawing(rdocx_oxml::drawing::CT_Drawing {
12229            inline: None,
12230            anchor: None,
12231        })];
12232        paragraph.runs.push(run);
12233        assert_no_checkpoints("drawing", drawing);
12234
12235        let mut raw = restart_input();
12236        let BodyContent::Paragraph(paragraph) = &mut raw.document.body.content[20] else {
12237            panic!("raw body entry is a paragraph");
12238        };
12239        paragraph.extra_xml.push((0, br#"<w:unknown/>"#.to_vec()));
12240        assert_no_checkpoints("raw child", raw);
12241
12242        let mut foreign_bookmark = restart_input();
12243        let BodyContent::Paragraph(paragraph) = &mut foreign_bookmark.document.body.content[20]
12244        else {
12245            panic!("bookmark body entry is a paragraph");
12246        };
12247        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
12248        assert!(paragraph.insert_bookmark_end(1, 7));
12249        paragraph.extra_xml[0].1 =
12250            br#"<ext:bookmarkStart xmlns:ext="urn:foreign" ext:id="7"/>"#.to_vec();
12251        assert_no_checkpoints("same-count foreign bookmark raw", foreign_bookmark);
12252
12253        for (label, duplicate_raw) in [
12254            (
12255                "duplicate expanded bookmark id",
12256                br#"<w:bookmarkStart xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:id="7" x:id="7" w:name="target"/>"#.as_slice(),
12257            ),
12258            (
12259                "duplicate expanded bookmark name",
12260                br#"<w:bookmarkStart xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:id="7" w:name="target" x:name="target"/>"#.as_slice(),
12261            ),
12262        ] {
12263            let mut duplicate_bookmark = restart_input();
12264            let BodyContent::Paragraph(paragraph) =
12265                &mut duplicate_bookmark.document.body.content[20]
12266            else {
12267                panic!("bookmark body entry is a paragraph");
12268            };
12269            assert!(paragraph.insert_bookmark_start(0, 7, "target"));
12270            assert!(paragraph.insert_bookmark_end(1, 7));
12271            paragraph.extra_xml[0].1 = duplicate_raw.to_vec();
12272            assert_no_checkpoints(label, duplicate_bookmark);
12273        }
12274
12275        let mut nested_bookmark = restart_input();
12276        let BodyContent::Paragraph(paragraph) = &mut nested_bookmark.document.body.content[20]
12277        else {
12278            panic!("bookmark body entry is a paragraph");
12279        };
12280        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
12281        assert!(paragraph.insert_bookmark_end(1, 7));
12282        paragraph.extra_xml[0].1 =
12283            br#"<w:bookmarkStart w:id="7" w:name="target"><w:unknown/></w:bookmarkStart>"#.to_vec();
12284        assert_no_checkpoints("non-empty bookmark root", nested_bookmark);
12285
12286        let mut trailing_bookmark = restart_input();
12287        let BodyContent::Paragraph(paragraph) = &mut trailing_bookmark.document.body.content[20]
12288        else {
12289            panic!("bookmark body entry is a paragraph");
12290        };
12291        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
12292        assert!(paragraph.insert_bookmark_end(1, 7));
12293        paragraph.extra_xml[0].1 =
12294            br#"<w:bookmarkStart w:id="7" w:name="target"/><w:unknown/>"#.to_vec();
12295        assert_no_checkpoints("trailing bookmark raw", trailing_bookmark);
12296
12297        let mut stale_raw_before = restart_input();
12298        let BodyContent::Paragraph(paragraph) = &mut stale_raw_before.document.body.content[20]
12299        else {
12300            panic!("bookmark body entry is a paragraph");
12301        };
12302        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
12303        assert!(paragraph.insert_bookmark_start(0, 7, "target"));
12304        paragraph.bookmark_markers.swap(0, 1);
12305        assert_no_checkpoints("stale bookmark raw order", stale_raw_before);
12306
12307        let aliased = CT_Document::from_xml(
12308            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>"#,
12309        )
12310        .expect("locally aliased bookmarks parse");
12311        let BodyContent::Paragraph(aliased) = &aliased.body.content[0] else {
12312            panic!("aliased bookmark body entry is a paragraph");
12313        };
12314        assert!(paragraph_bookmark_raw_is_exact(aliased));
12315
12316        let mut multilingual = restart_input();
12317        let BodyContent::Paragraph(paragraph) = &mut multilingual.document.body.content[20] else {
12318            panic!("multilingual body entry is a paragraph");
12319        };
12320        paragraph.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
12321            "다국어 상태",
12322        ))];
12323        paragraph.runs[0]
12324            .properties
12325            .get_or_insert_default()
12326            .language = Some("ko-KR".to_owned());
12327        assert_no_checkpoints("multilingual state", multilingual);
12328
12329        assert_no_checkpoints(
12330            "anchored empty paragraph",
12331            make_wrapping_document(WrapType::Square, None, 120.0, 60.0, 5.0),
12332        );
12333    }
12334
12335    fn related_story_restart_input(paragraph_count: usize) -> LayoutInput {
12336        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
12337        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
12338
12339        let mut input = make_input_with_text("paragraph 000 stable line");
12340        for index in 1..paragraph_count {
12341            let mut paragraph = CT_P::new();
12342            paragraph.add_run(&format!("paragraph {index:03} stable line"));
12343            input.document.body.add_paragraph(paragraph);
12344        }
12345
12346        for (index, content) in [
12347            (20, RunContent::FootnoteRef { id: 1 }),
12348            (40, RunContent::EndnoteRef { id: 2 }),
12349        ] {
12350            let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[index] else {
12351                panic!("related story reference belongs to a paragraph");
12352            };
12353            let mut run = CT_R::new("");
12354            run.content = vec![content];
12355            paragraph.runs.push(run);
12356        }
12357
12358        let mut footnote = CT_P::new();
12359        footnote.add_run("stable footnote text");
12360        input.footnotes = Some(CT_Footnotes {
12361            footnotes: vec![CT_Footnote {
12362                id: 1,
12363                note_type: NoteType::Normal,
12364                paragraphs: vec![footnote],
12365            }],
12366        });
12367        let mut endnote = CT_P::new();
12368        endnote.add_run("stable endnote text");
12369        input.endnotes = Some(CT_Footnotes {
12370            footnotes: vec![CT_Footnote {
12371                id: 2,
12372                note_type: NoteType::Normal,
12373                paragraphs: vec![endnote],
12374            }],
12375        });
12376
12377        let mut section = CT_SectPr::default_letter();
12378        section.header_refs.push(HdrFtrRef {
12379            hdr_ftr_type: HdrFtrType::Default,
12380            rel_id: "rIdHeader".to_owned(),
12381        });
12382        section.footer_refs.push(HdrFtrRef {
12383            hdr_ftr_type: HdrFtrType::Default,
12384            rel_id: "rIdFooter".to_owned(),
12385        });
12386        input.document.body.sect_pr = Some(section);
12387
12388        let mut header = CT_HdrFtr::new();
12389        let mut header_paragraph = CT_P::new();
12390        header_paragraph.add_run("stable header text");
12391        header.paragraphs.push(header_paragraph);
12392        input.headers.insert("rIdHeader".to_owned(), header);
12393
12394        let mut footer = CT_HdrFtr::new();
12395        let mut footer_paragraph = CT_P::new();
12396        let mut page_run = CT_R::new("");
12397        page_run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
12398        footer_paragraph.runs.push(page_run);
12399        footer.paragraphs.push(footer_paragraph);
12400        input.footers.insert("rIdFooter".to_owned(), footer);
12401        input
12402    }
12403
12404    #[test]
12405    fn unchanged_footnote_and_endnote_context_restarts_only_at_note_clean_boundaries() {
12406        let mut input = related_story_restart_input(700);
12407        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12408        let initial = engine.layout(&input).expect("initial related-story layout");
12409        let initial_pages = initial.pages.clone();
12410        assert!(
12411            engine.restart_cache.is_some(),
12412            "unchanged note context must permit a restart record, candidate {} bytes",
12413            engine.last_restart_candidate_bytes
12414        );
12415
12416        set_body_paragraph_text(&mut input, 350, "paragraph 350 changed line");
12417        let warm = engine.layout(&input).expect("warm related-story layout");
12418        let fresh = Engine::new_deterministic()
12419            .expect("bundled fonts load")
12420            .layout(&input)
12421            .expect("fresh related-story layout");
12422        assert_layout_results_equal(&warm, &fresh);
12423        assert!((1..=2).contains(&engine.page_layout_invocation_count()));
12424        assert!(
12425            warm.pages
12426                .iter()
12427                .zip(&initial_pages)
12428                .filter(|(current, retained)| Arc::ptr_eq(current, retained))
12429                .count()
12430                >= warm.pages.len().saturating_sub(2)
12431        );
12432        let rendered_text = warm
12433            .pages
12434            .iter()
12435            .flat_map(|page| compatibility_page_elements(page))
12436            .filter_map(|element| match element {
12437                PositionedElement::Text(run) => Some(run.text.as_str()),
12438                _ => None,
12439            })
12440            .collect::<String>();
12441        assert_eq!(
12442            rendered_text.matches("stable endnote text").count(),
12443            1,
12444            "endnote pages append exactly once: {rendered_text}"
12445        );
12446    }
12447
12448    #[test]
12449    fn restarted_body_completion_appends_prefix_and_suffix_endnotes_with_final_page_numbers() {
12450        use rdocx_oxml::footnotes::{CT_Footnote, NoteType};
12451
12452        let mut input = related_story_restart_input(700);
12453        let BodyContent::Paragraph(last) = &mut input.document.body.content[699] else {
12454            panic!("last body entry is a paragraph");
12455        };
12456        let mut marker = CT_R::new("");
12457        marker.content = vec![RunContent::EndnoteRef { id: 3 }];
12458        last.runs.push(marker);
12459        let mut suffix_endnote = CT_P::new();
12460        suffix_endnote.add_run("suffix endnote text");
12461        input
12462            .endnotes
12463            .as_mut()
12464            .expect("endnote stream")
12465            .footnotes
12466            .push(CT_Footnote {
12467                id: 3,
12468                note_type: NoteType::Normal,
12469                paragraphs: vec![suffix_endnote],
12470            });
12471
12472        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12473        let initial = engine.layout(&input).expect("initial endnote layout");
12474        set_body_paragraph_text(&mut input, 699, "paragraph 699 changed line");
12475        let warm = engine.layout(&input).expect("completed warm body layout");
12476        let fresh = Engine::new_deterministic()
12477            .expect("bundled fonts load")
12478            .layout(&input)
12479            .expect("fresh completed body layout");
12480        assert_layout_results_equal(&warm, &fresh);
12481        assert!((1..=2).contains(&engine.page_layout_invocation_count()));
12482        assert_eq!(
12483            warm.pages.last().map(|page| page.page_number),
12484            Some(warm.pages.len()),
12485            "the final endnote page keeps its document-wide page number"
12486        );
12487        assert!(
12488            !Arc::ptr_eq(
12489                warm.pages.last().expect("warm final endnote page"),
12490                initial.pages.last().expect("initial final endnote page")
12491            ),
12492            "completion must append endnotes instead of attaching the cached tail"
12493        );
12494        let rendered_text = warm
12495            .pages
12496            .iter()
12497            .flat_map(|page| compatibility_page_elements(page))
12498            .filter_map(|element| match element {
12499                PositionedElement::Text(run) => Some(run.text.as_str()),
12500                _ => None,
12501            })
12502            .collect::<String>();
12503        assert_eq!(rendered_text.matches("stable endnote text").count(), 1);
12504        assert_eq!(rendered_text.matches("suffix endnote text").count(), 1);
12505    }
12506
12507    #[test]
12508    fn unchanged_header_and_footer_context_keeps_restart_pagination_eligible() {
12509        let mut input = related_story_restart_input(700);
12510        input.footnotes = None;
12511        input.endnotes = None;
12512        for content in &mut input.document.body.content {
12513            let BodyContent::Paragraph(paragraph) = content else {
12514                continue;
12515            };
12516            for run in &mut paragraph.runs {
12517                run.content.retain(|content| {
12518                    !matches!(
12519                        content,
12520                        RunContent::FootnoteRef { .. } | RunContent::EndnoteRef { .. }
12521                    )
12522                });
12523            }
12524        }
12525        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12526        engine.layout(&input).expect("initial header-footer layout");
12527        assert!(
12528            engine.restart_cache.is_some(),
12529            "default headers and footers must permit a restart record, candidate {} bytes",
12530            engine.last_restart_candidate_bytes
12531        );
12532
12533        set_body_paragraph_text(&mut input, 350, "paragraph 350 changed line");
12534        let warm = engine.layout(&input).expect("warm header-footer layout");
12535        let fresh = Engine::new_deterministic()
12536            .expect("bundled fonts load")
12537            .layout(&input)
12538            .expect("fresh header-footer layout");
12539        assert_layout_results_equal(&warm, &fresh);
12540        assert!((1..=2).contains(&engine.page_layout_invocation_count()));
12541    }
12542
12543    #[test]
12544    fn changed_related_story_context_invalidates_restart_state() {
12545        fn assert_invalidated(label: &str, mutate: impl FnOnce(&mut LayoutInput)) {
12546            let mut input = related_story_restart_input(700);
12547            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12548            engine
12549                .layout(&input)
12550                .unwrap_or_else(|error| panic!("prime {label}: {error}"));
12551            assert!(
12552                engine.restart_cache.is_some(),
12553                "prime {label}, candidate {} bytes",
12554                engine.last_restart_candidate_bytes
12555            );
12556            mutate(&mut input);
12557            let warm = engine
12558                .layout(&input)
12559                .unwrap_or_else(|error| panic!("warm {label}: {error}"));
12560            let fresh = Engine::new_deterministic()
12561                .expect("bundled fonts load")
12562                .layout(&input)
12563                .unwrap_or_else(|error| panic!("fresh {label}: {error}"));
12564            assert_layout_results_equal(&warm, &fresh);
12565            assert!(
12566                engine.page_layout_invocation_count() > 2,
12567                "changed {label} must force full pagination"
12568            );
12569        }
12570
12571        assert_invalidated("footnote", |input| {
12572            set_body_paragraph_text_in_story(
12573                &mut input.footnotes.as_mut().expect("footnote stream").footnotes[0].paragraphs[0],
12574                "changed footnote text",
12575            );
12576        });
12577        assert_invalidated("endnote", |input| {
12578            set_body_paragraph_text_in_story(
12579                &mut input.endnotes.as_mut().expect("endnote stream").footnotes[0].paragraphs[0],
12580                "changed endnote text",
12581            );
12582        });
12583        assert_invalidated("header", |input| {
12584            set_body_paragraph_text_in_story(
12585                &mut input
12586                    .headers
12587                    .get_mut("rIdHeader")
12588                    .expect("header")
12589                    .paragraphs[0],
12590                "changed header text",
12591            );
12592        });
12593        assert_invalidated("footer", |input| {
12594            let footer = input.footers.get_mut("rIdFooter").expect("footer");
12595            footer.paragraphs[0].add_run("changed footer text");
12596        });
12597    }
12598
12599    fn set_body_paragraph_text_in_story(paragraph: &mut CT_P, text: &str) {
12600        paragraph.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text {
12601            text: text.to_owned(),
12602            preserve_space: false,
12603        })];
12604    }
12605
12606    #[test]
12607    fn a_footnote_continuation_never_creates_a_dirty_restart_boundary() {
12608        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
12609
12610        let mut input = make_input_with_text("body carrying a long note");
12611        let BodyContent::Paragraph(first) = &mut input.document.body.content[0] else {
12612            panic!("first body entry is a paragraph");
12613        };
12614        let mut marker = CT_R::new("");
12615        marker.content = vec![RunContent::FootnoteRef { id: 1 }];
12616        first.runs.push(marker);
12617        for index in 1..8 {
12618            let mut paragraph = CT_P::new();
12619            paragraph.properties = Some(CT_PPr {
12620                page_break_before: Some(true),
12621                ..Default::default()
12622            });
12623            paragraph.add_run(&format!("body page {index}"));
12624            input.document.body.add_paragraph(paragraph);
12625        }
12626        let mut long_note = CT_P::new();
12627        long_note.add_run(&"continuing footnote text ".repeat(2_000));
12628        input.footnotes = Some(CT_Footnotes {
12629            footnotes: vec![CT_Footnote {
12630                id: 1,
12631                note_type: NoteType::Normal,
12632                paragraphs: vec![long_note],
12633            }],
12634        });
12635
12636        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12637        let output = engine.layout(&input).expect("continued footnote layout");
12638        assert!(output.pages.len() > 2, "fixture must continue the footnote");
12639        let retained = engine
12640            .restart_cache
12641            .as_ref()
12642            .expect("continued notes retain only clean boundaries");
12643        assert!(
12644            retained
12645                .checkpoints
12646                .iter()
12647                .all(|checkpoint| checkpoint.next_block_index != 1),
12648            "the boundary carrying pending note state must not be retained"
12649        );
12650    }
12651
12652    fn set_body_paragraph_text(input: &mut LayoutInput, index: usize, text: &str) {
12653        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[index] else {
12654            panic!("body entry is a paragraph");
12655        };
12656        paragraph.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text {
12657            text: text.to_owned(),
12658            preserve_space: false,
12659        })];
12660    }
12661
12662    fn substituted_restart_input() -> LayoutInput {
12663        let mut input = restart_input();
12664        let BodyContent::Paragraph(fields) = &mut input.document.body.content[0] else {
12665            panic!("body entry is a paragraph");
12666        };
12667        for (instruction, display) in [
12668            ("PAGE", "page"),
12669            ("NUMPAGES", "pages"),
12670            ("PAGEREF destination", "target"),
12671        ] {
12672            let mut run = CT_R::new("");
12673            run.content = vec![RunContent::Field(Field::new(instruction, display))];
12674            fields.runs.push(run);
12675        }
12676        let BodyContent::Paragraph(target) = &mut input.document.body.content[100] else {
12677            panic!("body entry is a paragraph");
12678        };
12679        assert!(target.insert_bookmark_start(0, 46, "destination"));
12680        assert!(target.insert_bookmark_end(1, 46));
12681        input
12682    }
12683
12684    fn substituted_page_index(engine: &Engine) -> usize {
12685        engine
12686            .restart_cache
12687            .as_ref()
12688            .expect("restart record retained")
12689            .substitution_inputs
12690            .iter()
12691            .position(Option::is_some)
12692            .expect("field page retained")
12693    }
12694
12695    #[test]
12696    fn unchanged_page_fields_reuse_substituted_frames() {
12697        let input = substituted_restart_input();
12698        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12699        let first = engine.layout(&input).expect("initial field layout");
12700        let field_page = substituted_page_index(&engine);
12701        let retained = engine
12702            .restart_cache
12703            .as_ref()
12704            .expect("restart record retained");
12705        assert!(
12706            retained.checkpoints.is_empty(),
12707            "field pages remain excluded from pagination restart"
12708        );
12709        assert!(!Arc::ptr_eq(
12710            &retained.raw_pages[field_page],
12711            &retained.pages[field_page]
12712        ));
12713        assert!(
12714            retained
12715                .raw_pages
12716                .iter()
12717                .zip(&retained.pages)
12718                .zip(&retained.substitution_inputs)
12719                .filter(|(_, inputs)| inputs.is_none())
12720                .all(|((pristine, substituted), _)| Arc::ptr_eq(pristine, substituted))
12721        );
12722
12723        let warm = engine.layout(&input).expect("warm field layout");
12724        assert!(Arc::ptr_eq(
12725            &first.pages[field_page],
12726            &warm.pages[field_page]
12727        ));
12728    }
12729
12730    #[test]
12731    fn changed_substitution_context_reshapes_pages() {
12732        fn assert_retained_key_miss(
12733            label: &str,
12734            mutate: impl FnOnce(&mut FieldSubstitutionInputs),
12735        ) {
12736            let input = substituted_restart_input();
12737            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12738            let first = engine.layout(&input).expect("initial field layout");
12739            let field_page = substituted_page_index(&engine);
12740            let retained = engine
12741                .restart_cache
12742                .as_mut()
12743                .expect("restart record retained");
12744            mutate(
12745                retained.substitution_inputs[field_page]
12746                    .as_mut()
12747                    .expect("field inputs retained"),
12748            );
12749            let warm = engine.layout(&input).expect("warm field layout");
12750            let cold = Engine::new_deterministic()
12751                .expect("bundled fonts load")
12752                .layout(&input)
12753                .expect("cold field layout");
12754            assert_layout_results_equal(&warm, &cold);
12755            assert!(
12756                !Arc::ptr_eq(&first.pages[field_page], &warm.pages[field_page]),
12757                "{label}"
12758            );
12759        }
12760
12761        assert_retained_key_miss("page index must miss", |inputs| inputs.page_index += 1);
12762        assert_retained_key_miss("displayed page number must miss", |inputs| {
12763            inputs.page_number += 1;
12764        });
12765        assert_retained_key_miss("page count must miss", |inputs| inputs.total_pages += 1);
12766        assert_retained_key_miss("bookmark targets must miss", |inputs| {
12767            inputs.bookmark_pages.push((usize::MAX, usize::MAX));
12768        });
12769        assert_retained_key_miss("font identity must miss", |inputs| {
12770            inputs.font_identity.reverse();
12771            inputs.font_identity.push(FontId(u32::MAX));
12772        });
12773        assert_retained_key_miss("revision view must miss", |inputs| {
12774            inputs.revision_view = RevisionView::Tracked;
12775        });
12776
12777        let mut input = substituted_restart_input();
12778        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12779        let first = engine.layout(&input).expect("initial field layout");
12780        let field_page = substituted_page_index(&engine);
12781        set_body_paragraph_text(&mut input, 0, "changed pristine field page");
12782        let warm = engine.layout(&input).expect("changed pristine layout");
12783        let cold = Engine::new_deterministic()
12784            .expect("bundled fonts load")
12785            .layout(&input)
12786            .expect("cold changed pristine layout");
12787        assert_layout_results_equal(&warm, &cold);
12788        assert!(!Arc::ptr_eq(
12789            &first.pages[field_page],
12790            &warm.pages[field_page]
12791        ));
12792
12793        fn set_field_page_family(input: &mut LayoutInput, family: &str) {
12794            let BodyContent::Paragraph(fields) = &mut input.document.body.content[0] else {
12795                panic!("body entry is a paragraph");
12796            };
12797            for run in &mut fields.runs {
12798                run.properties = Some(rdocx_oxml::properties::CT_RPr {
12799                    font_ascii: Some(family.to_owned()),
12800                    font_hansi: Some(family.to_owned()),
12801                    ..Default::default()
12802                });
12803            }
12804        }
12805
12806        let mut input = substituted_restart_input();
12807        set_field_page_family(&mut input, "Caladea");
12808        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12809        engine.layout(&input).expect("first bundled-family layout");
12810        set_field_page_family(&mut input, "Carlito");
12811        let transitioned = engine.layout(&input).expect("font-transition field layout");
12812        let field_page = substituted_page_index(&engine);
12813        let field_free_page = engine
12814            .restart_cache
12815            .as_ref()
12816            .expect("restart record retained")
12817            .substitution_inputs
12818            .iter()
12819            .position(Option::is_none)
12820            .expect("field-free page retained");
12821        let warm = engine.layout(&input).expect("post-transition warm layout");
12822        let cold = Engine::new_deterministic()
12823            .expect("bundled fonts load")
12824            .layout(&input)
12825            .expect("post-transition cold layout");
12826        assert_layout_results_equal(&warm, &cold);
12827        assert!(Arc::ptr_eq(
12828            &transitioned.pages[field_page],
12829            &warm.pages[field_page]
12830        ));
12831        assert!(Arc::ptr_eq(
12832            &transitioned.pages[field_free_page],
12833            &warm.pages[field_free_page]
12834        ));
12835    }
12836
12837    #[test]
12838    fn substituted_page_reuse_is_bounded_and_complete_equal() {
12839        let input = substituted_restart_input();
12840        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12841        engine.layout(&input).expect("initial field layout");
12842        let warm = engine.layout(&input).expect("warm field layout");
12843        let cold = Engine::new_deterministic()
12844            .expect("bundled fonts load")
12845            .layout(&input)
12846            .expect("cold field layout");
12847        assert_layout_results_equal(&warm, &cold);
12848        let retained = engine
12849            .restart_cache
12850            .as_ref()
12851            .expect("restart record retained");
12852        assert!(
12853            retained.raw_pages.len().max(retained.checkpoints.len()) <= RESTART_CACHE_MAX_ENTRIES
12854        );
12855        assert_restart_cache_within_aggregate(&engine);
12856
12857        let mut bounded_input = make_input_with_text("");
12858        bounded_input.document.body.content.clear();
12859        for page in 0..1_024 {
12860            let mut paragraph = CT_P::new();
12861            paragraph.properties = Some(CT_PPr {
12862                page_break_before: (page > 0).then_some(true),
12863                ..Default::default()
12864            });
12865            let mut run = CT_R::new("");
12866            run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
12867            paragraph.runs.push(run);
12868            bounded_input.document.body.add_paragraph(paragraph);
12869        }
12870        let mut bounded = Engine::new_deterministic().expect("bundled fonts load");
12871        let result = bounded
12872            .layout(&bounded_input)
12873            .expect("bounded layout succeeds");
12874        assert_eq!(result.pages.len(), 1_024);
12875        bounded
12876            .restart_cache
12877            .as_ref()
12878            .expect("1,024 substituted page slots remain reusable");
12879        assert_restart_cache_within_aggregate(&bounded);
12880
12881        let mut oversized = bounded_input;
12882        let mut paragraph = CT_P::new();
12883        paragraph.properties = Some(CT_PPr {
12884            page_break_before: Some(true),
12885            ..Default::default()
12886        });
12887        let mut run = CT_R::new("");
12888        run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
12889        paragraph.runs.push(run);
12890        oversized.document.body.add_paragraph(paragraph);
12891        let result = bounded
12892            .layout(&oversized)
12893            .expect("oversized layout succeeds");
12894        assert_eq!(result.pages.len(), 1_025);
12895        assert!(
12896            bounded.restart_cache.is_none(),
12897            "an oversized pair set drops the optimization"
12898        );
12899
12900        fn over_limit<T>() -> usize {
12901            LEGACY_RESTART_CACHE_MAX_BYTES / std::mem::size_of::<T>() + 1
12902        }
12903        fn assert_capacity_rejected(label: &str, mutate: impl FnOnce(&mut RestartCache)) {
12904            let mut candidate = RestartCache {
12905                body: Vec::new(),
12906                with_provenance: false,
12907                raw_pages: Vec::new(),
12908                pages: Vec::new(),
12909                substitution_inputs: Vec::new(),
12910                outlines: Vec::new(),
12911                checkpoints: Vec::new(),
12912                font_trace: Vec::new(),
12913                bytes: 0,
12914            };
12915            mutate(&mut candidate);
12916            assert!(
12917                restart_cache_bytes(&candidate) > LEGACY_RESTART_CACHE_MAX_BYTES,
12918                "{label}"
12919            );
12920        }
12921
12922        assert_capacity_rejected("body vector capacity is charged", |cache| {
12923            cache.body = Vec::with_capacity(over_limit::<RestartBodyEntry>());
12924        });
12925        assert_capacity_rejected("pristine page vector capacity is charged", |cache| {
12926            cache.raw_pages = Vec::with_capacity(over_limit::<Arc<PageFrame>>());
12927        });
12928        assert_capacity_rejected("substituted page vector capacity is charged", |cache| {
12929            cache.pages = Vec::with_capacity(over_limit::<Arc<PageFrame>>());
12930        });
12931        assert_capacity_rejected("substitution vector capacity is charged", |cache| {
12932            cache.substitution_inputs =
12933                Vec::with_capacity(over_limit::<Option<FieldSubstitutionInputs>>());
12934        });
12935        assert_capacity_rejected("outline vector capacity is charged", |cache| {
12936            cache.outlines = Vec::with_capacity(over_limit::<oxml_layout::OutlineEntry>());
12937        });
12938        assert_capacity_rejected("checkpoint vector capacity is charged", |cache| {
12939            cache.checkpoints = Vec::with_capacity(over_limit::<paginator::PaginationCheckpoint>());
12940        });
12941        assert_capacity_rejected("font trace vector capacity is charged", |cache| {
12942            cache.font_trace = Vec::with_capacity(over_limit::<FontId>());
12943        });
12944        assert_capacity_rejected("body payload capacity is charged", |cache| {
12945            cache.body.push(RestartBodyEntry::Paragraph {
12946                fingerprint: 0,
12947                identity: Vec::new(),
12948                note_references: Vec::new(),
12949                bytes: LEGACY_RESTART_CACHE_MAX_BYTES + 1,
12950            });
12951        });
12952        assert_capacity_rejected("outline title capacity is charged", |cache| {
12953            cache.outlines.push(oxml_layout::OutlineEntry {
12954                title: String::with_capacity(LEGACY_RESTART_CACHE_MAX_BYTES + 1),
12955                level: 1,
12956                page_index: 0,
12957                y_position: 0.0,
12958            });
12959        });
12960        for nested in ["bookmark targets", "font identity"] {
12961            assert_capacity_rejected(&format!("{nested} capacity is charged"), |cache| {
12962                cache
12963                    .substitution_inputs
12964                    .push(Some(FieldSubstitutionInputs {
12965                        page_index: 0,
12966                        page_number: 1,
12967                        total_pages: 1,
12968                        bookmark_pages: if nested == "bookmark targets" {
12969                            Vec::with_capacity(over_limit::<(usize, usize)>())
12970                        } else {
12971                            Vec::new()
12972                        },
12973                        font_identity: if nested == "font identity" {
12974                            Vec::with_capacity(over_limit::<FontId>())
12975                        } else {
12976                            Vec::new()
12977                        },
12978                        revision_view: RevisionView::Accepted,
12979                    }));
12980            });
12981        }
12982    }
12983
12984    #[test]
12985    fn thousand_page_restart_records_at_most_two_page_layout_invocations() {
12986        let mut input = make_input_with_text("");
12987        input.document.body.content.clear();
12988        for page in 0..1_000 {
12989            let mut paragraph = CT_P::new();
12990            paragraph.properties = Some(CT_PPr {
12991                page_break_before: (page > 0).then_some(true),
12992                ..Default::default()
12993            });
12994            paragraph.add_run(&format!("Incremental page {}", page + 1));
12995            input.document.body.add_paragraph(paragraph);
12996        }
12997
12998        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12999        let initial = engine.layout(&input).expect("initial thousand-page layout");
13000        assert_eq!(initial.pages.len(), 1_000);
13001        assert_eq!(engine.page_layout_invocation_count(), 1_000);
13002        engine
13003            .restart_cache
13004            .as_ref()
13005            .expect("thousand-page restart record retained");
13006        let cache_counts = engine.paragraph_cache_counts();
13007
13008        set_body_paragraph_text(&mut input, 499, "Incremental page 500 changed");
13009        let warm = engine.layout(&input).expect("warm thousand-page layout");
13010        assert!((1..=2).contains(&engine.page_layout_invocation_count()));
13011        let fresh = Engine::new_deterministic()
13012            .expect("bundled fonts load")
13013            .layout(&input)
13014            .expect("fresh thousand-page layout");
13015        assert_layout_results_equal(&warm, &fresh);
13016        let rebuilt = engine
13017            .last_rebuilt_page_range
13018            .clone()
13019            .expect("rebuilt range recorded");
13020        assert!(
13021            rebuilt.end.saturating_sub(rebuilt.start) <= 2,
13022            "{rebuilt:?}"
13023        );
13024        let warm_cache_counts = engine.paragraph_cache_counts();
13025        assert_eq!(warm_cache_counts.0 - cache_counts.0, 999);
13026        assert_eq!(warm_cache_counts.1 - cache_counts.1, 1);
13027    }
13028
13029    #[test]
13030    fn warm_restart_rebuilds_only_the_bounded_changed_region() {
13031        let mut input = restart_input();
13032        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13033        let cold_initial = engine.layout(&input).expect("initial pagination");
13034        let initial_pages = cold_initial.pages.clone();
13035        let retained = engine
13036            .restart_cache
13037            .as_ref()
13038            .expect("restart state retained");
13039        assert!(retained.checkpoints.len() > 1);
13040        assert!(retained.checkpoints.len() <= RESTART_CACHE_MAX_ENTRIES);
13041        assert_restart_cache_within_aggregate(&engine);
13042
13043        set_body_paragraph_text(&mut input, 70, "paragraph 070 changed line");
13044        let warm = engine.layout(&input).expect("warm middle edit");
13045        let cold = Engine::new_deterministic()
13046            .expect("bundled fonts load")
13047            .layout(&input)
13048            .expect("cold middle edit");
13049        assert_layout_results_equal(&warm, &cold);
13050        let rebuilt = engine
13051            .last_rebuilt_page_range
13052            .clone()
13053            .expect("rebuilt range recorded");
13054        assert!(
13055            rebuilt.end.saturating_sub(rebuilt.start) <= 2,
13056            "{rebuilt:?}"
13057        );
13058        assert!(
13059            warm.pages
13060                .iter()
13061                .zip(&initial_pages)
13062                .take(rebuilt.start)
13063                .all(|(current, previous)| Arc::ptr_eq(current, previous))
13064        );
13065        assert!(
13066            warm.pages
13067                .iter()
13068                .zip(&initial_pages)
13069                .skip(rebuilt.end)
13070                .all(|(current, previous)| Arc::ptr_eq(current, previous))
13071        );
13072
13073        for (index, label) in [(0, "start"), (139, "tail")] {
13074            set_body_paragraph_text(
13075                &mut input,
13076                index,
13077                &format!("paragraph {index:03} {label:>7} line"),
13078            );
13079            let warm = engine.layout(&input).expect("warm boundary edit");
13080            let cold = Engine::new_deterministic()
13081                .expect("bundled fonts load")
13082                .layout(&input)
13083                .expect("cold boundary edit");
13084            assert_layout_results_equal(&warm, &cold);
13085        }
13086    }
13087
13088    #[test]
13089    fn unsafe_pagination_state_falls_back_to_full_layout() {
13090        let assert_fallback = |label: &str, input: LayoutInput| {
13091            let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13092            engine
13093                .layout(&input)
13094                .unwrap_or_else(|error| panic!("{label}: {error}"));
13095            assert!(engine.restart_cache.is_none(), "{label}");
13096        };
13097
13098        let mut table = make_input_with_text("before unsafe table");
13099        let mut unsafe_table = safe_table("table");
13100        unsafe_table.rows[0].cells[0].paragraphs_mut()[0]
13101            .properties
13102            .get_or_insert_default()
13103            .num_id = Some(1);
13104        table.document.body.add_table(unsafe_table);
13105        assert_fallback("traversal-sensitive table", table);
13106
13107        assert_fallback(
13108            "floating drawing",
13109            make_wrapping_document(WrapType::Square, None, 120.0, 60.0, 5.0),
13110        );
13111
13112        let note_source = make_input_with_footnote(&["note in table"]);
13113        let BodyContent::Paragraph(note_paragraph) = &note_source.document.body.content[0] else {
13114            panic!("note source is a paragraph");
13115        };
13116        let mut note_table_input = make_input_with_text("before note table");
13117        let mut note_table = safe_table("table text");
13118        note_table.rows[0].cells[0].paragraphs_mut()[0]
13119            .runs
13120            .push(note_paragraph.runs[1].clone());
13121        note_table_input.document.body.add_table(note_table);
13122        note_table_input.footnotes = note_source.footnotes;
13123        assert_fallback("note-bearing table", note_table_input);
13124
13125        let mut sections = restart_input();
13126        let BodyContent::Paragraph(first) = &mut sections.document.body.content[20] else {
13127            panic!("paragraph");
13128        };
13129        first.properties.get_or_insert_default().sect_pr = Some(CT_SectPr::default_letter());
13130        assert_fallback("multiple sections", sections);
13131
13132        let mut background = restart_input();
13133        background.document.background_xml = Some(
13134            br#"<w:background xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:color="FFFFFF"/>"#
13135                .to_vec(),
13136        );
13137        assert_fallback("page background", background);
13138
13139        let mut field = restart_input();
13140        let BodyContent::Paragraph(paragraph) = &mut field.document.body.content[20] else {
13141            panic!("paragraph");
13142        };
13143        let mut field_run = CT_R::new("");
13144        field_run.content = vec![RunContent::Field(rdocx_oxml::text::Field::new("PAGE", "1"))];
13145        paragraph.runs.push(field_run);
13146        let mut field_engine = Engine::new_deterministic().expect("bundled fonts load");
13147        field_engine.layout(&field).expect("field layout");
13148        let retained = field_engine
13149            .restart_cache
13150            .as_ref()
13151            .expect("field substitution pairs retained");
13152        assert!(
13153            retained.checkpoints.is_empty(),
13154            "fields must not become pagination restart boundaries"
13155        );
13156
13157        let mut boundary = restart_input();
13158        let mut boundary_engine = Engine::new_deterministic().expect("bundled fonts load");
13159        boundary_engine
13160            .layout(&boundary)
13161            .expect("prime boundary state");
13162        let stale = boundary_engine
13163            .restart_cache
13164            .as_mut()
13165            .and_then(|cache| {
13166                cache
13167                    .checkpoints
13168                    .iter_mut()
13169                    .find(|checkpoint| checkpoint.next_block_index > 80)
13170            })
13171            .expect("later checkpoint exists");
13172        stale.next_header_page_number += 1;
13173        set_body_paragraph_text(&mut boundary, 70, "changed before stale boundary");
13174        let warm = boundary_engine
13175            .layout(&boundary)
13176            .expect("warm boundary mismatch");
13177        let cold = Engine::new_deterministic()
13178            .expect("bundled fonts load")
13179            .layout(&boundary)
13180            .expect("cold boundary mismatch");
13181        assert_layout_results_equal(&warm, &cold);
13182        assert!(
13183            boundary_engine
13184                .restart_cache
13185                .as_ref()
13186                .expect("correct state republished")
13187                .checkpoints
13188                .iter()
13189                .all(|checkpoint| {
13190                    checkpoint.next_header_page_number == checkpoint.page_count + 1
13191                })
13192        );
13193    }
13194
13195    #[test]
13196    fn warm_and_cold_outputs_are_complete_equals() {
13197        let mut input = restart_input();
13198        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13199        engine.layout(&input).expect("prime restart state");
13200
13201        let mut inserted = CT_P::new();
13202        inserted.add_run("inserted stable line");
13203        input
13204            .document
13205            .body
13206            .content
13207            .insert(60, BodyContent::Paragraph(inserted));
13208        let warm_insert = engine.layout(&input).expect("warm insertion");
13209        let cold_insert = Engine::new_deterministic()
13210            .expect("bundled fonts load")
13211            .layout(&input)
13212            .expect("cold insertion");
13213        assert_layout_results_equal(&warm_insert, &cold_insert);
13214
13215        input.document.body.content.remove(60);
13216        let warm_delete = engine.layout(&input).expect("warm deletion");
13217        let cold_delete = Engine::new_deterministic()
13218            .expect("bundled fonts load")
13219            .layout(&input)
13220            .expect("cold deletion");
13221        assert_layout_results_equal(&warm_delete, &cold_delete);
13222
13223        let mut sourced_engine = Engine::new_deterministic().expect("bundled fonts load");
13224        let (original, original_sources) = sourced_engine
13225            .layout_with_provenance(&input)
13226            .expect("prime sourced restart state");
13227        input.document.body.content.insert(
13228            60,
13229            BodyContent::Paragraph({
13230                let mut paragraph = CT_P::new();
13231                paragraph.add_run("inserted sourced line");
13232                paragraph
13233            }),
13234        );
13235        let (warm_insert, warm_insert_sources) = sourced_engine
13236            .layout_with_provenance(&input)
13237            .expect("warm sourced insertion");
13238        let (cold_insert, cold_insert_sources) = Engine::new_deterministic()
13239            .expect("bundled fonts load")
13240            .layout_with_provenance(&input)
13241            .expect("cold sourced insertion");
13242        assert_layout_results_equal(&warm_insert, &cold_insert);
13243        assert_eq!(warm_insert_sources, cold_insert_sources);
13244
13245        input.document.body.content.remove(60);
13246        let (warm_delete, warm_delete_sources) = sourced_engine
13247            .layout_with_provenance(&input)
13248            .expect("warm sourced deletion");
13249        assert_layout_results_equal(&warm_delete, &original);
13250        assert_eq!(warm_delete_sources, original_sources);
13251
13252        let mut truncate_engine = Engine::new_deterministic().expect("bundled fonts load");
13253        truncate_engine
13254            .layout(&input)
13255            .expect("prime whole-suffix deletion state");
13256        let checkpoint = truncate_engine
13257            .restart_cache
13258            .as_ref()
13259            .and_then(|cache| cache.checkpoints.iter().next_back().copied())
13260            .expect("restart cache has a final safe boundary");
13261        input
13262            .document
13263            .body
13264            .content
13265            .truncate(checkpoint.next_block_index);
13266        let warm_truncate = truncate_engine
13267            .layout(&input)
13268            .expect("warm whole-suffix deletion");
13269        let cold_truncate = Engine::new_deterministic()
13270            .expect("bundled fonts load")
13271            .layout(&input)
13272            .expect("cold whole-suffix deletion");
13273        assert_layout_results_equal(&warm_truncate, &cold_truncate);
13274    }
13275
13276    #[test]
13277    fn paragraph_cache_failure_and_eviction_remain_bounded() {
13278        let mut input = make_input_with_text("");
13279        input.document = rdocx_oxml::CT_Document::from_xml(
13280            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>"#,
13281        )
13282        .expect("diagnostic document parses");
13283        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13284        let cold = engine.layout(&input).expect("cold layout succeeds");
13285        let warm = engine.layout(&input).expect("warm layout succeeds");
13286        assert!(!cold.diagnostics.is_empty());
13287        assert_eq!(cold.diagnostics, warm.diagnostics);
13288        assert_eq!(engine.paragraph_cache_counts(), (1, 1));
13289
13290        let (valid_family, valid_bytes) = oxml_layout::bundled_fonts::bundled_font_data()[0];
13291        let (invalid_family, invalid_source) = oxml_layout::bundled_fonts::bundled_font_data()[4];
13292        let mut invalid_bytes = invalid_source.to_vec();
13293        let table_count = u16::from_be_bytes([invalid_bytes[4], invalid_bytes[5]]) as usize;
13294        let head_offset = (0..table_count)
13295            .find_map(|table| {
13296                let record = 12 + table * 16;
13297                (&invalid_bytes[record..record + 4] == b"head").then(|| {
13298                    u32::from_be_bytes(
13299                        invalid_bytes[record + 8..record + 12]
13300                            .try_into()
13301                            .expect("head offset"),
13302                    ) as usize
13303                })
13304            })
13305            .expect("font has head table");
13306        invalid_bytes[head_offset + 18..head_offset + 20].copy_from_slice(&0u16.to_be_bytes());
13307
13308        let mut failing = Engine::with_font_manager(FontManager::new_with_fonts(vec![(
13309            valid_family.to_owned(),
13310            valid_bytes.to_vec(),
13311        )]));
13312        let mut failing_input = make_input_with_text("cache-safe successful prefix");
13313        let BodyContent::Paragraph(prefix) = &mut failing_input.document.body.content[0] else {
13314            panic!("prefix paragraph");
13315        };
13316        prefix.runs[0].properties.get_or_insert_default().font_ascii =
13317            Some(valid_family.to_owned());
13318        failing_input
13319            .document
13320            .body
13321            .content
13322            .push(BodyContent::Table(safe_nested_table(
13323                "staged outer before failure",
13324                "staged nested before failure",
13325            )));
13326        let mut later = CT_P::new();
13327        later
13328            .add_run("late font failure")
13329            .properties
13330            .get_or_insert_default()
13331            .font_ascii = Some(invalid_family.to_owned());
13332        failing_input.document.body.add_paragraph(later);
13333        failing_input.fonts.push(oxml_layout::FontFile {
13334            family: invalid_family.to_owned(),
13335            data: invalid_bytes,
13336        });
13337        assert!(failing.layout(&failing_input).is_err());
13338        assert!(failing.paragraph_cache.is_empty());
13339        assert!(failing.table_cache.is_empty());
13340        assert_eq!(failing.paragraph_cache_counts(), (0, 1));
13341        assert_eq!(failing.table_cache_counts(), (0, 1));
13342
13343        let template_input = make_input_with_text("eviction template");
13344        let mut bounded = Engine::new_deterministic().expect("bundled fonts load");
13345        bounded
13346            .layout(&template_input)
13347            .expect("template layout succeeds");
13348        let template = bounded
13349            .paragraph_cache
13350            .pop_front()
13351            .expect("template paragraph is retained");
13352        bounded.paragraph_cache_bytes = 0;
13353        for index in 0..=PARAGRAPH_CACHE_MAX_ENTRIES {
13354            let mut paragraph = CT_P::new();
13355            paragraph.add_run(&format!("eviction paragraph {index}"));
13356            let bytes = paragraph_cache_entry_bytes(
13357                &paragraph,
13358                &template.block,
13359                &template.diagnostics,
13360                template.font_trace.len(),
13361            );
13362            bounded.publish_paragraph_cache_entry(ParagraphCacheEntry {
13363                fingerprint: paragraph_fingerprint(&paragraph),
13364                key: ParagraphCacheKey {
13365                    paragraph,
13366                    content_width_bits: PageGeometry::default().content_width().to_bits(),
13367                    revision_view: RevisionView::Accepted,
13368                },
13369                block: template.block.clone(),
13370                diagnostics: template.diagnostics.clone(),
13371                font_trace: template.font_trace.clone(),
13372                reflow_direction: template.reflow_direction,
13373                bytes,
13374            });
13375        }
13376        assert_eq!(bounded.paragraph_cache.len(), PARAGRAPH_CACHE_MAX_ENTRIES);
13377        assert!(bounded.paragraph_cache_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
13378        assert_eq!(
13379            bounded
13380                .paragraph_cache
13381                .front()
13382                .expect("FIFO cache has a front")
13383                .key
13384                .paragraph
13385                .text(),
13386            "eviction paragraph 1"
13387        );
13388    }
13389
13390    #[test]
13391    fn paragraph_relayout_cache_is_bounded() {
13392        let mut input = make_input_with_text("bounded paragraph 0");
13393        for index in 1..(PARAGRAPH_CACHE_MAX_ENTRIES + 20) {
13394            let mut paragraph = CT_P::new();
13395            paragraph.add_run(&format!("bounded paragraph {index}"));
13396            input.document.body.add_paragraph(paragraph);
13397        }
13398        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13399        engine.layout(&input).expect("bounded layout succeeds");
13400        assert!(engine.paragraph_cache.len() <= PARAGRAPH_CACHE_MAX_ENTRIES);
13401        assert!(engine.paragraph_cache_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
13402        assert!(engine.pending_paragraph_cache_peak_entries <= PARAGRAPH_CACHE_MAX_ENTRIES);
13403        assert!(engine.pending_paragraph_cache_peak_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
13404    }
13405
13406    #[test]
13407    fn transactional_paragraph_staging_is_bounded_before_publication() {
13408        let mut input = make_input_with_text("staged paragraph 0");
13409        for index in 1..(PARAGRAPH_CACHE_MAX_ENTRIES * 2) {
13410            let mut paragraph = CT_P::new();
13411            paragraph.add_run(&format!("staged paragraph {index}"));
13412            input.document.body.add_paragraph(paragraph);
13413        }
13414        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13415        engine
13416            .layout(&input)
13417            .expect("transactional layout succeeds");
13418        assert_eq!(
13419            engine.pending_paragraph_cache_peak_entries,
13420            PARAGRAPH_CACHE_MAX_ENTRIES
13421        );
13422        assert!(engine.pending_paragraph_cache_peak_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
13423    }
13424
13425    #[test]
13426    fn paragraph_relayout_cache_enforces_the_reflow_byte_ceiling() {
13427        let input = make_input_with_text("reflow accounting template");
13428        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13429        engine.layout(&input).expect("template layout succeeds");
13430        let template = engine
13431            .paragraph_cache
13432            .front()
13433            .expect("safe paragraph cached")
13434            .block
13435            .clone();
13436        let mut retained = match &template.lines[0].items[0] {
13437            LineItem::Text(text) => text.clone(),
13438            other => panic!("expected text line item, got {other:?}"),
13439        };
13440        retained.advances = vec![0.0; PARAGRAPH_CACHE_MAX_BYTES / 8 + 1];
13441
13442        let mut block = template.as_ref().clone();
13443        block.reflow = Some(Box::new(block::ParagraphReflow {
13444            items: vec![InlineItem::Text(retained)],
13445            params: oxml_layout::LineBreakParams::default(),
13446        }));
13447        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
13448            panic!("body paragraph");
13449        };
13450        let bytes = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
13451        assert!(bytes > PARAGRAPH_CACHE_MAX_BYTES);
13452
13453        engine.paragraph_cache.clear();
13454        engine.paragraph_cache_bytes = 0;
13455        engine.publish_paragraph_cache_entry(ParagraphCacheEntry {
13456            fingerprint: paragraph_fingerprint(paragraph),
13457            key: ParagraphCacheKey {
13458                paragraph: paragraph.clone(),
13459                content_width_bits: PageGeometry::default().content_width().to_bits(),
13460                revision_view: RevisionView::Accepted,
13461            },
13462            block: Arc::new(block),
13463            diagnostics: Vec::new(),
13464            font_trace: Vec::new(),
13465            reflow_direction: TextDirection::Auto,
13466            bytes,
13467        });
13468        assert!(engine.paragraph_cache.is_empty());
13469        assert_eq!(engine.paragraph_cache_bytes, 0);
13470    }
13471
13472    #[test]
13473    fn tab_heavy_paragraph_in_wrapping_document_counts_reflow_parameter_buffers() {
13474        use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
13475        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
13476        use rdocx_oxml::shared::ST_TabJc;
13477        use rdocx_oxml::units::Twips;
13478
13479        let mut input =
13480            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
13481        let retained_per_stop =
13482            std::mem::size_of::<CT_TabStop>() + std::mem::size_of::<oxml_layout::TabStop>();
13483        let stop_count = PARAGRAPH_CACHE_MAX_BYTES / retained_per_stop + 1;
13484        let mut paragraph = CT_P::new();
13485        paragraph.properties = Some(CT_PPr {
13486            tabs: Some(CT_Tabs {
13487                tabs: (0..stop_count)
13488                    .map(|_| CT_TabStop::new(ST_TabJc::Left, Twips(720)))
13489                    .collect(),
13490            }),
13491            ..CT_PPr::default()
13492        });
13493        paragraph.add_run("cache-safe paragraph with many owned tab definitions");
13494        assert!(paragraph_is_cache_safe(&paragraph, &input.styles));
13495        input.document.body.add_paragraph(paragraph);
13496
13497        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13498        engine.layout(&input).expect("tab-heavy layout succeeds");
13499        assert!(engine.paragraph_cache.is_empty());
13500        assert_eq!(engine.paragraph_cache_bytes, 0);
13501    }
13502
13503    #[test]
13504    fn paragraph_relayout_cache_counts_all_reflow_parameter_vectors() {
13505        let input = make_input_with_text("reflow parameter accounting template");
13506        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13507        engine.layout(&input).expect("template layout succeeds");
13508        let mut block = engine
13509            .paragraph_cache
13510            .front()
13511            .expect("safe paragraph cached")
13512            .block
13513            .as_ref()
13514            .clone();
13515        let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
13516            panic!("body paragraph");
13517        };
13518        let baseline = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
13519        let reflow = block.reflow.as_mut().expect("cache retains reflow inputs");
13520        reflow.params.tab_stops = vec![
13521            oxml_layout::TabStop {
13522                pos_pt: 36.0,
13523                align: oxml_layout::TabAlign::Left,
13524                leader: None,
13525            };
13526            3
13527        ];
13528        reflow.params.line_prefix_widths = vec![0.0; 5];
13529        reflow.params.line_suffix_widths = vec![0.0; 7];
13530        let with_parameters = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
13531        let expected =
13532            3 * std::mem::size_of::<oxml_layout::TabStop>() + 12 * std::mem::size_of::<f64>();
13533        assert_eq!(with_parameters - baseline, expected);
13534    }
13535
13536    #[test]
13537    fn paragraph_relayout_cache_counts_fixed_storage_in_owned_keys() {
13538        let input = make_input_with_text("key accounting template");
13539        let mut engine = Engine::new_deterministic().expect("bundled fonts load");
13540        engine.layout(&input).expect("template layout succeeds");
13541        let block = engine
13542            .paragraph_cache
13543            .front()
13544            .expect("safe paragraph cached")
13545            .block
13546            .clone();
13547
13548        let mut paragraph = CT_P::new();
13549        let mut run = CT_R::new("");
13550        let content_count = PARAGRAPH_CACHE_MAX_BYTES / std::mem::size_of::<RunContent>() + 1;
13551        run.content = std::iter::repeat_n(RunContent::Tab, content_count).collect();
13552        paragraph.runs.push(run);
13553        assert!(paragraph_is_cache_safe(&paragraph, &input.styles));
13554        let bytes = paragraph_cache_entry_bytes(&paragraph, &block, &[], 0);
13555        assert!(bytes > PARAGRAPH_CACHE_MAX_BYTES);
13556
13557        engine.paragraph_cache.clear();
13558        engine.paragraph_cache_bytes = 0;
13559        engine.publish_paragraph_cache_entry(ParagraphCacheEntry {
13560            fingerprint: paragraph_fingerprint(&paragraph),
13561            key: ParagraphCacheKey {
13562                paragraph,
13563                content_width_bits: PageGeometry::default().content_width().to_bits(),
13564                revision_view: RevisionView::Accepted,
13565            },
13566            block,
13567            diagnostics: Vec::new(),
13568            font_trace: Vec::new(),
13569            reflow_direction: TextDirection::Auto,
13570            bytes,
13571        });
13572        assert!(engine.paragraph_cache.is_empty());
13573        assert_eq!(engine.paragraph_cache_bytes, 0);
13574    }
13575
13576    #[test]
13577    fn every_sourced_glyph_run_resolves_to_its_exact_word_text() {
13578        use rdocx_oxml::document::CT_SectPr;
13579        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
13580        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
13581
13582        let body_text = "Body ASCII 🚀 界 wraps across several exact source slices ".repeat(5);
13583        let mut input = make_input_with_text(&body_text);
13584
13585        let mut outer = CT_Tbl::new();
13586        let mut outer_row = CT_Row::new();
13587        let mut outer_cell = CT_Tc::new();
13588        outer_cell.paragraphs_mut()[0].add_run("outer cell");
13589        let mut nested = CT_Tbl::new();
13590        let mut nested_row = CT_Row::new();
13591        let mut nested_cell = CT_Tc::new();
13592        nested_cell.paragraphs_mut()[0].add_run("nested cell");
13593        nested_row.cells.push(nested_cell);
13594        nested.rows.push(nested_row);
13595        outer_cell.content.push(CellContent::Table(nested));
13596        outer_row.cells.push(outer_cell);
13597        outer.rows.push(outer_row);
13598        input.document.body.add_table(outer);
13599
13600        let mut references = CT_P::new();
13601        let mut reference_run = CT_R::new("");
13602        reference_run.content = vec![
13603            RunContent::FootnoteRef { id: 4 },
13604            RunContent::EndnoteRef { id: 9 },
13605        ];
13606        references.runs.push(reference_run);
13607        input.document.body.add_paragraph(references);
13608
13609        let mut header = CT_HdrFtr::new();
13610        let mut header_paragraph = CT_P::new();
13611        header_paragraph.add_run("رأس الصفحة");
13612        header.paragraphs.push(header_paragraph);
13613        input.headers.insert("rIdHeader".to_owned(), header);
13614
13615        let mut footer = CT_HdrFtr::new();
13616        let mut footer_paragraph = CT_P::new();
13617        footer_paragraph.add_run("تذييل الصفحة");
13618        footer.paragraphs.push(footer_paragraph);
13619        input.footers.insert("rIdFooter".to_owned(), footer);
13620
13621        let mut section = CT_SectPr::default_letter();
13622        section.header_refs.push(HdrFtrRef {
13623            hdr_ftr_type: HdrFtrType::Default,
13624            rel_id: "rIdHeader".to_owned(),
13625        });
13626        section.footer_refs.push(HdrFtrRef {
13627            hdr_ftr_type: HdrFtrType::Default,
13628            rel_id: "rIdFooter".to_owned(),
13629        });
13630        input.document.body.sect_pr = Some(section);
13631
13632        let mut footnote_paragraph = CT_P::new();
13633        footnote_paragraph.add_run("footnote text");
13634        input.footnotes = Some(CT_Footnotes {
13635            footnotes: vec![CT_Footnote {
13636                id: 4,
13637                note_type: NoteType::Normal,
13638                paragraphs: vec![footnote_paragraph],
13639            }],
13640        });
13641        let mut endnote_paragraph = CT_P::new();
13642        endnote_paragraph.add_run("endnote text");
13643        input.endnotes = Some(CT_Footnotes {
13644            footnotes: vec![CT_Footnote {
13645                id: 9,
13646                note_type: NoteType::Normal,
13647                paragraphs: vec![endnote_paragraph],
13648            }],
13649        });
13650
13651        let expected = HashMap::from([
13652            (
13653                WordSourcePath {
13654                    story: WordStory::Document,
13655                    children: vec![0],
13656                },
13657                body_text,
13658            ),
13659            (
13660                WordSourcePath {
13661                    story: WordStory::Document,
13662                    children: vec![1, 0, 0, 0],
13663                },
13664                "outer cell".to_owned(),
13665            ),
13666            (
13667                WordSourcePath {
13668                    story: WordStory::Document,
13669                    children: vec![1, 0, 0, 1, 0, 0, 0],
13670                },
13671                "nested cell".to_owned(),
13672            ),
13673            (
13674                WordSourcePath {
13675                    story: WordStory::Header {
13676                        relationship_id: "rIdHeader".to_owned(),
13677                    },
13678                    children: vec![0],
13679                },
13680                "رأس الصفحة".to_owned(),
13681            ),
13682            (
13683                WordSourcePath {
13684                    story: WordStory::Footer {
13685                        relationship_id: "rIdFooter".to_owned(),
13686                    },
13687                    children: vec![0],
13688                },
13689                "تذييل الصفحة".to_owned(),
13690            ),
13691            (
13692                WordSourcePath {
13693                    story: WordStory::Footnote { id: 4 },
13694                    children: vec![0],
13695                },
13696                "footnote text".to_owned(),
13697            ),
13698            (
13699                WordSourcePath {
13700                    story: WordStory::Endnote { id: 9 },
13701                    children: vec![0],
13702                },
13703                "endnote text".to_owned(),
13704            ),
13705        ]);
13706
13707        let result = crate::layout_document_deterministic_with_provenance(&input)
13708            .expect("layout with provenance");
13709        let mut seen = std::collections::HashSet::new();
13710        for (source, text) in result.layout.pages.iter().flat_map(|page| {
13711            compatibility_page_elements(page)
13712                .into_iter()
13713                .filter_map(|element| match element {
13714                    PositionedElement::Text(run) => Some((run.source, run.text.as_str())),
13715                    PositionedElement::MultilingualText(run) => {
13716                        Some((run.source, run.logical_text.as_str()))
13717                    }
13718                    _ => None,
13719                })
13720        }) {
13721            let Some(span) = source else {
13722                continue;
13723            };
13724            let path = result.source_node(span.node).expect("source node resolves");
13725            let source_text = expected.get(path).expect("source path belongs to fixture");
13726            let selected = source_text
13727                .chars()
13728                .skip(span.char_start as usize)
13729                .take((span.char_end - span.char_start) as usize)
13730                .collect::<String>();
13731            assert_eq!(selected, text, "mismatch at {path:?}");
13732            seen.insert(path.clone());
13733        }
13734        assert_eq!(
13735            seen.len(),
13736            expected.len(),
13737            "every supported story is sourced"
13738        );
13739        for path in expected.keys() {
13740            assert!(seen.contains(path), "missing source path {path:?}");
13741        }
13742
13743        let without_provenance = deterministic_layout(&input);
13744        let rich_header_footer = multilingual_runs(&without_provenance)
13745            .into_iter()
13746            .filter(|run| run.logical_text.contains("الصفحة"))
13747            .collect::<Vec<_>>();
13748        assert_eq!(rich_header_footer.len(), 2);
13749        assert!(
13750            rich_header_footer.iter().all(|run| run.source.is_none()),
13751            "cache-only source IDs must not escape without provenance"
13752        );
13753    }
13754
13755    #[test]
13756    fn repeated_text_and_repeated_stories_keep_distinct_source_nodes() {
13757        use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
13758
13759        let repeated = "duplicate phrase ".repeat(220);
13760        let mut input = make_input_with_text(&repeated);
13761        let mut second = CT_P::new();
13762        second.add_run(&repeated);
13763        input.document.body.add_paragraph(second);
13764
13765        let mut header = CT_HdrFtr::new();
13766        let mut paragraph = CT_P::new();
13767        paragraph.add_run("repeated header");
13768        header.paragraphs.push(paragraph);
13769        input.headers.insert("rIdRepeated".to_owned(), header);
13770        input
13771            .document
13772            .body
13773            .sect_pr
13774            .as_mut()
13775            .expect("default section")
13776            .header_refs
13777            .push(HdrFtrRef {
13778                hdr_ftr_type: HdrFtrType::Default,
13779                rel_id: "rIdRepeated".to_owned(),
13780            });
13781
13782        let result = crate::layout_document_deterministic_with_provenance(&input)
13783            .expect("layout repeated stories");
13784        assert!(result.layout.pages.len() > 1, "header must be reused");
13785        let mut first_body = std::collections::HashSet::new();
13786        let mut second_body = std::collections::HashSet::new();
13787        let mut header_nodes = std::collections::HashSet::new();
13788        let mut header_runs = 0usize;
13789        for run in result.layout.pages.iter().flat_map(|page| {
13790            compatibility_page_elements(page)
13791                .into_iter()
13792                .filter_map(|element| match element {
13793                    PositionedElement::Text(run) => Some(run),
13794                    _ => None,
13795                })
13796        }) {
13797            let Some(source) = run.source else {
13798                continue;
13799            };
13800            match result.source_node(source.node).expect("source resolves") {
13801                WordSourcePath {
13802                    story: WordStory::Document,
13803                    children,
13804                } if children == &[0] => {
13805                    first_body.insert(source.node);
13806                }
13807                WordSourcePath {
13808                    story: WordStory::Document,
13809                    children,
13810                } if children == &[1] => {
13811                    second_body.insert(source.node);
13812                }
13813                WordSourcePath {
13814                    story: WordStory::Header { relationship_id },
13815                    children,
13816                } if relationship_id == "rIdRepeated" && children == &[0] => {
13817                    header_nodes.insert(source.node);
13818                    header_runs += 1;
13819                }
13820                _ => {}
13821            }
13822        }
13823        assert_eq!(first_body.len(), 1);
13824        assert_eq!(second_body.len(), 1);
13825        assert_ne!(
13826            first_body, second_body,
13827            "duplicate paragraphs must not alias"
13828        );
13829        assert_eq!(header_nodes.len(), 1, "repeated header reuses one node");
13830        assert!(header_runs > 1, "header must be emitted more than once");
13831    }
13832
13833    #[test]
13834    fn accepted_and_tracked_views_record_projection_local_ranges() {
13835        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>"#;
13836        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision XML parses");
13837        let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
13838            panic!("expected paragraph");
13839        };
13840
13841        for (view, expected) in [
13842            (RevisionView::Accepted, "AC"),
13843            (RevisionView::Tracked, "ABC"),
13844        ] {
13845            assert_eq!(projected_paragraph_text(paragraph, view), expected);
13846            let mut input = make_input_with_text("");
13847            input.document = document.clone();
13848            input.revision_view = view;
13849            let result = crate::layout_document_deterministic_with_provenance(&input)
13850                .expect("revision layout with provenance");
13851            assert_eq!(result.revision_view, view);
13852            let mut selected = String::new();
13853            for run in result.layout.pages.iter().flat_map(|page| {
13854                compatibility_page_elements(page)
13855                    .into_iter()
13856                    .filter_map(|element| match element {
13857                        PositionedElement::Text(run) => Some(run),
13858                        _ => None,
13859                    })
13860            }) {
13861                let Some(span) = run.source else {
13862                    continue;
13863                };
13864                assert!(matches!(
13865                    result.source_node(span.node),
13866                    Some(WordSourcePath {
13867                        story: WordStory::Document,
13868                        children,
13869                    }) if children == &[0]
13870                ));
13871                let exact = expected
13872                    .chars()
13873                    .skip(span.char_start as usize)
13874                    .take((span.char_end - span.char_start) as usize)
13875                    .collect::<String>();
13876                assert_eq!(exact, run.text);
13877                selected.push_str(&run.text);
13878            }
13879            assert_eq!(selected, expected);
13880        }
13881    }
13882
13883    #[test]
13884    fn field_projection_ownership_disambiguates_repeated_literal_ranges() {
13885        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>"#;
13886        let document = rdocx_oxml::CT_Document::from_xml(xml).expect("complex field parses");
13887        let BodyContent::Paragraph(parsed) = &document.body.content[0] else {
13888            panic!("expected paragraph");
13889        };
13890        let [RunContent::Field(complex)] = parsed.runs[0].content.as_slice() else {
13891            panic!("expected projected complex field");
13892        };
13893
13894        let cases = [
13895            (
13896                vec![
13897                    RunContent::Field(complex.clone()),
13898                    RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
13899                    RunContent::Field(Field::new("DATE", "a")),
13900                ],
13901                "aa",
13902                vec![("a", 1, 2)],
13903            ),
13904            (
13905                vec![
13906                    RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
13907                    RunContent::Field(complex.clone()),
13908                    RunContent::Text(rdocx_oxml::text::CT_Text::new("aa")),
13909                    RunContent::Field(Field::new("DATE", "a")),
13910                    RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
13911                ],
13912                "aaaaa",
13913                vec![("a", 0, 1), ("aa", 2, 4), ("a", 4, 5)],
13914            ),
13915        ];
13916
13917        for (content, expected_projection, expected_literals) in cases {
13918            let mut input = make_input_with_text("");
13919            let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
13920                panic!("expected paragraph");
13921            };
13922            let mut run = CT_R::new("");
13923            run.content = content;
13924            assert_eq!(run.text(), expected_projection);
13925            paragraph.runs = vec![run];
13926
13927            let result = crate::layout_document_deterministic_with_provenance(&input)
13928                .expect("mixed field layout");
13929            let sourced = result
13930                .layout
13931                .pages
13932                .iter()
13933                .flat_map(|page| compatibility_page_elements(page))
13934                .filter_map(|element| match element {
13935                    PositionedElement::Text(run) => run
13936                        .source
13937                        .map(|span| (run.text.as_str(), span.char_start, span.char_end)),
13938                    _ => None,
13939                })
13940                .collect::<Vec<_>>();
13941            assert_eq!(sourced, expected_literals);
13942        }
13943    }
13944
13945    #[test]
13946    fn generated_or_transformed_text_remains_unattributed() {
13947        use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
13948        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
13949        use rdocx_oxml::numbering::{
13950            CT_AbstractNum, CT_Lvl, CT_Num, CT_Numbering, ST_NumberFormat,
13951        };
13952        use rdocx_oxml::properties::CT_RPr;
13953        use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
13954        use rdocx_oxml::units::Twips;
13955
13956        let mut input = make_input_with_text("ordinary");
13957
13958        let mut transformed = CT_P::new();
13959        let mut caps = CT_R::new("straße");
13960        caps.properties = Some(CT_RPr {
13961            caps: Some(true),
13962            ..Default::default()
13963        });
13964        transformed.runs.push(caps);
13965        input.document.body.add_paragraph(transformed);
13966
13967        let mut generated = CT_P::new();
13968        generated.properties = Some(CT_PPr {
13969            tabs: Some(CT_Tabs {
13970                tabs: vec![CT_TabStop {
13971                    val: ST_TabJc::Left,
13972                    pos: Twips(3600),
13973                    leader: Some(ST_TabLeader::Dot),
13974                    source_occurrence: None,
13975                }],
13976            }),
13977            ..Default::default()
13978        });
13979        let mut generated_run = CT_R::new("");
13980        generated_run.content = vec![
13981            RunContent::Text(rdocx_oxml::text::CT_Text::new("left")),
13982            RunContent::Tab,
13983            RunContent::Text(rdocx_oxml::text::CT_Text::new("right")),
13984            RunContent::Field(Field::new("PAGE", "7")),
13985            RunContent::Text(rdocx_oxml::text::CT_Text::new("after")),
13986            RunContent::FootnoteRef { id: 4 },
13987        ];
13988        generated.runs.push(generated_run);
13989        input.document.body.add_paragraph(generated);
13990
13991        let mut list = CT_P::new();
13992        list.properties = Some(CT_PPr {
13993            num_id: Some(1),
13994            num_ilvl: Some(0),
13995            ..Default::default()
13996        });
13997        list.add_run("listed");
13998        input.document.body.add_paragraph(list);
13999        let mut level = CT_Lvl::new(0);
14000        level.start = Some(1);
14001        level.num_fmt = Some(ST_NumberFormat::Decimal);
14002        level.lvl_text = Some("%1.".to_owned());
14003        let mut abstract_num = CT_AbstractNum::new(1);
14004        abstract_num.levels.push(level);
14005        input.numbering = Some(CT_Numbering {
14006            abstract_nums: vec![abstract_num],
14007            nums: vec![CT_Num {
14008                num_id: 1,
14009                abstract_num_id: 1,
14010                extra_xml: Vec::new(),
14011                extra_attributes: Vec::new(),
14012            }],
14013            root_attributes: Vec::new(),
14014            extra_xml: Vec::new(),
14015        });
14016
14017        let mut note = CT_P::new();
14018        note.add_run("note body");
14019        input.footnotes = Some(CT_Footnotes {
14020            footnotes: vec![CT_Footnote {
14021                id: 4,
14022                note_type: NoteType::Normal,
14023                paragraphs: vec![note],
14024            }],
14025        });
14026
14027        let result = crate::layout_document_deterministic_with_provenance(&input)
14028            .expect("generated text layout");
14029        let runs = result
14030            .layout
14031            .pages
14032            .iter()
14033            .flat_map(|page| compatibility_page_elements(page))
14034            .filter_map(|element| match element {
14035                PositionedElement::Text(run) => Some(run),
14036                _ => None,
14037            })
14038            .collect::<Vec<_>>();
14039        assert!(
14040            runs.iter()
14041                .any(|run| run.text == "ordinary" && run.source.is_some())
14042        );
14043        assert!(
14044            runs.iter()
14045                .any(|run| run.text == "after" && run.source.is_some())
14046        );
14047        assert!(
14048            runs.iter()
14049                .any(|run| run.text == "STRASSE" && run.source.is_none())
14050        );
14051        assert!(
14052            runs.iter()
14053                .any(|run| run.text == "1." && run.source.is_none())
14054        );
14055        assert!(runs.iter().any(|run| {
14056            !run.text.is_empty()
14057                && run.text.chars().all(|character| character == '.')
14058                && run.source.is_none()
14059        }));
14060        assert!(
14061            runs.iter()
14062                .any(|run| run.field_kind == Some(FieldKind::Page) && run.source.is_none())
14063        );
14064        assert!(
14065            runs.iter()
14066                .any(|run| run.note.is_some() && run.source.is_none())
14067        );
14068    }
14069
14070    #[test]
14071    fn existing_low_level_layout_functions_keep_identical_output() {
14072        fn clear_sources(elements: &mut [PositionedElement]) {
14073            for element in elements {
14074                match element {
14075                    PositionedElement::Text(run) => run.source = None,
14076                    PositionedElement::MarkedContent { children, .. } => clear_sources(children),
14077                    _ => {}
14078                }
14079            }
14080        }
14081
14082        let input = make_input_with_text("compatibility 🚀 text that wraps ".repeat(30).as_str());
14083        let ordinary = crate::layout_document_deterministic(&input).expect("ordinary layout");
14084        let mut sourced = crate::layout_document_deterministic_with_provenance(&input)
14085            .expect("provenance layout")
14086            .into_layout_result();
14087        for page in &mut sourced.pages {
14088            clear_sources(&mut Arc::make_mut(page).elements);
14089        }
14090        assert_eq!(format!("{ordinary:?}"), format!("{sourced:?}"));
14091    }
14092
14093    #[test]
14094    fn caller_font_and_deterministic_provenance_variants_return_complete_maps() {
14095        let mut input = make_input_with_text("caller font provenance");
14096        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
14097            panic!("expected paragraph");
14098        };
14099        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
14100            font_ascii: Some("Caller Carlito".to_owned()),
14101            font_hansi: Some("Caller Carlito".to_owned()),
14102            ..Default::default()
14103        });
14104        input.fonts.push(oxml_layout::FontFile {
14105            family: "Caller Carlito".to_owned(),
14106            data: include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf").to_vec(),
14107        });
14108
14109        let normal = crate::layout_document_with_provenance(&input).expect("caller font layout");
14110        let deterministic = crate::layout_document_deterministic_with_provenance(&input)
14111            .expect("deterministic caller font layout");
14112        for result in [&normal, &deterministic] {
14113            assert!(
14114                result
14115                    .layout
14116                    .fonts
14117                    .iter()
14118                    .any(|font| font.data.as_ref() == input.fonts[0].data.as_slice()),
14119                "the caller-provided font bytes shaped the result"
14120            );
14121            let runs = result
14122                .layout
14123                .pages
14124                .iter()
14125                .flat_map(|page| compatibility_page_elements(page))
14126                .filter_map(|element| match element {
14127                    PositionedElement::Text(run) if run.source.is_some() => Some(run),
14128                    _ => None,
14129                })
14130                .collect::<Vec<_>>();
14131            assert!(!runs.is_empty(), "caller-font text is sourced");
14132            assert_eq!(
14133                runs.iter().map(|run| run.text.as_str()).collect::<String>(),
14134                "caller font provenance"
14135            );
14136            for run in runs {
14137                let source = run.source.expect("run is sourced");
14138                assert!(matches!(
14139                    result.source_node(source.node),
14140                    Some(WordSourcePath {
14141                        story: WordStory::Document,
14142                        children,
14143                    }) if children == &[0]
14144                ));
14145            }
14146        }
14147    }
14148
14149    #[test]
14150    fn layout_simple_document() {
14151        let input = make_input_with_text("Hello World");
14152        let result = Engine::new().layout(&input);
14153        // On systems without fonts, this may fail — that's OK
14154        if let Ok(result) = result {
14155            assert!(!result.pages.is_empty());
14156            assert_eq!(result.pages[0].page_number, 1);
14157            assert!((result.pages[0].width - 612.0).abs() < 0.01);
14158        }
14159    }
14160
14161    #[test]
14162    fn layout_empty_document() {
14163        let mut doc = rdocx_oxml::document::CT_Document::new();
14164        doc.body.add_paragraph(CT_P::new());
14165
14166        let input = LayoutInput {
14167            revision_view: crate::input::RevisionView::Accepted,
14168            automatic_hyphenation: false,
14169            math_properties: None,
14170            document: doc,
14171            styles: CT_Styles::new_default(),
14172            numbering: None,
14173            headers: HashMap::new(),
14174            footers: HashMap::new(),
14175            images: HashMap::new(),
14176            charts: HashMap::new(),
14177            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14178            chart_color_map: oxml_drawing::color::ColorMap::default(),
14179            core_properties: None,
14180            hyperlink_urls: HashMap::new(),
14181            footnotes: None,
14182            endnotes: None,
14183            theme: None,
14184            fonts: Vec::new(),
14185        };
14186
14187        let result = Engine::new().layout(&input);
14188        if let Ok(result) = result {
14189            assert_eq!(result.pages.len(), 1);
14190        }
14191    }
14192
14193    #[test]
14194    fn word_blocks_build_document_order_semantics_before_pagination() {
14195        fn paragraph() -> ParagraphBlock {
14196            block::build_paragraph_block(
14197                Vec::new(),
14198                0.0,
14199                0.0,
14200                None,
14201                None,
14202                0.0,
14203                0.0,
14204                None,
14205                false,
14206                false,
14207                false,
14208                true,
14209            )
14210        }
14211
14212        let mut heading = paragraph();
14213        heading.heading_level = Some(1);
14214        let mut list_item = paragraph();
14215        list_item.list = Some((7, 0));
14216        let mut nested_item = paragraph();
14217        nested_item.list = Some((7, 1));
14218        nested_item.lines = vec![oxml_layout::LayoutLine {
14219            items: vec![LineItem::Figure {
14220                item: Box::new(LineItem::Group {
14221                    width: 10.0,
14222                    height: 10.0,
14223                    baseline: None,
14224                    group: GroupElement {
14225                        transform: oxml_layout::Transform::IDENTITY,
14226                        clip: None,
14227                        opacity: 1.0,
14228                        effects: Vec::new(),
14229                        children: vec![PositionedElement::FilledRect {
14230                            rect: Rect {
14231                                x: 0.0,
14232                                y: 0.0,
14233                                width: 10.0,
14234                                height: 10.0,
14235                            },
14236                            color: Color::BLACK,
14237                        }],
14238                    },
14239                }),
14240                alternate_text: "Revenue by quarter".to_owned(),
14241                structure_id: None,
14242            }],
14243            width: 10.0,
14244            ascent: 10.0,
14245            descent: 0.0,
14246            line_gap: 0.0,
14247            height: 10.0,
14248            indent_left: 0.0,
14249            available_width: 468.0,
14250            is_last: true,
14251        }];
14252        let cell = |is_first_row: bool| table::TableCell {
14253            structure_id: None,
14254            blocks: vec![table::CellBlock::Paragraph(paragraph())],
14255            width: 100.0,
14256            height: 12.0,
14257            grid_span: 1,
14258            is_vmerge_continue: false,
14259            starts_vmerge: false,
14260            merged_height: 12.0,
14261            merge_with_below: false,
14262            clip_content: false,
14263            col_index: 0,
14264            borders: None,
14265            shading: None,
14266            margin_left: 0.0,
14267            margin_right: 0.0,
14268            margin_top: 0.0,
14269            margin_bottom: 0.0,
14270            is_first_row,
14271            is_last_row: !is_first_row,
14272            v_align: None,
14273        };
14274        let table = table::TableBlock {
14275            structure_id: None,
14276            col_widths: vec![100.0],
14277            rows: vec![
14278                table::TableRow {
14279                    structure_id: None,
14280                    cells: vec![cell(true)],
14281                    height: 12.0,
14282                    is_header: true,
14283                },
14284                table::TableRow {
14285                    structure_id: None,
14286                    cells: vec![cell(false)],
14287                    height: 12.0,
14288                    is_header: false,
14289                },
14290            ],
14291            header_row_indices: vec![0],
14292            table_width: 100.0,
14293            table_indent: 0.0,
14294            borders: None,
14295        };
14296        let mut sections = [paginator::Section {
14297            blocks: vec![
14298                LayoutBlock::Paragraph(heading),
14299                LayoutBlock::Paragraph(list_item),
14300                LayoutBlock::Paragraph(nested_item),
14301                LayoutBlock::Table(table),
14302            ],
14303            geometry: PageGeometry::default(),
14304            header_footer: None,
14305            title_pg: false,
14306            page_number_start: None,
14307        }];
14308
14309        let structure = assign_document_structure(&mut sections);
14310        let roles = structure
14311            .nodes
14312            .iter()
14313            .map(|node| node.role)
14314            .collect::<Vec<_>>();
14315        assert_eq!(
14316            roles,
14317            [
14318                StructureRole::Document,
14319                StructureRole::Heading(1),
14320                StructureRole::List,
14321                StructureRole::ListItem,
14322                StructureRole::Paragraph,
14323                StructureRole::List,
14324                StructureRole::ListItem,
14325                StructureRole::Paragraph,
14326                StructureRole::Figure,
14327                StructureRole::Table,
14328                StructureRole::TableRow,
14329                StructureRole::TableHeaderCell,
14330                StructureRole::Paragraph,
14331                StructureRole::TableRow,
14332                StructureRole::TableCell,
14333                StructureRole::Paragraph,
14334            ]
14335        );
14336        assert_eq!(
14337            structure.nodes[8].alternate_text.as_deref(),
14338            Some("Revenue by quarter")
14339        );
14340        assert_eq!(structure.nodes[5].children, [structure.nodes[6].id]);
14341        assert_eq!(
14342            structure.nodes[9].children,
14343            [structure.nodes[10].id, structure.nodes[13].id]
14344        );
14345    }
14346
14347    #[test]
14348    fn behind_document_figure_follows_its_source_paragraph_in_structure() {
14349        let mut paragraph = block::build_paragraph_block(
14350            Vec::new(),
14351            0.0,
14352            0.0,
14353            None,
14354            None,
14355            0.0,
14356            0.0,
14357            None,
14358            false,
14359            false,
14360            false,
14361            true,
14362        );
14363        paragraph.anchored.push(block::AnchoredDrawing {
14364            behind_doc: true,
14365            rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
14366            off_h: 0.0,
14367            rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
14368            off_v: 0.0,
14369            width: 10.0,
14370            height: 10.0,
14371            wrap: rdocx_oxml::drawing::WrapType::None,
14372            dist_top: 0.0,
14373            dist_bottom: 0.0,
14374            dist_left: 0.0,
14375            dist_right: 0.0,
14376            align_h: None,
14377            align_v: None,
14378            content: block::AnchoredContent::Image {
14379                media_id: MediaId(1),
14380            },
14381            alternate_text: Some("Background diagram".to_owned()),
14382            structure_id: None,
14383        });
14384        let mut sections = [paginator::Section {
14385            blocks: vec![LayoutBlock::Paragraph(paragraph)],
14386            geometry: PageGeometry::default(),
14387            header_footer: None,
14388            title_pg: false,
14389            page_number_start: None,
14390        }];
14391
14392        let structure = assign_document_structure(&mut sections);
14393
14394        assert_eq!(
14395            structure
14396                .nodes
14397                .iter()
14398                .map(|node| node.role)
14399                .collect::<Vec<_>>(),
14400            [
14401                StructureRole::Document,
14402                StructureRole::Paragraph,
14403                StructureRole::Figure,
14404            ]
14405        );
14406        assert_eq!(
14407            structure.nodes[0].children,
14408            [structure.nodes[1].id, structure.nodes[2].id]
14409        );
14410        assert!(structure.nodes[1].children.is_empty());
14411        assert_eq!(
14412            structure.nodes[2].alternate_text.as_deref(),
14413            Some("Background diagram")
14414        );
14415    }
14416
14417    #[test]
14418    fn empty_shapeless_anchor_keeps_the_pre_cutover_omission() {
14419        let input = make_input_with_text("");
14420        let mut paragraph = CT_P::new();
14421        paragraph.add_run("").content = vec![RunContent::Drawing(
14422            rdocx_oxml::drawing::CT_Drawing::anchor(rdocx_oxml::drawing::CT_Anchor::background(
14423                "", 914_400, 914_400,
14424            )),
14425        )];
14426        let mut font_manager = FontManager::new();
14427        let mut numbering_state = NumberingState::new();
14428        let mut diagnostics = Vec::new();
14429        let media = MediaRegistry::new(&input.images);
14430
14431        let anchored = collect_anchored_drawings(
14432            &paragraph,
14433            &input.styles,
14434            &input,
14435            &media,
14436            &mut font_manager,
14437            &mut numbering_state,
14438            &mut diagnostics,
14439        )
14440        .expect("empty shapeless anchor collection should succeed");
14441
14442        assert!(anchored.is_empty());
14443    }
14444
14445    #[test]
14446    fn colliding_media_ids_keep_inline_and_anchored_image_bytes_distinct() {
14447        let mut input = make_input_with_text("");
14448        input.images.insert(
14449            "rIdInline".to_string(),
14450            ImageData {
14451                data: vec![1, 2, 3],
14452                content_type: "image/png".to_string(),
14453            },
14454        );
14455        input.images.insert(
14456            "rIdAnchor".to_string(),
14457            ImageData {
14458                data: vec![4, 5, 6],
14459                content_type: "image/jpeg".to_string(),
14460            },
14461        );
14462
14463        let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
14464        let inline_id = media.id_for_relationship("rIdInline");
14465        let anchor_id = media.id_for_relationship("rIdAnchor");
14466        assert_ne!(inline_id, anchor_id);
14467
14468        let line = oxml_layout::LayoutLine {
14469            items: vec![oxml_layout::LineItem::Image {
14470                width: 12.0,
14471                height: 10.0,
14472                media_id: inline_id,
14473            }],
14474            width: 12.0,
14475            ascent: 10.0,
14476            descent: 0.0,
14477            line_gap: 0.0,
14478            height: 10.0,
14479            indent_left: 0.0,
14480            available_width: 468.0,
14481            is_last: true,
14482        };
14483        let mut paragraph = block::build_paragraph_block(
14484            vec![line],
14485            0.0,
14486            0.0,
14487            None,
14488            None,
14489            0.0,
14490            0.0,
14491            None,
14492            false,
14493            false,
14494            false,
14495            true,
14496        );
14497        paragraph.anchored.push(block::AnchoredDrawing {
14498            behind_doc: false,
14499            rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
14500            off_h: 20.0,
14501            rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
14502            off_v: 20.0,
14503            width: 12.0,
14504            height: 10.0,
14505            wrap: rdocx_oxml::drawing::WrapType::None,
14506            dist_top: 0.0,
14507            dist_bottom: 0.0,
14508            dist_left: 0.0,
14509            dist_right: 0.0,
14510            align_h: None,
14511            align_v: None,
14512            content: block::AnchoredContent::Image {
14513                media_id: anchor_id,
14514            },
14515            alternate_text: None,
14516            structure_id: None,
14517        });
14518        let sections = [paginator::Section {
14519            blocks: vec![LayoutBlock::Paragraph(paragraph)],
14520            geometry: PageGeometry::default(),
14521            header_footer: None,
14522            title_pg: false,
14523            page_number_start: None,
14524        }];
14525
14526        let (pages, _) = paginator::paginate_sections(
14527            &sections,
14528            &FontManager::new(),
14529            &media,
14530            &NoteRegistry::default(),
14531        );
14532        let images = compatibility_page_elements(&pages[0])
14533            .into_iter()
14534            .filter_map(|element| match element {
14535                PositionedElement::Image {
14536                    data,
14537                    content_type,
14538                    media_id,
14539                    ..
14540                } => Some((data.as_slice(), content_type.as_str(), *media_id)),
14541                _ => None,
14542            })
14543            .collect::<Vec<_>>();
14544
14545        assert!(images.contains(&(b"\x01\x02\x03".as_slice(), "image/png", inline_id)));
14546        assert!(images.contains(&(b"\x04\x05\x06".as_slice(), "image/jpeg", anchor_id)));
14547    }
14548
14549    #[test]
14550    fn watermark_image_uses_the_collision_safe_media_registry_id() {
14551        let mut input = make_input_with_text("body");
14552        input.images.insert(
14553            "rIdHeader\0rIdOrdinary".to_owned(),
14554            ImageData {
14555                data: vec![1],
14556                content_type: "image/png".to_owned(),
14557            },
14558        );
14559        input.images.insert(
14560            "rIdHeader\0rIdWatermark".to_owned(),
14561            ImageData {
14562                data: vec![2],
14563                content_type: "image/png".to_owned(),
14564            },
14565        );
14566        let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
14567        let expected = media.id_for_relationship("rIdHeader\0rIdWatermark");
14568        let mut font_manager = FontManager::new();
14569        let mut diagnostics = Vec::new();
14570        let group = layout_watermark(
14571            &VmlWatermark::Image {
14572                relationship_id: "rIdWatermark".to_owned(),
14573                width_pt: 72.0,
14574                height_pt: 36.0,
14575                rotation_degrees: 0.0,
14576                opacity: 0.5,
14577            },
14578            "rIdHeader",
14579            &input,
14580            &media,
14581            &mut font_manager,
14582            PageGeometry::default(),
14583            &mut diagnostics,
14584        )
14585        .unwrap()
14586        .unwrap();
14587        let PositionedElement::Image { media_id, data, .. } = &group.children[0] else {
14588            panic!("expected watermark image");
14589        };
14590        assert_eq!(*media_id, expected);
14591        assert_eq!(data, &[2]);
14592        assert!(diagnostics.is_empty());
14593    }
14594
14595    #[test]
14596    fn group_inline_item_breaks_and_positions_like_an_image() {
14597        let child = PositionedElement::FilledRect {
14598            rect: Rect {
14599                x: 2.0,
14600                y: 3.0,
14601                width: 4.0,
14602                height: 5.0,
14603            },
14604            color: Color::BLACK,
14605        };
14606        let group = GroupElement {
14607            transform: oxml_layout::Transform::IDENTITY,
14608            clip: None,
14609            opacity: 1.0,
14610            effects: Vec::new(),
14611            children: vec![child.clone()],
14612        };
14613        let line = oxml_layout::LayoutLine {
14614            items: vec![oxml_layout::LineItem::Group {
14615                width: 80.0,
14616                height: 40.0,
14617                baseline: None,
14618                group,
14619            }],
14620            width: 80.0,
14621            ascent: 40.0,
14622            descent: 0.0,
14623            line_gap: 0.0,
14624            height: 40.0,
14625            indent_left: 0.0,
14626            available_width: 468.0,
14627            is_last: true,
14628        };
14629        let paragraph = block::build_paragraph_block(
14630            vec![line],
14631            0.0,
14632            0.0,
14633            None,
14634            None,
14635            0.0,
14636            0.0,
14637            None,
14638            false,
14639            false,
14640            false,
14641            true,
14642        );
14643        let sections = [paginator::Section {
14644            blocks: vec![LayoutBlock::Paragraph(paragraph)],
14645            geometry: PageGeometry::default(),
14646            header_footer: None,
14647            title_pg: false,
14648            page_number_start: None,
14649        }];
14650        let media = MediaRegistry::new(&HashMap::new());
14651        let (pages, _) = paginator::paginate_sections(
14652            &sections,
14653            &FontManager::new(),
14654            &media,
14655            &NoteRegistry::default(),
14656        );
14657
14658        let PositionedElement::Group(actual) = &pages[0].elements[0] else {
14659            panic!("group line item should become a positioned group");
14660        };
14661        assert_eq!((actual.transform.e, actual.transform.f), (72.0, 72.0));
14662        assert_eq!(actual.children, vec![child]);
14663    }
14664
14665    #[test]
14666    fn layout_with_heading_style() {
14667        let mut doc = rdocx_oxml::document::CT_Document::new();
14668        let mut p = CT_P::new();
14669        p.properties = Some(CT_PPr {
14670            style_id: Some("Heading1".to_string()),
14671            ..Default::default()
14672        });
14673        p.add_run("Chapter 1");
14674        doc.body.add_paragraph(p);
14675
14676        let input = LayoutInput {
14677            revision_view: crate::input::RevisionView::Accepted,
14678            automatic_hyphenation: false,
14679            math_properties: None,
14680            document: doc,
14681            styles: CT_Styles::new_default(),
14682            numbering: None,
14683            headers: HashMap::new(),
14684            footers: HashMap::new(),
14685            images: HashMap::new(),
14686            charts: HashMap::new(),
14687            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14688            chart_color_map: oxml_drawing::color::ColorMap::default(),
14689            core_properties: None,
14690            hyperlink_urls: HashMap::new(),
14691            footnotes: None,
14692            endnotes: None,
14693            theme: None,
14694            fonts: Vec::new(),
14695        };
14696
14697        let result = Engine::new().layout(&input);
14698        if let Ok(result) = result {
14699            assert!(!result.pages.is_empty());
14700            // Should produce one outline entry for Heading1
14701            assert_eq!(result.outlines.len(), 1);
14702            assert_eq!(result.outlines[0].title, "Chapter 1");
14703            assert_eq!(result.outlines[0].level, 1);
14704            assert_eq!(result.outlines[0].page_index, 0);
14705        }
14706    }
14707
14708    #[test]
14709    fn layout_nested_headings_produce_outlines() {
14710        let mut doc = rdocx_oxml::document::CT_Document::new();
14711
14712        // H1
14713        let mut h1 = CT_P::new();
14714        h1.properties = Some(CT_PPr {
14715            style_id: Some("Heading1".to_string()),
14716            ..Default::default()
14717        });
14718        h1.add_run("Chapter 1");
14719        doc.body.add_paragraph(h1);
14720
14721        // H2 under H1
14722        let mut h2 = CT_P::new();
14723        h2.properties = Some(CT_PPr {
14724            style_id: Some("Heading2".to_string()),
14725            ..Default::default()
14726        });
14727        h2.add_run("Section 1.1");
14728        doc.body.add_paragraph(h2);
14729
14730        // Another H1
14731        let mut h1b = CT_P::new();
14732        h1b.properties = Some(CT_PPr {
14733            style_id: Some("Heading1".to_string()),
14734            ..Default::default()
14735        });
14736        h1b.add_run("Chapter 2");
14737        doc.body.add_paragraph(h1b);
14738
14739        let input = LayoutInput {
14740            revision_view: crate::input::RevisionView::Accepted,
14741            automatic_hyphenation: false,
14742            math_properties: None,
14743            document: doc,
14744            styles: CT_Styles::new_default(),
14745            numbering: None,
14746            headers: HashMap::new(),
14747            footers: HashMap::new(),
14748            images: HashMap::new(),
14749            charts: HashMap::new(),
14750            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14751            chart_color_map: oxml_drawing::color::ColorMap::default(),
14752            core_properties: None,
14753            hyperlink_urls: HashMap::new(),
14754            footnotes: None,
14755            endnotes: None,
14756            theme: None,
14757            fonts: Vec::new(),
14758        };
14759
14760        let result = Engine::new().layout(&input);
14761        if let Ok(result) = result {
14762            assert_eq!(result.outlines.len(), 3);
14763            assert_eq!(result.outlines[0].level, 1);
14764            assert_eq!(result.outlines[0].title, "Chapter 1");
14765            assert_eq!(result.outlines[1].level, 2);
14766            assert_eq!(result.outlines[1].title, "Section 1.1");
14767            assert_eq!(result.outlines[2].level, 1);
14768            assert_eq!(result.outlines[2].title, "Chapter 2");
14769        }
14770    }
14771
14772    #[test]
14773    fn sect_pr_geometry_conversion() {
14774        let sect = CT_SectPr::default_letter();
14775        let geom = sect_pr_to_geometry(&sect);
14776        assert!((geom.page_width - 612.0).abs() < 0.01);
14777        assert!((geom.page_height - 792.0).abs() < 0.01);
14778        assert!((geom.margin_top - 72.0).abs() < 0.01);
14779        assert!((geom.content_width() - 468.0).abs() < 0.01);
14780    }
14781
14782    #[test]
14783    fn section_page_number_start_requires_a_direct_word_child_and_decodes_entities() {
14784        let mut section = CT_SectPr::default_letter();
14785        section.extra_xml = vec![
14786            br#"<x:pgNumType xmlns:x="urn:producer" x:start="2"/>"#.to_vec(),
14787            br#"<w:pgNumType xmlns:w="urn:producer" w:start="2"/>"#.to_vec(),
14788            format!(
14789                r#"<w:wrapper xmlns:w="{}"><w:pgNumType w:start="2"/></w:wrapper>"#,
14790                rdocx_oxml::namespace::W_NS
14791            )
14792            .into_bytes(),
14793            format!(
14794                r#"<q:pgNumType xmlns:q="{}" q:start="&#x31;"/>"#,
14795                rdocx_oxml::namespace::W_NS
14796            )
14797            .into_bytes(),
14798        ];
14799
14800        assert_eq!(section_page_number_start(&section), Some(1));
14801    }
14802
14803    #[test]
14804    fn sect_pr_a4_geometry() {
14805        let sect = CT_SectPr::default_a4();
14806        let geom = sect_pr_to_geometry(&sect);
14807        // A4: 210mm = 595.3pt, 297mm = 841.9pt
14808        assert!((geom.page_width - 595.3).abs() < 0.5);
14809        assert!((geom.page_height - 841.9).abs() < 0.5);
14810    }
14811
14812    // F-X013a, footnote line advance.
14813
14814    /// Build a document whose single body paragraph references footnote 1, and
14815    /// whose footnote 1 is one paragraph made of `note_runs` separate runs.
14816    fn make_input_with_footnote(note_runs: &[&str]) -> LayoutInput {
14817        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14818        use rdocx_oxml::text::CT_R;
14819
14820        let mut doc = rdocx_oxml::document::CT_Document::new();
14821        let mut body = CT_P::new();
14822        body.add_run("Body text carrying a note");
14823        let mut marker_run = CT_R::new("");
14824        marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
14825        body.runs.push(marker_run);
14826        doc.body.add_paragraph(body);
14827
14828        let mut note = CT_P::new();
14829        for text in note_runs {
14830            note.add_run(text);
14831        }
14832
14833        LayoutInput {
14834            revision_view: crate::input::RevisionView::Accepted,
14835            automatic_hyphenation: false,
14836            math_properties: None,
14837            document: doc,
14838            styles: CT_Styles::new_default(),
14839            numbering: None,
14840            headers: HashMap::new(),
14841            footers: HashMap::new(),
14842            images: HashMap::new(),
14843            charts: HashMap::new(),
14844            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14845            chart_color_map: oxml_drawing::color::ColorMap::default(),
14846            core_properties: None,
14847            hyperlink_urls: HashMap::new(),
14848            footnotes: Some(CT_Footnotes {
14849                footnotes: vec![CT_Footnote {
14850                    id: 1,
14851                    note_type: NoteType::Normal,
14852                    paragraphs: vec![note],
14853                }],
14854            }),
14855            endnotes: None,
14856            theme: None,
14857            fonts: Vec::new(),
14858        }
14859    }
14860
14861    #[test]
14862    fn automatic_hyphenation_reaches_note_story_paragraphs() {
14863        let mut input = make_input_with_footnote(&["representation"]);
14864        input.automatic_hyphenation = true;
14865        let note = &mut input.footnotes.as_mut().unwrap().footnotes[0].paragraphs[0];
14866        note.properties = Some(CT_PPr {
14867            ind_right: Some(rdocx_oxml::units::Twips(8_500)),
14868            ..Default::default()
14869        });
14870        note.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
14871            language: Some("en-US".to_owned()),
14872            ..Default::default()
14873        });
14874
14875        let text = output_text(&deterministic_layout(&input));
14876        assert!(text.iter().any(|item| item == "-"), "{text:?}");
14877    }
14878
14879    /// The x origin of every glyph run sitting below the footnote separator,
14880    /// in the order the renderer emitted them. The first is the note marker.
14881    fn footnote_glyph_x(page: &oxml_layout::output::PageFrame) -> Vec<f64> {
14882        let elements = compatibility_page_elements(page);
14883        let separator_y = elements
14884            .iter()
14885            .find_map(|element| match element {
14886                PositionedElement::Line { start, .. } => Some(start.y),
14887                _ => None,
14888            })
14889            .expect("a page with a footnote draws a separator line");
14890
14891        elements
14892            .iter()
14893            .filter_map(|element| match element {
14894                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.x),
14895                _ => None,
14896            })
14897            .collect()
14898    }
14899
14900    #[test]
14901    fn a_multi_segment_footnote_does_not_stack_its_segments_at_one_x() {
14902        let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
14903        let mut engine = Engine::new();
14904        let output = engine.layout(&input).expect("layout succeeds");
14905        let xs = footnote_glyph_x(&output.pages[0]);
14906
14907        assert!(
14908            xs.len() >= 4,
14909            "expected a marker and three note segments, got {xs:?}"
14910        );
14911        for pair in xs.windows(2) {
14912            assert!(
14913                pair[1] > pair[0],
14914                "footnote segments must advance, got {xs:?}"
14915            );
14916        }
14917    }
14918
14919    #[test]
14920    fn a_single_segment_footnote_keeps_its_original_position() {
14921        let input = make_input_with_footnote(&["Solitary"]);
14922        let mut engine = Engine::new();
14923        let output = engine.layout(&input).expect("layout succeeds");
14924        let xs = footnote_glyph_x(&output.pages[0]);
14925        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14926
14927        // The marker sits at the left margin, the single segment one indent in.
14928        assert_eq!(xs.len(), 2, "expected a marker and one segment, got {xs:?}");
14929        assert!(
14930            (xs[0] - geometry.margin_left).abs() < 0.01,
14931            "marker at {xs:?}"
14932        );
14933        assert!(
14934            (xs[1] - (geometry.margin_left + 12.0)).abs() < 0.01,
14935            "segment at {xs:?}"
14936        );
14937    }
14938
14939    #[test]
14940    fn a_long_footnote_does_not_overrun_the_right_margin() {
14941        // Long enough to wrap, which is what exposes a break width that
14942        // disagrees with the indent the note is drawn at.
14943        let long = "In paged media, footnotes are usually displayed at the \
14944                    bottom of the text. However, in ebooks, a better paradigm \
14945                    is to make them clickable endnotes that the reader can \
14946                    browse at leisure, which this sentence exists to force.";
14947        let input = make_input_with_footnote(&[long]);
14948        let mut engine = Engine::new();
14949        let output = engine.layout(&input).expect("layout succeeds");
14950        let page = &output.pages[0];
14951        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14952        let right_margin = geometry.page_width - geometry.margin_right;
14953
14954        let elements = compatibility_page_elements(page);
14955        let separator_y = elements
14956            .iter()
14957            .find_map(|element| match element {
14958                PositionedElement::Line { start, .. } => Some(start.y),
14959                _ => None,
14960            })
14961            .expect("a page with a footnote draws a separator line");
14962
14963        let mut wrapped = false;
14964        let mut first_y = None;
14965        for element in elements {
14966            let PositionedElement::Text(run) = element else {
14967                continue;
14968            };
14969            if run.origin.y <= separator_y {
14970                continue;
14971            }
14972            let first = *first_y.get_or_insert(run.origin.y);
14973            if run.origin.y > first + 0.01 {
14974                wrapped = true;
14975            }
14976            let right_edge = run.origin.x + run.advances.iter().sum::<f64>();
14977            assert!(
14978                right_edge <= right_margin + 0.01,
14979                "note text reaches {right_edge}, past the right margin {right_margin}"
14980            );
14981        }
14982        assert!(wrapped, "the note must wrap for this test to mean anything");
14983    }
14984
14985    #[test]
14986    fn a_tab_inside_a_footnote_still_advances_the_text_after_it() {
14987        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14988        use rdocx_oxml::text::CT_R;
14989
14990        // Two notes differing only by a tab between their runs. The tab is not
14991        // drawn, but it occupies width, so the run after it must shift right.
14992        let build = |with_tab: bool| {
14993            let mut doc = rdocx_oxml::document::CT_Document::new();
14994            let mut body = CT_P::new();
14995            body.add_run("Body");
14996            let mut marker_run = CT_R::new("");
14997            marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
14998            body.runs.push(marker_run);
14999            doc.body.add_paragraph(body);
15000
15001            let mut note = CT_P::new();
15002            note.add_run("Alpha");
15003            if with_tab {
15004                let mut tab_run = CT_R::new("");
15005                tab_run.content = vec![RunContent::Tab];
15006                note.runs.push(tab_run);
15007            }
15008            note.add_run("Beta");
15009
15010            LayoutInput {
15011                revision_view: crate::input::RevisionView::Accepted,
15012                automatic_hyphenation: false,
15013                math_properties: None,
15014                document: doc,
15015                styles: CT_Styles::new_default(),
15016                numbering: None,
15017                headers: HashMap::new(),
15018                footers: HashMap::new(),
15019                images: HashMap::new(),
15020                charts: HashMap::new(),
15021                chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15022                chart_color_map: oxml_drawing::color::ColorMap::default(),
15023                core_properties: None,
15024                hyperlink_urls: HashMap::new(),
15025                footnotes: Some(CT_Footnotes {
15026                    footnotes: vec![CT_Footnote {
15027                        id: 1,
15028                        note_type: NoteType::Normal,
15029                        paragraphs: vec![note],
15030                    }],
15031                }),
15032                endnotes: None,
15033                theme: None,
15034                fonts: Vec::new(),
15035            }
15036        };
15037
15038        let mut engine = Engine::new();
15039        let plain = engine.layout(&build(false)).expect("layout succeeds");
15040        let tabbed = engine.layout(&build(true)).expect("layout succeeds");
15041
15042        let plain_x = footnote_glyph_x(&plain.pages[0]);
15043        let tabbed_x = footnote_glyph_x(&tabbed.pages[0]);
15044
15045        // Marker and both runs are drawn in each case. The tab draws nothing.
15046        assert_eq!(plain_x.len(), 3, "plain note glyphs {plain_x:?}");
15047        assert_eq!(tabbed_x.len(), 3, "tabbed note glyphs {tabbed_x:?}");
15048        assert!(
15049            tabbed_x[2] > plain_x[2] + 1.0,
15050            "the run after a tab must shift right, plain {plain_x:?} tabbed {tabbed_x:?}"
15051        );
15052    }
15053
15054    #[test]
15055    fn footnote_segment_advance_matches_body_segment_advance() {
15056        let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
15057        let mut engine = Engine::new();
15058        let output = engine.layout(&input).expect("layout succeeds");
15059        let page = &output.pages[0];
15060
15061        let elements = compatibility_page_elements(page);
15062        let separator_y = elements
15063            .iter()
15064            .find_map(|element| match element {
15065                PositionedElement::Line { start, .. } => Some(start.y),
15066                _ => None,
15067            })
15068            .expect("a page with a footnote draws a separator line");
15069
15070        // Gaps between consecutive note segments must equal the width of the
15071        // segment that precedes them, which is what the body path advances by.
15072        let notes: Vec<&oxml_layout::GlyphRun> = elements
15073            .iter()
15074            .filter_map(|element| match element {
15075                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run),
15076                _ => None,
15077            })
15078            .skip(1) // the marker, which is positioned independently
15079            .collect();
15080
15081        assert_eq!(notes.len(), 3, "expected three note segments");
15082        for pair in notes.windows(2) {
15083            let advance: f64 = pair[0].advances.iter().sum();
15084            let gap = pair[1].origin.x - pair[0].origin.x;
15085            assert!(
15086                (gap - advance).abs() < 0.01,
15087                "gap {gap} should equal preceding segment advance {advance}"
15088            );
15089        }
15090    }
15091
15092    // F-X013b, reservation and splitting.
15093
15094    /// A document of `body_paras` paragraphs. The paragraph at
15095    /// `ref_positions` each carry a reference to note 1, whose content is
15096    /// `note_paras` paragraphs of `note_text`.
15097    fn make_noted_document(
15098        body_paras: usize,
15099        ref_positions: &[usize],
15100        note_paras: usize,
15101        note_text: &str,
15102        continuation_separator: bool,
15103    ) -> LayoutInput {
15104        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
15105        use rdocx_oxml::text::CT_R;
15106
15107        let mut doc = rdocx_oxml::document::CT_Document::new();
15108        for index in 0..body_paras {
15109            let mut para = CT_P::new();
15110            para.add_run("Body paragraph text that occupies a line of the page.");
15111            if ref_positions.contains(&index) {
15112                let mut marker = CT_R::new("");
15113                marker.content = vec![RunContent::FootnoteRef { id: 1 }];
15114                para.runs.push(marker);
15115            }
15116            doc.body.add_paragraph(para);
15117        }
15118
15119        let mut entries = Vec::new();
15120        if continuation_separator {
15121            entries.push(CT_Footnote {
15122                id: 0,
15123                note_type: NoteType::ContinuationSeparator,
15124                paragraphs: vec![CT_P::new()],
15125            });
15126        }
15127        entries.push(CT_Footnote {
15128            id: 1,
15129            note_type: NoteType::Normal,
15130            paragraphs: (0..note_paras)
15131                .map(|_| {
15132                    let mut p = CT_P::new();
15133                    p.add_run(note_text);
15134                    p
15135                })
15136                .collect(),
15137        });
15138
15139        LayoutInput {
15140            revision_view: crate::input::RevisionView::Accepted,
15141            automatic_hyphenation: false,
15142            math_properties: None,
15143            document: doc,
15144            styles: CT_Styles::new_default(),
15145            numbering: None,
15146            headers: HashMap::new(),
15147            footers: HashMap::new(),
15148            images: HashMap::new(),
15149            charts: HashMap::new(),
15150            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15151            chart_color_map: oxml_drawing::color::ColorMap::default(),
15152            core_properties: None,
15153            hyperlink_urls: HashMap::new(),
15154            footnotes: Some(CT_Footnotes { footnotes: entries }),
15155            endnotes: None,
15156            theme: None,
15157            fonts: Vec::new(),
15158        }
15159    }
15160
15161    /// Split a page into the glyphs drawn above the note separator and those
15162    /// drawn below it. Notes are emitted after body content, so the separator
15163    /// is the boundary.
15164    fn split_at_separator(
15165        page: &oxml_layout::output::PageFrame,
15166    ) -> Option<(f64, Vec<f64>, Vec<String>)> {
15167        let elements = compatibility_page_elements(page);
15168        let separator_index = elements.iter().position(|element| {
15169            matches!(element, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001)
15170        })?;
15171        let PositionedElement::Line { start, end, .. } = elements[separator_index] else {
15172            return None;
15173        };
15174        let separator_y = start.y;
15175        let separator_width = end.x - start.x;
15176
15177        let body_ys: Vec<f64> = elements[..separator_index]
15178            .iter()
15179            .filter_map(|element| match element {
15180                PositionedElement::Text(run) => Some(run.origin.y),
15181                _ => None,
15182            })
15183            .collect();
15184        let note_text: Vec<String> = elements[separator_index + 1..]
15185            .iter()
15186            .filter_map(|element| match element {
15187                PositionedElement::Text(run) => Some(run.text.clone()),
15188                _ => None,
15189            })
15190            .collect();
15191
15192        let _ = separator_y;
15193        Some((separator_width, body_ys, note_text))
15194    }
15195
15196    fn separator_y_of(page: &oxml_layout::output::PageFrame) -> Option<f64> {
15197        compatibility_page_elements(page)
15198            .into_iter()
15199            .find_map(|element| match element {
15200                PositionedElement::Line { start, width, .. } if (*width - 0.5).abs() < 0.001 => {
15201                    Some(start.y)
15202                }
15203                _ => None,
15204            })
15205    }
15206
15207    #[test]
15208    fn a_page_whose_body_fills_the_text_area_does_not_overlap_its_notes() {
15209        // Enough body to reach the bottom margin, with the reference early so
15210        // the note is owed by the first page.
15211        let input = make_noted_document(
15212            60,
15213            &[0],
15214            2,
15215            "A note long enough to wrap onto a second line of the note area.",
15216            false,
15217        );
15218        let mut engine = Engine::new();
15219        let output = engine.layout(&input).expect("layout succeeds");
15220        let page = &output.pages[0];
15221
15222        let separator_y = separator_y_of(page).expect("the page draws a separator");
15223        let (_, body_ys, note_text) = split_at_separator(page).unwrap();
15224
15225        assert!(!note_text.is_empty(), "the note must be drawn");
15226        let lowest_body = body_ys.iter().cloned().fold(f64::MIN, f64::max);
15227        assert!(
15228            lowest_body < separator_y,
15229            "body text reaches {lowest_body}, at or below the separator at {separator_y}"
15230        );
15231    }
15232
15233    #[test]
15234    fn a_page_referencing_one_note_twice_reserves_it_once() {
15235        let input = make_noted_document(4, &[0, 1], 1, "Referenced twice from one page.", false);
15236        let mut engine = Engine::new();
15237        let output = engine.layout(&input).expect("layout succeeds");
15238        let page = &output.pages[0];
15239
15240        let separators = compatibility_page_elements(page)
15241            .into_iter()
15242            .filter(|e| matches!(e, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001))
15243            .count();
15244        assert_eq!(separators, 1, "one note area, so one separator");
15245
15246        let (_, _, note_text) = split_at_separator(page).unwrap();
15247        let markers = note_text.iter().filter(|t| t.as_str() == "1").count();
15248        assert_eq!(markers, 1, "the note is drawn once, got {note_text:?}");
15249    }
15250
15251    #[test]
15252    fn a_note_taller_than_its_remaining_space_continues_on_the_next_page() {
15253        // 120 note paragraphs exceed a single page, so the note has to break.
15254        let input = make_noted_document(30, &[25], 120, "Note paragraph line.", true);
15255        let mut engine = Engine::new();
15256        let output = engine.layout(&input).expect("layout succeeds");
15257
15258        let note_pages: Vec<usize> = output
15259            .pages
15260            .iter()
15261            .enumerate()
15262            .filter(|(_, page)| separator_y_of(page).is_some())
15263            .map(|(index, _)| index)
15264            .collect();
15265
15266        assert!(
15267            note_pages.len() >= 2,
15268            "a note taller than a page must span pages, got {note_pages:?}"
15269        );
15270
15271        let first = split_at_separator(&output.pages[note_pages[0]]).unwrap().2;
15272        let second = split_at_separator(&output.pages[note_pages[1]]).unwrap().2;
15273
15274        assert!(!first.is_empty(), "the first page draws part of the note");
15275        assert!(!second.is_empty(), "the next page draws the rest");
15276        assert_eq!(
15277            first.iter().filter(|t| t.as_str() == "1").count(),
15278            1,
15279            "the marker is drawn on the page the note starts on"
15280        );
15281        assert_eq!(
15282            second.iter().filter(|t| t.as_str() == "1").count(),
15283            0,
15284            "a continuation does not repeat the marker, got {second:?}"
15285        );
15286    }
15287
15288    #[test]
15289    fn a_continued_note_draws_the_continuation_separator() {
15290        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15291
15292        let widths = |continuation: bool| {
15293            let input = make_noted_document(30, &[25], 120, "Note paragraph line.", continuation);
15294            let mut engine = Engine::new();
15295            let output = engine.layout(&input).expect("layout succeeds");
15296            let pages: Vec<usize> = output
15297                .pages
15298                .iter()
15299                .enumerate()
15300                .filter(|(_, page)| separator_y_of(page).is_some())
15301                .map(|(index, _)| index)
15302                .collect();
15303            assert!(pages.len() >= 2, "the note must span pages");
15304            (
15305                split_at_separator(&output.pages[pages[0]]).unwrap().0,
15306                split_at_separator(&output.pages[pages[1]]).unwrap().0,
15307            )
15308        };
15309
15310        let (first, second) = widths(true);
15311        assert!(
15312            (first - geometry.content_width() * 0.33).abs() < 0.5,
15313            "a note starting on its page gets the short rule, got {first}"
15314        );
15315        assert!(
15316            (second - geometry.content_width()).abs() < 0.5,
15317            "a continued note gets the full-width rule, got {second}"
15318        );
15319
15320        // A document defining no continuation separator keeps the short rule.
15321        let (_, second) = widths(false);
15322        assert!(
15323            (second - geometry.content_width() * 0.33).abs() < 0.5,
15324            "without a continuation separator the short rule is kept, got {second}"
15325        );
15326    }
15327
15328    #[test]
15329    fn an_oversized_note_still_leaves_room_for_body_text() {
15330        // A note several pages tall, referenced from the first paragraph.
15331        let input = make_noted_document(3, &[0], 200, "A line of an enormous note.", true);
15332        let mut engine = Engine::new();
15333        let output = engine.layout(&input).expect("layout terminates");
15334
15335        let (_, body_ys, _) = split_at_separator(&output.pages[0]).unwrap();
15336        assert!(
15337            !body_ys.is_empty(),
15338            "an oversized note must not starve the page of body text"
15339        );
15340        assert!(
15341            output.pages.len() > 1 && output.pages.len() < 100,
15342            "the note spills over a bounded number of pages, got {}",
15343            output.pages.len()
15344        );
15345
15346        // The note area has to stay on the page. Placing an oversized note
15347        // whole would push its separator off the top of the sheet.
15348        for (index, page) in output.pages.iter().enumerate() {
15349            let Some(separator_y) = separator_y_of(page) else {
15350                continue;
15351            };
15352            assert!(
15353                separator_y >= 0.0,
15354                "page {} draws its separator at {separator_y}, off the sheet",
15355                index + 1
15356            );
15357        }
15358    }
15359
15360    #[test]
15361    fn a_note_is_drawn_on_the_page_that_carries_its_reference() {
15362        // Sweeping the reference across the document is what catches the two
15363        // ways a note drifts off its own page: notes claimed for a paragraph
15364        // that then moves, and a note area measured from a cursor that still
15365        // holds the previous paragraph's trailing space.
15366        let mut mismatches = Vec::new();
15367        for position in 0..60 {
15368            let input = make_noted_document(60, &[position], 1, "Note text.", false);
15369            let mut engine = Engine::new();
15370            let output = engine.layout(&input).expect("layout succeeds");
15371
15372            let reference_page = output.pages.iter().position(|page| {
15373                compatibility_page_elements(page)
15374                    .into_iter()
15375                    .any(|element| {
15376                        matches!(element, PositionedElement::Text(run)
15377                        if run.note == Some(oxml_layout::NoteRef {
15378                            stream: oxml_layout::NoteStream::Footnote,
15379                            id: 1,
15380                        }))
15381                    })
15382            });
15383            let note_page = output
15384                .pages
15385                .iter()
15386                .position(|page| separator_y_of(page).is_some());
15387
15388            if reference_page != note_page {
15389                mismatches.push((position, reference_page, note_page));
15390            }
15391        }
15392
15393        assert!(
15394            mismatches.is_empty(),
15395            "note and reference landed on different pages for (position, ref, note): {mismatches:?}"
15396        );
15397    }
15398
15399    // F-X013c, endnotes at the document end.
15400
15401    /// A document whose single body paragraph references footnote `id` and
15402    /// endnote `id`, with each stream giving that number different text.
15403    fn make_document_with_both_streams(id: i32, body_paras: usize) -> LayoutInput {
15404        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
15405        use rdocx_oxml::text::CT_R;
15406
15407        let mut doc = rdocx_oxml::document::CT_Document::new();
15408        for index in 0..body_paras {
15409            let mut para = CT_P::new();
15410            para.add_run("Body paragraph text that occupies a line of the page.");
15411            if index == 0 {
15412                let mut foot = CT_R::new("");
15413                foot.content = vec![RunContent::FootnoteRef { id }];
15414                para.runs.push(foot);
15415                let mut end = CT_R::new("");
15416                end.content = vec![RunContent::EndnoteRef { id }];
15417                para.runs.push(end);
15418            }
15419            doc.body.add_paragraph(para);
15420        }
15421
15422        let note = |text: &str| {
15423            let mut p = CT_P::new();
15424            p.add_run(text);
15425            CT_Footnote {
15426                id,
15427                note_type: NoteType::Normal,
15428                paragraphs: vec![p],
15429            }
15430        };
15431
15432        LayoutInput {
15433            revision_view: crate::input::RevisionView::Accepted,
15434            automatic_hyphenation: false,
15435            math_properties: None,
15436            document: doc,
15437            styles: CT_Styles::new_default(),
15438            numbering: None,
15439            headers: HashMap::new(),
15440            footers: HashMap::new(),
15441            images: HashMap::new(),
15442            charts: HashMap::new(),
15443            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15444            chart_color_map: oxml_drawing::color::ColorMap::default(),
15445            core_properties: None,
15446            hyperlink_urls: HashMap::new(),
15447            footnotes: Some(CT_Footnotes {
15448                footnotes: vec![note("FOOTNOTETEXT")],
15449            }),
15450            endnotes: Some(CT_Footnotes {
15451                footnotes: vec![note("ENDNOTETEXT")],
15452            }),
15453            theme: None,
15454            fonts: Vec::new(),
15455        }
15456    }
15457
15458    fn page_text(page: &oxml_layout::output::PageFrame) -> String {
15459        compatibility_page_elements(page)
15460            .into_iter()
15461            .filter_map(|element| match element {
15462                PositionedElement::Text(run) => Some(run.text.as_str()),
15463                _ => None,
15464            })
15465            .collect::<Vec<_>>()
15466            .join(" ")
15467    }
15468
15469    #[test]
15470    fn a_footnote_and_an_endnote_sharing_a_number_render_their_own_text() {
15471        let input = make_document_with_both_streams(2, 3);
15472        let mut engine = Engine::new();
15473        let output = engine.layout(&input).expect("layout succeeds");
15474
15475        let all: String = output
15476            .pages
15477            .iter()
15478            .map(|page| page_text(page))
15479            .collect::<Vec<_>>()
15480            .join(" | ");
15481        assert!(
15482            all.contains("FOOTNOTETEXT"),
15483            "the footnote must render its own text, got {all}"
15484        );
15485        assert!(
15486            all.contains("ENDNOTETEXT"),
15487            "the endnote must render its own text, got {all}"
15488        );
15489    }
15490
15491    #[test]
15492    fn endnotes_render_after_the_last_body_page() {
15493        let input = make_document_with_both_streams(2, 3);
15494        let mut engine = Engine::new();
15495        let output = engine.layout(&input).expect("layout succeeds");
15496
15497        let endnote_page = output
15498            .pages
15499            .iter()
15500            .position(|page| page_text(page).contains("ENDNOTETEXT"))
15501            .expect("the endnote is rendered somewhere");
15502        let last_body_page = output
15503            .pages
15504            .iter()
15505            .rposition(|page| page_text(page).contains("occupies"))
15506            .expect("the body is rendered somewhere");
15507
15508        assert!(
15509            endnote_page > last_body_page,
15510            "endnotes come after every body page, endnote on {endnote_page} and body to {last_body_page}"
15511        );
15512        assert!(
15513            !page_text(&output.pages[endnote_page]).contains("occupies"),
15514            "an endnote page carries no body text"
15515        );
15516    }
15517
15518    #[test]
15519    fn footnotes_and_endnotes_keep_their_own_regions() {
15520        let input = make_document_with_both_streams(2, 3);
15521        let mut engine = Engine::new();
15522        let output = engine.layout(&input).expect("layout succeeds");
15523
15524        let footnote_page = output
15525            .pages
15526            .iter()
15527            .position(|page| page_text(page).contains("FOOTNOTETEXT"))
15528            .expect("the footnote is rendered");
15529
15530        // The footnote shares the page that carries its reference.
15531        assert!(
15532            page_text(&output.pages[footnote_page]).contains("occupies"),
15533            "a footnote sits on the page carrying its reference"
15534        );
15535        assert!(
15536            separator_y_of(&output.pages[footnote_page]).is_some(),
15537            "the footnote page draws a separator"
15538        );
15539
15540        // The endnote page is a different page, and draws no separator,
15541        // because there is no body text there to divide it from.
15542        let endnote_page = output
15543            .pages
15544            .iter()
15545            .position(|page| page_text(page).contains("ENDNOTETEXT"))
15546            .expect("the endnote is rendered");
15547        assert_ne!(footnote_page, endnote_page, "the two regions are distinct");
15548        assert!(
15549            separator_y_of(&output.pages[endnote_page]).is_none(),
15550            "an endnote page draws no separator rule"
15551        );
15552    }
15553
15554    #[test]
15555    fn an_endnote_reference_does_not_reserve_space_at_the_page_foot() {
15556        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
15557        use rdocx_oxml::text::CT_R;
15558
15559        // The same document twice, once with an endnote reference and once
15560        // with none. An endnote costs its page nothing, so the body must
15561        // paginate identically.
15562        let build = |with_endnote: bool| {
15563            let mut doc = rdocx_oxml::document::CT_Document::new();
15564            for index in 0..60 {
15565                let mut para = CT_P::new();
15566                para.add_run("Body paragraph text that occupies a line of the page.");
15567                if index == 0 && with_endnote {
15568                    let mut end = CT_R::new("");
15569                    end.content = vec![RunContent::EndnoteRef { id: 1 }];
15570                    para.runs.push(end);
15571                }
15572                doc.body.add_paragraph(para);
15573            }
15574            let mut note = CT_P::new();
15575            note.add_run("An endnote that would be tall in the margin.");
15576            LayoutInput {
15577                revision_view: crate::input::RevisionView::Accepted,
15578                automatic_hyphenation: false,
15579                math_properties: None,
15580                document: doc,
15581                styles: CT_Styles::new_default(),
15582                numbering: None,
15583                headers: HashMap::new(),
15584                footers: HashMap::new(),
15585                images: HashMap::new(),
15586                charts: HashMap::new(),
15587                chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15588                chart_color_map: oxml_drawing::color::ColorMap::default(),
15589                core_properties: None,
15590                hyperlink_urls: HashMap::new(),
15591                footnotes: None,
15592                endnotes: Some(CT_Footnotes {
15593                    footnotes: vec![CT_Footnote {
15594                        id: 1,
15595                        note_type: NoteType::Normal,
15596                        paragraphs: vec![note],
15597                    }],
15598                }),
15599                theme: None,
15600                fonts: Vec::new(),
15601            }
15602        };
15603
15604        let mut engine = Engine::new();
15605        let plain = engine.layout(&build(false)).expect("layout succeeds");
15606        let noted = engine.layout(&build(true)).expect("layout succeeds");
15607
15608        // One extra page for the endnote itself, and no separator anywhere.
15609        assert_eq!(
15610            noted.pages.len(),
15611            plain.pages.len() + 1,
15612            "an endnote adds its own page and takes none from the body"
15613        );
15614        for (index, page) in noted.pages.iter().enumerate() {
15615            if index < plain.pages.len() {
15616                assert!(
15617                    separator_y_of(page).is_none(),
15618                    "page {} reserved foot space for an endnote",
15619                    index + 1
15620                );
15621            }
15622        }
15623
15624        // Body pagination is untouched.
15625        for (index, plain_page) in plain.pages.iter().enumerate() {
15626            let body_lines = |page: &oxml_layout::output::PageFrame| {
15627                compatibility_page_elements(page)
15628                    .into_iter()
15629                    .filter(|element| {
15630                        matches!(element, PositionedElement::Text(run)
15631                            if run.text.starts_with("occupies"))
15632                    })
15633                    .count()
15634            };
15635            assert_eq!(
15636                body_lines(plain_page),
15637                body_lines(&noted.pages[index]),
15638                "page {} holds a different amount of body text",
15639                index + 1
15640            );
15641        }
15642    }
15643
15644    // F-X016, text wrapping around a floating drawing.
15645
15646    /// A document of one long paragraph, with a floating drawing anchored to
15647    /// it. `align` places the drawing, `wrap` says how text should treat it.
15648    fn make_wrapping_document(
15649        wrap: rdocx_oxml::drawing::WrapType,
15650        align: Option<rdocx_oxml::drawing::AnchorAlignH>,
15651        width_pt: f64,
15652        height_pt: f64,
15653        dist_pt: f64,
15654    ) -> LayoutInput {
15655        use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV};
15656        use rdocx_oxml::text::CT_R;
15657        use rdocx_oxml::units::Emu;
15658
15659        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
15660
15661        let mut doc = rdocx_oxml::document::CT_Document::new();
15662        let mut para = CT_P::new();
15663        // Long enough that many lines sit below the drawing, which is what
15664        // makes "returns to the margin" a meaningful assertion.
15665        let mut body = String::new();
15666        for index in 0..40 {
15667            body.push_str(&format!(
15668                "Sentence {index} of running text that fills the paragraph out. "
15669            ));
15670        }
15671        para.add_run(&body);
15672
15673        let mut anchor = CT_Anchor::background("rId1", 0, 0);
15674        anchor.extent_cx = emu(width_pt);
15675        anchor.extent_cy = emu(height_pt);
15676        anchor.behind_doc = false;
15677        anchor.wrap = wrap;
15678        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
15679        anchor.pos_h_align = align;
15680        anchor.pos_v_relative_from = ST_RelativeFromV::Paragraph;
15681        anchor.pos_v_offset = Emu(0);
15682        anchor.dist_t = emu(dist_pt);
15683        anchor.dist_b = emu(dist_pt);
15684        anchor.dist_l = emu(dist_pt);
15685        anchor.dist_r = emu(dist_pt);
15686
15687        let mut drawing_run = CT_R::new("");
15688        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
15689            inline: None,
15690            anchor: Some(anchor),
15691        })];
15692        para.runs.push(drawing_run);
15693        doc.body.add_paragraph(para);
15694
15695        let mut images = HashMap::new();
15696        images.insert(
15697            "rId1".to_string(),
15698            ImageData {
15699                data: vec![0u8; 8],
15700                content_type: "image/png".to_string(),
15701            },
15702        );
15703
15704        LayoutInput {
15705            revision_view: crate::input::RevisionView::Accepted,
15706            automatic_hyphenation: false,
15707            math_properties: None,
15708            document: doc,
15709            styles: CT_Styles::new_default(),
15710            numbering: None,
15711            headers: HashMap::new(),
15712            footers: HashMap::new(),
15713            images,
15714            charts: HashMap::new(),
15715            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15716            chart_color_map: oxml_drawing::color::ColorMap::default(),
15717            core_properties: None,
15718            hyperlink_urls: HashMap::new(),
15719            footnotes: None,
15720            endnotes: None,
15721            theme: None,
15722            fonts: Vec::new(),
15723        }
15724    }
15725
15726    /// The x origin and right edge of every body text run, by line.
15727    fn text_extents(page: &oxml_layout::output::PageFrame) -> Vec<(f64, f64)> {
15728        let mut by_line: Vec<(f64, f64, f64)> = Vec::new();
15729        for element in compatibility_page_elements(page) {
15730            let PositionedElement::Text(run) = element else {
15731                continue;
15732            };
15733            let right = run.origin.x + run.advances.iter().sum::<f64>();
15734            if let Some(entry) = by_line
15735                .iter_mut()
15736                .find(|(y, _, _)| (*y - run.origin.y).abs() < 0.01)
15737            {
15738                entry.1 = entry.1.min(run.origin.x);
15739                entry.2 = entry.2.max(right);
15740            } else {
15741                by_line.push((run.origin.y, run.origin.x, right));
15742            }
15743        }
15744        by_line.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
15745        by_line.into_iter().map(|(_, l, r)| (l, r)).collect()
15746    }
15747
15748    #[test]
15749    fn text_wraps_beside_a_left_aligned_square_drawing() {
15750        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
15751
15752        let input =
15753            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
15754        let mut engine = Engine::new();
15755        let output = engine.layout(&input).expect("layout succeeds");
15756        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15757        let extents = text_extents(&output.pages[0]);
15758
15759        assert!(
15760            extents.len() > 2,
15761            "the paragraph must wrap, got {extents:?}"
15762        );
15763
15764        // Lines beside the drawing start to its right, past width plus distR.
15765        let expected_left = geometry.margin_left + 100.0 + 5.0;
15766        assert!(
15767            (extents[0].0 - expected_left).abs() < 1.0,
15768            "first line should start at {expected_left}, got {:?}",
15769            extents[0]
15770        );
15771
15772        // A line below the drawing returns to the margin.
15773        let last = extents.last().unwrap();
15774        assert!(
15775            (last.0 - geometry.margin_left).abs() < 1.0,
15776            "the last line should return to the margin, got {last:?}"
15777        );
15778    }
15779
15780    #[test]
15781    fn drawing_reflow_can_select_a_conditional_hyphen() {
15782        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
15783
15784        let mut input =
15785            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 400.0, 40.0, 5.0);
15786        input.automatic_hyphenation = true;
15787        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
15788            panic!("expected paragraph")
15789        };
15790        paragraph.runs[0] = rdocx_oxml::text::CT_R::new("representation representation");
15791        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
15792            language: Some("en-US".to_owned()),
15793            ..Default::default()
15794        });
15795
15796        let text = output_text(&deterministic_layout(&input));
15797        assert!(text.iter().any(|item| item == "-"), "{text:?}");
15798    }
15799
15800    #[test]
15801    fn drawing_reflow_retains_the_exact_word_rich_baseline() {
15802        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
15803
15804        let mut input =
15805            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
15806        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
15807            panic!("expected paragraph")
15808        };
15809        paragraph.properties = Some(CT_PPr {
15810            line_spacing: Some(rdocx_oxml::units::Twips(480)),
15811            line_rule: Some("exact".to_owned()),
15812            ..Default::default()
15813        });
15814        paragraph.runs[0] = rdocx_oxml::text::CT_R::new(&"العربية مرحبا بالعالم ".repeat(12));
15815        paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
15816            sz: Some(rdocx_oxml::units::HalfPoint(48)),
15817            language: Some("ar-SA".to_owned()),
15818            language_bidi: Some("ar-SA".to_owned()),
15819            ..Default::default()
15820        });
15821
15822        let output = deterministic_layout(&input);
15823        let first = multilingual_runs(&output)
15824            .into_iter()
15825            .min_by(|left, right| left.origin.y.total_cmp(&right.origin.y))
15826            .expect("wrapped paragraph emits rich text");
15827        assert!(
15828            (first.origin.y - 91.2).abs() < 0.001,
15829            "wrapped rich baseline was {}, expected 91.2",
15830            first.origin.y
15831        );
15832    }
15833
15834    #[test]
15835    fn drawing_reflow_retains_the_explicit_ltr_paragraph_base() {
15836        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
15837
15838        let mut input =
15839            make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
15840        input.automatic_hyphenation = true;
15841        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
15842            panic!("expected paragraph")
15843        };
15844        paragraph.properties = Some(CT_PPr {
15845            bidi: Some(false),
15846            ..Default::default()
15847        });
15848        let drawing = paragraph.runs.pop().expect("wrapping drawing run");
15849        let mut arabic = CT_R::new("العربية ");
15850        arabic.properties = Some(CT_RPr {
15851            language_bidi: Some("ar-SA".to_owned()),
15852            ..Default::default()
15853        });
15854        let mut english = CT_R::new(&"representation ".repeat(12));
15855        english.properties = Some(CT_RPr {
15856            language: Some("en-US".to_owned()),
15857            ..Default::default()
15858        });
15859        paragraph.runs = vec![arabic, english, drawing];
15860
15861        let output = deterministic_layout(&input);
15862        let arabic = multilingual_runs(&output)
15863            .into_iter()
15864            .min_by(|left, right| left.origin.y.total_cmp(&right.origin.y))
15865            .expect("Arabic rich run");
15866        let english = output
15867            .pages
15868            .iter()
15869            .flat_map(|page| compatibility_page_elements(page))
15870            .filter_map(|element| match element {
15871                PositionedElement::Text(run) if run.text.contains("repre") => Some(run),
15872                _ => None,
15873            })
15874            .filter(|run| (run.origin.y - arabic.origin.y).abs() < 0.001)
15875            .min_by(|left, right| left.origin.x.total_cmp(&right.origin.x))
15876            .expect("hyphenatable English shares the first line");
15877        assert!(
15878            arabic.origin.x < english.origin.x,
15879            "explicit LTR must survive drawing reflow: Arabic {}, English {}",
15880            arabic.origin.x,
15881            english.origin.x
15882        );
15883    }
15884
15885    #[test]
15886    fn text_wraps_beside_a_right_aligned_square_drawing() {
15887        use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
15888
15889        let input = make_wrapping_document(
15890            WrapType::Square,
15891            Some(AnchorAlignH::Right),
15892            100.0,
15893            40.0,
15894            5.0,
15895        );
15896        let mut engine = Engine::new();
15897        let output = engine.layout(&input).expect("layout succeeds");
15898        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15899        let extents = text_extents(&output.pages[0]);
15900
15901        assert!(
15902            extents.len() > 2,
15903            "the paragraph must wrap, got {extents:?}"
15904        );
15905
15906        // Lines beside the drawing still start at the margin but end early.
15907        let text_right = geometry.page_width - geometry.margin_right;
15908        let drawing_left = text_right - 100.0;
15909        assert!(
15910            (extents[0].0 - geometry.margin_left).abs() < 1.0,
15911            "a right-aligned drawing does not move the line start, got {:?}",
15912            extents[0]
15913        );
15914        assert!(
15915            extents[0].1 <= drawing_left - 5.0 + 1.0,
15916            "the first line should stop before the drawing at {}, got {:?}",
15917            drawing_left - 5.0,
15918            extents[0]
15919        );
15920
15921        // Some line below the drawing runs past where the drawing sat, which
15922        // is only possible once the reservation stops applying. The final line
15923        // of a paragraph is naturally short, so the widest is the fair test.
15924        let widest = extents
15925            .iter()
15926            .map(|(_, right)| *right)
15927            .fold(f64::MIN, f64::max);
15928        assert!(
15929            widest > drawing_left,
15930            "a line below the drawing should reach past {drawing_left}, got {extents:?}"
15931        );
15932    }
15933
15934    #[test]
15935    fn a_top_and_bottom_drawing_pushes_text_below_it() {
15936        use rdocx_oxml::drawing::WrapType;
15937
15938        let input = make_wrapping_document(WrapType::TopAndBottom, None, 100.0, 40.0, 5.0);
15939        let mut engine = Engine::new();
15940        let output = engine.layout(&input).expect("layout succeeds");
15941        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15942        let extents = text_extents(&output.pages[0]);
15943
15944        assert!(!extents.is_empty(), "the paragraph renders");
15945
15946        // The drawing sits at the paragraph top, so text starts below its
15947        // bottom edge plus distB.
15948        let first_baseline = compatibility_page_elements(&output.pages[0])
15949            .into_iter()
15950            .find_map(|element| match element {
15951                PositionedElement::Text(run) => Some(run.origin.y),
15952                _ => None,
15953            })
15954            .expect("text is rendered");
15955        let drawing_bottom = geometry.margin_top + 40.0 + 5.0;
15956        assert!(
15957            first_baseline >= drawing_bottom,
15958            "the first line at {first_baseline} should sit below {drawing_bottom}"
15959        );
15960    }
15961
15962    #[test]
15963    fn a_wrap_none_drawing_leaves_text_untouched() {
15964        use rdocx_oxml::drawing::WrapType;
15965
15966        // The identity case. A drawing that does not wrap must not move a
15967        // single glyph, which is what keeps every recorded baseline still.
15968        let with = make_wrapping_document(WrapType::None, None, 100.0, 40.0, 5.0);
15969        let mut engine = Engine::new();
15970        let output = engine.layout(&with).expect("layout succeeds");
15971        let wrapped_extents = text_extents(&output.pages[0]);
15972
15973        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15974        for (left, _) in &wrapped_extents {
15975            assert!(
15976                (left - geometry.margin_left).abs() < 0.01,
15977                "a wrapNone drawing must not indent any line, got {wrapped_extents:?}"
15978            );
15979        }
15980    }
15981
15982    #[test]
15983    fn a_drawing_anchored_to_a_later_paragraph_still_pushes_text_aside() {
15984        use rdocx_oxml::drawing::{
15985            AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
15986            WrapType,
15987        };
15988        use rdocx_oxml::text::CT_R;
15989        use rdocx_oxml::units::Emu;
15990
15991        // Word routinely anchors the arrow beside a paragraph to the paragraph
15992        // after it, which is what the external contribution's own sample does.
15993        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
15994        let mut doc = rdocx_oxml::document::CT_Document::new();
15995
15996        let mut first = CT_P::new();
15997        let mut body = String::new();
15998        for index in 0..40 {
15999            body.push_str(&format!("Sentence {index} of running text to fill lines. "));
16000        }
16001        first.add_run(&body);
16002        doc.body.add_paragraph(first);
16003
16004        let mut second = CT_P::new();
16005        second.add_run("A later paragraph that owns the drawing.");
16006        let mut anchor = CT_Anchor::background("rId1", 0, 0);
16007        anchor.extent_cx = emu(100.0);
16008        anchor.extent_cy = emu(40.0);
16009        anchor.behind_doc = false;
16010        anchor.wrap = WrapType::Square;
16011        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
16012        anchor.pos_h_align = Some(AnchorAlignH::Left);
16013        // Margin-relative, so its position does not depend on where the
16014        // paragraph that owns it lands.
16015        anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
16016        anchor.pos_v_align = Some(AnchorAlignV::Top);
16017        let mut drawing_run = CT_R::new("");
16018        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
16019            inline: None,
16020            anchor: Some(anchor),
16021        })];
16022        second.runs.push(drawing_run);
16023        doc.body.add_paragraph(second);
16024
16025        let mut images = HashMap::new();
16026        images.insert(
16027            "rId1".to_string(),
16028            ImageData {
16029                data: vec![0u8; 8],
16030                content_type: "image/png".to_string(),
16031            },
16032        );
16033
16034        let input = LayoutInput {
16035            revision_view: crate::input::RevisionView::Accepted,
16036            automatic_hyphenation: false,
16037            math_properties: None,
16038            document: doc,
16039            styles: CT_Styles::new_default(),
16040            numbering: None,
16041            headers: HashMap::new(),
16042            footers: HashMap::new(),
16043            images,
16044            charts: HashMap::new(),
16045            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
16046            chart_color_map: oxml_drawing::color::ColorMap::default(),
16047            core_properties: None,
16048            hyperlink_urls: HashMap::new(),
16049            footnotes: None,
16050            endnotes: None,
16051            theme: None,
16052            fonts: Vec::new(),
16053        };
16054
16055        let mut engine = Engine::new();
16056        let output = engine.layout(&input).expect("layout succeeds");
16057        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
16058        let extents = text_extents(&output.pages[0]);
16059
16060        assert!(!extents.is_empty(), "text renders");
16061        let expected_left = geometry.margin_left + 100.0;
16062        assert!(
16063            extents[0].0 >= expected_left - 1.0,
16064            "the first line of the earlier paragraph should clear the drawing at \
16065             {expected_left}, got {:?}",
16066            extents[0]
16067        );
16068    }
16069
16070    #[test]
16071    fn a_split_paragraph_clearing_a_drawing_stays_inside_the_page() {
16072        use rdocx_oxml::drawing::{
16073            AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
16074            WrapType,
16075        };
16076        use rdocx_oxml::text::CT_R;
16077        use rdocx_oxml::units::Emu;
16078
16079        // A top-and-bottom drawing pushes the paragraph's content down, and the
16080        // paragraph is long enough to split. The offset has to be counted where
16081        // the split point is decided, or the last lines run off the page.
16082        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
16083        let mut doc = rdocx_oxml::document::CT_Document::new();
16084        let mut para = CT_P::new();
16085        let mut body = String::new();
16086        for index in 0..300 {
16087            body.push_str(&format!("Sentence {index} of a very long paragraph. "));
16088        }
16089        para.add_run(&body);
16090
16091        let mut anchor = CT_Anchor::background("rId1", 0, 0);
16092        anchor.extent_cx = emu(200.0);
16093        anchor.extent_cy = emu(120.0);
16094        anchor.behind_doc = false;
16095        anchor.wrap = WrapType::TopAndBottom;
16096        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
16097        anchor.pos_h_align = Some(AnchorAlignH::Center);
16098        anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
16099        anchor.pos_v_align = Some(AnchorAlignV::Top);
16100        anchor.dist_b = emu(10.0);
16101        let mut drawing_run = CT_R::new("");
16102        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
16103            inline: None,
16104            anchor: Some(anchor),
16105        })];
16106        para.runs.push(drawing_run);
16107        doc.body.add_paragraph(para);
16108
16109        let mut images = HashMap::new();
16110        images.insert(
16111            "rId1".to_string(),
16112            ImageData {
16113                data: vec![0u8; 8],
16114                content_type: "image/png".to_string(),
16115            },
16116        );
16117
16118        let input = LayoutInput {
16119            revision_view: crate::input::RevisionView::Accepted,
16120            automatic_hyphenation: false,
16121            math_properties: None,
16122            document: doc,
16123            styles: CT_Styles::new_default(),
16124            numbering: None,
16125            headers: HashMap::new(),
16126            footers: HashMap::new(),
16127            images,
16128            charts: HashMap::new(),
16129            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
16130            chart_color_map: oxml_drawing::color::ColorMap::default(),
16131            core_properties: None,
16132            hyperlink_urls: HashMap::new(),
16133            footnotes: None,
16134            endnotes: None,
16135            theme: None,
16136            fonts: Vec::new(),
16137        };
16138
16139        let mut engine = Engine::new();
16140        let output = engine.layout(&input).expect("layout succeeds");
16141        let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
16142        let bottom = geometry.page_height - geometry.margin_bottom;
16143
16144        assert!(output.pages.len() > 1, "the paragraph must split");
16145        for (index, page) in output.pages.iter().enumerate() {
16146            for element in compatibility_page_elements(page) {
16147                let PositionedElement::Text(run) = element else {
16148                    continue;
16149                };
16150                assert!(
16151                    run.origin.y <= bottom + 0.5,
16152                    "page {} draws text at {}, past the bottom margin at {bottom}",
16153                    index + 1,
16154                    run.origin.y
16155                );
16156            }
16157        }
16158    }
16159
16160    // F-X017, notes broken to their own section's width.
16161
16162    /// Text long enough to wrap at either measure under test, so a change of
16163    /// measure changes the number of lines rather than nothing at all.
16164    const NOTE_PROSE: &str = "A note long enough that the measure it is broken \
16165        to decides how many lines it occupies, which is the whole point of \
16166        breaking it to the width of the section that references it rather than \
16167        to the width of whichever section happens to come last in the document.";
16168
16169    /// A document whose first section is `first_page_width` twips wide and
16170    /// whose body-level final section is letter portrait. The first section
16171    /// references note 1 and the second references note 2, and both notes carry
16172    /// the same text, so a difference in their line counts is a difference in
16173    /// the measure each was broken to.
16174    fn make_two_section_input(first_page_width: i32, endnotes_instead: bool) -> LayoutInput {
16175        use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
16176        use rdocx_oxml::text::CT_R;
16177        use rdocx_oxml::units::Twips;
16178
16179        let note_of = |id: i32| {
16180            let mut note = CT_P::new();
16181            note.add_run(NOTE_PROSE);
16182            CT_Footnote {
16183                id,
16184                note_type: NoteType::Normal,
16185                paragraphs: vec![note],
16186            }
16187        };
16188        let reference = |id: i32| {
16189            let mut run = CT_R::new("");
16190            run.content = vec![if endnotes_instead {
16191                RunContent::EndnoteRef { id }
16192            } else {
16193                RunContent::FootnoteRef { id }
16194            }];
16195            run
16196        };
16197
16198        let mut first_sect = CT_SectPr::default_letter();
16199        first_sect.page_width = Some(Twips(first_page_width));
16200
16201        let mut doc = rdocx_oxml::document::CT_Document::new();
16202
16203        // The paragraph carrying a sectPr is the one that ends its section.
16204        let mut first = CT_P::new();
16205        first.add_run("Body text in the first section");
16206        first.runs.push(reference(1));
16207        first.properties = Some(rdocx_oxml::properties::CT_PPr {
16208            sect_pr: Some(first_sect),
16209            ..Default::default()
16210        });
16211        doc.body.add_paragraph(first);
16212
16213        let mut second = CT_P::new();
16214        second.add_run("Body text in the second section");
16215        second.runs.push(reference(2));
16216        doc.body.add_paragraph(second);
16217        doc.body.sect_pr = Some(CT_SectPr::default_letter());
16218
16219        let stream = CT_Footnotes {
16220            footnotes: vec![note_of(1), note_of(2)],
16221        };
16222        LayoutInput {
16223            revision_view: crate::input::RevisionView::Accepted,
16224            automatic_hyphenation: false,
16225            math_properties: None,
16226            document: doc,
16227            styles: CT_Styles::new_default(),
16228            numbering: None,
16229            headers: HashMap::new(),
16230            footers: HashMap::new(),
16231            images: HashMap::new(),
16232            charts: HashMap::new(),
16233            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
16234            chart_color_map: oxml_drawing::color::ColorMap::default(),
16235            core_properties: None,
16236            hyperlink_urls: HashMap::new(),
16237            footnotes: (!endnotes_instead).then(|| stream.clone()),
16238            endnotes: endnotes_instead.then_some(stream),
16239            theme: None,
16240            fonts: Vec::new(),
16241        }
16242    }
16243
16244    /// How many distinct baselines a page drew below its separator rule. A page
16245    /// without notes gives zero.
16246    ///
16247    /// This is the note's line count plus one for each note drawn, because a
16248    /// marker sits a rise above the line it belongs to and so has a baseline of
16249    /// its own. Every use below compares two of these counts over documents
16250    /// drawing the same number of notes, where the offset cancels.
16251    fn note_baseline_count(page: &oxml_layout::output::PageFrame) -> usize {
16252        let Some(separator_y) = separator_y_of(page) else {
16253            return 0;
16254        };
16255        let mut baselines: Vec<f64> = compatibility_page_elements(page)
16256            .into_iter()
16257            .filter_map(|element| match element {
16258                PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.y),
16259                _ => None,
16260            })
16261            .collect();
16262        baselines.sort_by(|a, b| a.partial_cmp(b).expect("baselines are finite"));
16263        baselines.dedup_by(|a, b| (*a - *b).abs() < 0.01);
16264        baselines.len()
16265    }
16266
16267    #[test]
16268    fn a_note_is_broken_to_the_width_of_its_own_section() {
16269        // 17 inches wide against letter's 8.5, so the wide section's measure is
16270        // unmistakably different rather than different by a rounding.
16271        let output = Engine::new()
16272            .layout(&make_two_section_input(24480, false))
16273            .expect("layout succeeds");
16274
16275        let wide = note_baseline_count(&output.pages[0]);
16276        let narrow = note_baseline_count(&output.pages[1]);
16277
16278        assert!(wide > 0 && narrow > 0, "both sections must draw their note");
16279        assert!(
16280            wide < narrow,
16281            "the same note took {wide} lines in the wide section and {narrow} \
16282             in the narrow one, so both were broken to one measure"
16283        );
16284    }
16285
16286    #[test]
16287    fn a_single_section_document_lays_notes_out_exactly_as_before() {
16288        // Two sections of identical geometry are the same document as one, so
16289        // the width key must collapse them. Any difference here is the fix
16290        // moving output it had no business moving.
16291        let two = Engine::new()
16292            .layout(&make_two_section_input(12240, false))
16293            .expect("layout succeeds");
16294
16295        let single = Engine::new()
16296            .layout(&make_input_with_footnote(&[NOTE_PROSE]))
16297            .expect("layout succeeds");
16298
16299        assert_eq!(
16300            note_baseline_count(&two.pages[0]),
16301            note_baseline_count(&single.pages[0]),
16302            "a note in a letter section stopped matching the same note in a \
16303             single-section letter document"
16304        );
16305
16306        // And the same document laid out twice is still the same document.
16307        let again = Engine::new()
16308            .layout(&make_input_with_footnote(&[NOTE_PROSE]))
16309            .expect("layout succeeds");
16310        assert_eq!(single.pages.len(), again.pages.len());
16311        assert_eq!(single.pages[0].elements, again.pages[0].elements);
16312    }
16313
16314    #[test]
16315    fn an_endnote_is_broken_to_the_final_sections_width() {
16316        // Endnotes are emitted after the last body page and drawn against the
16317        // final section's geometry, so that is the measure they must be broken
16318        // to even when the reference sits in a wider section.
16319        let wide_first = Engine::new()
16320            .layout(&make_two_section_input(24480, true))
16321            .expect("layout succeeds");
16322        let all_narrow = Engine::new()
16323            .layout(&make_two_section_input(12240, true))
16324            .expect("layout succeeds");
16325
16326        // Endnotes are emitted on their own pages after every body page, and
16327        // this document has one short paragraph per section, so everything
16328        // drawn after the second page is endnote content.
16329        let endnote_lines = |output: &LayoutResult| {
16330            output.pages[2..]
16331                .iter()
16332                .map(|page| {
16333                    compatibility_page_elements(page)
16334                        .into_iter()
16335                        .filter(|element| matches!(element, PositionedElement::Text(_)))
16336                        .count()
16337                })
16338                .sum::<usize>()
16339        };
16340
16341        assert_eq!(wide_first.pages.len(), all_narrow.pages.len());
16342        assert!(
16343            all_narrow.pages.len() > 2,
16344            "the endnotes must reach pages of their own"
16345        );
16346        assert!(endnote_lines(&all_narrow) > 0, "the endnotes must be drawn");
16347        assert_eq!(
16348            endnote_lines(&wide_first),
16349            endnote_lines(&all_narrow),
16350            "an endnote whose reference sits in a wide section was broken to \
16351             that section rather than to the final one it is drawn in"
16352        );
16353    }
16354
16355    // F-X019, paragraph-relative drawings in later blocks should wrap.
16356
16357    /// Two paragraphs, the second anchoring a wrapping drawing measured from
16358    /// `rel_v`. The first paragraph is the earlier text that should flow around
16359    /// it, which is the whole question: the drawing belongs to a block that has
16360    /// not been placed when the first paragraph is being laid out.
16361    fn make_lookahead_document(
16362        rel_v: rdocx_oxml::drawing::ST_RelativeFromV,
16363        wrap: rdocx_oxml::drawing::WrapType,
16364        off_v_pt: f64,
16365    ) -> LayoutInput {
16366        use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH};
16367        use rdocx_oxml::text::CT_R;
16368        use rdocx_oxml::units::Emu;
16369
16370        let emu = |pt: f64| Emu((pt * 12700.0) as i64);
16371
16372        let mut doc = rdocx_oxml::document::CT_Document::new();
16373
16374        let mut first = CT_P::new();
16375        let mut body = String::new();
16376        for index in 0..30 {
16377            body.push_str(&format!(
16378                "Sentence {index} of running text that fills the paragraph out. "
16379            ));
16380        }
16381        first.add_run(&body);
16382        doc.body.add_paragraph(first);
16383
16384        let mut second = CT_P::new();
16385        second.add_run("The paragraph the drawing is anchored to.");
16386        let mut anchor = CT_Anchor::background("rId1", 0, 0);
16387        anchor.extent_cx = emu(200.0);
16388        anchor.extent_cy = emu(120.0);
16389        anchor.behind_doc = false;
16390        anchor.wrap = wrap;
16391        anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
16392        anchor.pos_h_align = Some(rdocx_oxml::drawing::AnchorAlignH::Right);
16393        anchor.pos_v_relative_from = rel_v;
16394        // The offset is measured from `rel_v`, so the two cases need different
16395        // numbers to land in the same band of the page. Above its own
16396        // paragraph for the paragraph-relative case, and a fixed way down the
16397        // page for the page-relative one. A drawing that lands below every line
16398        // of the first paragraph pushes nothing aside and would prove nothing.
16399        anchor.pos_v_offset = emu(off_v_pt);
16400
16401        let mut drawing_run = CT_R::new("");
16402        drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
16403            inline: None,
16404            anchor: Some(anchor),
16405        })];
16406        second.runs.push(drawing_run);
16407        doc.body.add_paragraph(second);
16408
16409        let mut images = HashMap::new();
16410        images.insert(
16411            "rId1".to_string(),
16412            ImageData {
16413                data: vec![0u8; 8],
16414                content_type: "image/png".to_string(),
16415            },
16416        );
16417
16418        LayoutInput {
16419            revision_view: crate::input::RevisionView::Accepted,
16420            automatic_hyphenation: false,
16421            math_properties: None,
16422            document: doc,
16423            styles: CT_Styles::new_default(),
16424            numbering: None,
16425            headers: HashMap::new(),
16426            footers: HashMap::new(),
16427            images,
16428            charts: HashMap::new(),
16429            chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
16430            chart_color_map: oxml_drawing::color::ColorMap::default(),
16431            core_properties: None,
16432            hyperlink_urls: HashMap::new(),
16433            footnotes: None,
16434            endnotes: None,
16435            theme: None,
16436            fonts: Vec::new(),
16437        }
16438    }
16439
16440    /// How many lines of body text the document drew, across every page.
16441    fn body_line_count(output: &LayoutResult) -> usize {
16442        output
16443            .pages
16444            .iter()
16445            .map(|page| text_extents(page).len())
16446            .sum()
16447    }
16448
16449    #[test]
16450    fn a_paragraph_relative_wrapping_drawing_pushes_earlier_text_aside() {
16451        use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
16452
16453        // The same document twice, differing only in whether the drawing
16454        // wraps. Narrowed lines hold less text, so the paragraph needs more of
16455        // them, and that is visible without depending on where any one line
16456        // broke.
16457        let wrapping = Engine::new()
16458            .layout(&make_lookahead_document(
16459                ST_RelativeFromV::Paragraph,
16460                WrapType::Square,
16461                -120.0,
16462            ))
16463            .expect("layout succeeds");
16464        let ignoring = Engine::new()
16465            .layout(&make_lookahead_document(
16466                ST_RelativeFromV::Paragraph,
16467                WrapType::None,
16468                -120.0,
16469            ))
16470            .expect("layout succeeds");
16471
16472        assert!(
16473            body_line_count(&wrapping) > body_line_count(&ignoring),
16474            "the earlier paragraph took {} lines against {}, so it flowed \
16475             through the drawing rather than around it",
16476            body_line_count(&wrapping),
16477            body_line_count(&ignoring)
16478        );
16479    }
16480
16481    #[test]
16482    fn a_page_relative_drawing_in_a_later_block_still_wraps() {
16483        use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
16484
16485        // F-X016's case, which the second pass must not disturb. This document
16486        // has no paragraph-relative wrap, so it paginates in one pass.
16487        let wrapping = Engine::new()
16488            .layout(&make_lookahead_document(
16489                ST_RelativeFromV::Page,
16490                WrapType::Square,
16491                150.0,
16492            ))
16493            .expect("layout succeeds");
16494        let ignoring = Engine::new()
16495            .layout(&make_lookahead_document(
16496                ST_RelativeFromV::Page,
16497                WrapType::None,
16498                150.0,
16499            ))
16500            .expect("layout succeeds");
16501
16502        assert!(body_line_count(&wrapping) > body_line_count(&ignoring));
16503    }
16504
16505    #[test]
16506    fn a_second_pass_is_stable_for_the_document_that_earns_it() {
16507        use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
16508
16509        // Two passes, not a fixed point, so the guarantee is that the answer is
16510        // the same answer every time rather than that it has converged.
16511        let build = || {
16512            Engine::new()
16513                .layout(&make_lookahead_document(
16514                    ST_RelativeFromV::Paragraph,
16515                    WrapType::Square,
16516                    -120.0,
16517                ))
16518                .expect("layout succeeds")
16519        };
16520        let first = build();
16521        let second = build();
16522
16523        assert_eq!(first.pages.len(), second.pages.len());
16524        for (index, page) in first.pages.iter().enumerate() {
16525            assert_eq!(
16526                page.elements,
16527                second.pages[index].elements,
16528                "page {} differs between two runs",
16529                index + 1
16530            );
16531        }
16532    }
16533
16534    fn cross_reference_run(instruction: &str, display: &str) -> rdocx_oxml::text::CT_R {
16535        let mut run = rdocx_oxml::text::CT_R::new("");
16536        run.content = vec![RunContent::Field(Field::new(instruction, display))];
16537        run
16538    }
16539
16540    fn target_paragraph(targets: &[(i32, &str, usize, usize)], text: &str, hidden: bool) -> CT_P {
16541        let mut paragraph = CT_P::new();
16542        paragraph.properties = Some(CT_PPr {
16543            page_break_before: Some(true),
16544            ..Default::default()
16545        });
16546        let mut run = rdocx_oxml::text::CT_R::new(text);
16547        if hidden {
16548            run.properties = Some(rdocx_oxml::properties::CT_RPr {
16549                vanish: Some(true),
16550                ..Default::default()
16551            });
16552        }
16553        paragraph.runs.push(run);
16554        for (id, name, start, end) in targets {
16555            assert!(paragraph.insert_bookmark_start(*start, *id, name));
16556            assert!(paragraph.insert_bookmark_end(*end, *id));
16557        }
16558        paragraph
16559    }
16560
16561    fn output_text(output: &LayoutResult) -> Vec<String> {
16562        let mut text = Vec::new();
16563        for page in &output.pages {
16564            oxml_layout::walk(&page.elements, &mut |element, _| {
16565                if let PositionedElement::Text(run) = element {
16566                    text.push(run.text.clone());
16567                }
16568            });
16569        }
16570        text
16571    }
16572
16573    fn deterministic_layout(input: &LayoutInput) -> LayoutResult {
16574        Engine::new_deterministic()
16575            .expect("bundled fonts")
16576            .layout(input)
16577            .expect("layout succeeds")
16578    }
16579
16580    #[test]
16581    fn an_unsupported_complex_field_keeps_its_cached_display() {
16582        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>"#;
16583        let mut input = make_input_with_text("");
16584        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
16585
16586        let text = output_text(&deterministic_layout(&input));
16587        assert!(text.concat().contains("17 August 2026"), "{text:?}");
16588    }
16589
16590    #[test]
16591    fn a_complex_field_keeps_each_cached_result_runs_formatting() {
16592        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>"#;
16593        let mut input = make_input_with_text("");
16594        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
16595
16596        let output = deterministic_layout(&input);
16597        let mut displays = Vec::new();
16598        for page in &output.pages {
16599            oxml_layout::walk(&page.elements, &mut |element, _| {
16600                if let PositionedElement::Text(run) = element
16601                    && matches!(run.text.as_str(), "bold" | "italic")
16602                {
16603                    displays.push((run.text.clone(), run.bold, run.italic));
16604                }
16605            });
16606        }
16607        assert_eq!(
16608            displays,
16609            vec![
16610                ("bold".to_owned(), true, false),
16611                ("italic".to_owned(), false, true)
16612            ]
16613        );
16614    }
16615
16616    #[test]
16617    fn a_computed_complex_field_keeps_its_cached_result_run_formatting() {
16618        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>"#;
16619        let mut input = make_input_with_text("");
16620        input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
16621        let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
16622            panic!("expected paragraph")
16623        };
16624        let RunContent::Field(field) = &mut paragraph.runs[0].content[0] else {
16625            panic!("expected field")
16626        };
16627        field.cached_result = "edited stored value".to_owned();
16628
16629        let output = deterministic_layout(&input);
16630        let mut displays = Vec::new();
16631        for page in &output.pages {
16632            oxml_layout::walk(&page.elements, &mut |element, _| {
16633                if let PositionedElement::Text(run) = element
16634                    && run.text == "1"
16635                {
16636                    displays.push((run.bold, run.italic));
16637                }
16638            });
16639        }
16640        assert_eq!(displays, vec![(true, true)]);
16641    }
16642
16643    #[test]
16644    fn a_pageref_inside_a_table_uses_the_final_target_page() {
16645        use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
16646
16647        let mut input = make_input_with_text("");
16648        input.document.body.content.clear();
16649        let mut field = CT_P::new();
16650        field
16651            .runs
16652            .push(cross_reference_run("PAGEREF destination", "cached"));
16653        let mut cell = CT_Tc::new();
16654        cell.content = vec![CellContent::Paragraph(field)];
16655        let mut row = CT_Row::new();
16656        row.cells.push(cell);
16657        let mut table = CT_Tbl::new();
16658        table.rows.push(row);
16659        input.document.body.content.push(BodyContent::Table(table));
16660        input.document.body.add_paragraph(target_paragraph(
16661            &[(4, "destination", 0, 1)],
16662            "target",
16663            false,
16664        ));
16665
16666        let output = deterministic_layout(&input);
16667        let text = output_text(&output);
16668        assert!(text.iter().any(|value| value == "2"), "{text:?}");
16669        assert!(!text.iter().any(|value| value == "cached"), "{text:?}");
16670    }
16671
16672    #[test]
16673    fn a_resolved_pageref_uses_a_fixed_pagination_placeholder() {
16674        let build = |display: &str| {
16675            let mut input = make_input_with_text("");
16676            input.document.body.content.clear();
16677            let mut field = CT_P::new();
16678            field
16679                .runs
16680                .push(cross_reference_run("PAGEREF destination", display));
16681            input.document.body.add_paragraph(field);
16682            input.document.body.add_paragraph(target_paragraph(
16683                &[(4, "destination", 0, 1)],
16684                "target",
16685                false,
16686            ));
16687            deterministic_layout(&input)
16688        };
16689        let short = build("7");
16690        let long = build(&"stale display ".repeat(1000));
16691
16692        assert_eq!(short.pages.len(), long.pages.len());
16693        assert_eq!(output_text(&short), output_text(&long));
16694    }
16695
16696    #[test]
16697    fn every_target_at_a_paragraph_end_is_retained() {
16698        let mut input = make_input_with_text("");
16699        input.document.body.content.clear();
16700        let mut fields = CT_P::new();
16701        for name in ["first", "second"] {
16702            fields
16703                .runs
16704                .push(cross_reference_run(&format!("PAGEREF {name}"), "cached"));
16705        }
16706        input.document.body.add_paragraph(fields);
16707        input.document.body.add_paragraph(target_paragraph(
16708            &[(4, "first", 1, 1), (5, "second", 1, 1)],
16709            "target",
16710            false,
16711        ));
16712
16713        let text = output_text(&deterministic_layout(&input));
16714        assert_eq!(
16715            text.iter().filter(|value| value.as_str() == "2").count(),
16716            2,
16717            "{text:?}"
16718        );
16719    }
16720
16721    #[test]
16722    fn a_target_before_hidden_text_is_retained() {
16723        let mut input = make_input_with_text("");
16724        input.document.body.content.clear();
16725        let mut field = CT_P::new();
16726        field
16727            .runs
16728            .push(cross_reference_run("PAGEREF destination", "cached"));
16729        input.document.body.add_paragraph(field);
16730        input.document.body.add_paragraph(target_paragraph(
16731            &[(4, "destination", 0, 1)],
16732            "hidden target",
16733            true,
16734        ));
16735
16736        let text = output_text(&deterministic_layout(&input));
16737        assert!(text.iter().any(|value| value == "2"), "{text:?}");
16738        assert!(!text.iter().any(|value| value == "cached"), "{text:?}");
16739    }
16740}