1use 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
53const 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
131pub(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
836pub 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
1311fn 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 pub fn new_deterministic() -> Result<Self> {
1407 Ok(Self::with_font_manager(FontManager::new_deterministic()?))
1408 }
1409
1410 pub(crate) fn new_with_caller_fonts() -> Self {
1413 Self::with_font_manager(FontManager::new_with_fonts(Vec::new()))
1414 }
1415
1416 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 #[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 #[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 pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
1457 self.layout_inner(input, None)
1458 }
1459
1460 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 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 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 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; for content in main_story_layout_items(&input.document) {
1624 match content {
1625 MainStoryLayoutItem::Paragraph(para, path) => {
1626 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 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 let Some(sect_pr) = para_sect_pr {
1689 let geometry = sect_pr_to_geometry(§_pr);
1690 let header_footer = layout_header_footer(
1691 self,
1692 §_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(§_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 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| §ion.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 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(§ions[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(§ions[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 §ions[0],
1926 &self.font_manager,
1927 &media,
1928 ¬es,
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 ¬es,
1943 final_geometry,
1944 checkpoint.page_count,
1945 );
1946 } else {
1947 paginator::append_endnote_pages(&mut recorded.pages, ¬es, 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(§ions, &self.font_manager, &media, ¬es);
2023 #[cfg(test)]
2024 {
2025 self.page_layout_invocations = pages.len();
2026 }
2027 paginator::append_endnote_pages(&mut pages, ¬es, 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 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 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 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, ¤t_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 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 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(¶graph.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| ¶graph.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 ¶graph.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(¶graph_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
4569fn 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 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 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
4601fn extract_background_color(xml: &str) -> Option<Color> {
4603 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
4618fn 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
5026fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
5028 let style_id = para.properties.as_ref()?.style_id.as_deref()?;
5029 if let Some(rest) = style_id.strip_prefix("Heading") {
5031 return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
5032 }
5033 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
5043pub 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 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 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 if let Some(direct_ppr) = direct_ppr {
5196 merge_direct_ppr(&mut effective_ppr, direct_ppr);
5197 }
5198
5199 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 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 let mut inline_items = Vec::new();
5224 let mut multilingual_styles = HashMap::<usize, WordMultilingualStyle>::new();
5225
5226 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 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 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 let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
5320 std::collections::HashMap::new();
5321 for hl in ¶.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 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 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 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 let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
5388
5389 let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
5391
5392 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 let mut baseline_offset = 0.0;
5404 if let Some(ref va) = effective_rpr.vert_align {
5405 match va.as_str() {
5406 "superscript" => {
5407 let original_size = font_size;
5409 font_size *= 0.58;
5410 baseline_offset = original_size * 0.33; }
5412 "subscript" => {
5413 let original_size = font_size;
5415 font_size *= 0.58;
5416 baseline_offset = -(original_size * 0.14); }
5418 _ => {}
5419 }
5420 }
5421
5422 if let Some(pos) = effective_rpr.position {
5424 baseline_offset += pos as f64 / 2.0; }
5426
5427 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 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 let stream = match content {
5752 RunContent::EndnoteRef { .. } => NoteStream::Endnote,
5753 _ => NoteStream::Footnote,
5754 };
5755 let marker = id.to_string();
5757 let sup_size = font_size * 0.58;
5758 let sup_offset = font_size * 0.33; 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 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 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 ¶graph.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
6213fn 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 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
6307fn 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 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 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 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
6411fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
6413 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
6482fn 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 §_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
6673fn 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 §_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 §_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
7128fn resolve_font_family(
7132 rpr: &rdocx_oxml::properties::CT_RPr,
7133 theme: Option<&rdocx_oxml::theme::Theme>,
7134) -> Option<String> {
7135 if rpr.font_ascii.is_some() {
7137 return rpr.font_ascii.clone();
7138 }
7139
7140 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
7159fn resolve_run_color(
7163 rpr: &rdocx_oxml::properties::CT_RPr,
7164 theme: Option<&rdocx_oxml::theme::Theme>,
7165) -> Color {
7166 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 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
7465fn 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 ¶graph,
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 ¶graph,
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 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(¶graph),
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 §ion,
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) = ¬e_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 ¶graph,
13358 &template.block,
13359 &template.diagnostics,
13360 template.font_trace.len(),
13361 );
13362 bounded.publish_paragraph_cache_entry(ParagraphCacheEntry {
13363 fingerprint: paragraph_fingerprint(¶graph),
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(¶graph, &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(¶graph, &input.styles));
13554 let bytes = paragraph_cache_entry_bytes(¶graph, &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(¶graph),
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 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 ¶graph,
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 §ions,
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 §ions,
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 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 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 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 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(§);
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="1"/>"#,
14795 rdocx_oxml::namespace::W_NS
14796 )
14797 .into_bytes(),
14798 ];
14799
14800 assert_eq!(section_page_number_start(§ion), 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(§);
14807 assert!((geom.page_width - 595.3).abs() < 0.5);
14809 assert!((geom.page_height - 841.9).abs() < 0.5);
14810 }
14811
14812 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 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 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 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 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 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 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) .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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(¬ed.pages[index]),
15638 "page {} holds a different amount of body text",
15639 index + 1
15640 );
15641 }
15642 }
15643
15644 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}