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
76pub(crate) struct SourceRegistry {
78 nodes: Vec<WordSourcePath>,
79 ids: HashMap<WordSourcePath, SourceNodeId>,
80 body_ids: Vec<Option<SourceNodeId>>,
81}
82
83impl SourceRegistry {
84 fn for_input(input: &LayoutInput) -> Self {
85 let mut registry = Self {
86 nodes: Vec::new(),
87 ids: HashMap::new(),
88 body_ids: Vec::with_capacity(input.document.body.content.len()),
89 };
90
91 for (body_index, content) in input.document.body.content.iter().enumerate() {
92 match content {
93 BodyContent::Paragraph(_) => {
94 let id = registry.insert_node(WordSourcePath {
95 story: WordStory::Document,
96 children: vec![body_index],
97 });
98 registry.body_ids.push(Some(id));
99 }
100 BodyContent::Table(table) => {
101 registry.body_ids.push(None);
102 registry.collect_table(table, &WordStory::Document, &[body_index])
103 }
104 BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
105 registry.body_ids.push(None);
106 }
107 }
108 }
109
110 let mut headers = input.headers.iter().collect::<Vec<_>>();
111 headers.sort_unstable_by_key(|(relationship_id, _)| *relationship_id);
112 for (relationship_id, header) in headers {
113 let story = WordStory::Header {
114 relationship_id: relationship_id.clone(),
115 };
116 for paragraph_index in 0..header.paragraphs.len() {
117 registry.insert(WordSourcePath {
118 story: story.clone(),
119 children: vec![paragraph_index],
120 });
121 }
122 }
123
124 let mut footers = input.footers.iter().collect::<Vec<_>>();
125 footers.sort_unstable_by_key(|(relationship_id, _)| *relationship_id);
126 for (relationship_id, footer) in footers {
127 let story = WordStory::Footer {
128 relationship_id: relationship_id.clone(),
129 };
130 for paragraph_index in 0..footer.paragraphs.len() {
131 registry.insert(WordSourcePath {
132 story: story.clone(),
133 children: vec![paragraph_index],
134 });
135 }
136 }
137
138 for (story_kind, stream) in [
139 (NoteStream::Footnote, input.footnotes.as_ref()),
140 (NoteStream::Endnote, input.endnotes.as_ref()),
141 ]
142 .into_iter()
143 .filter_map(|(story, stream)| stream.map(|stream| (story, stream)))
144 {
145 for note in &stream.footnotes {
146 if stream.get_by_id(note.id).is_none() {
147 continue;
148 }
149 let story = match story_kind {
150 NoteStream::Footnote => WordStory::Footnote { id: note.id },
151 NoteStream::Endnote => WordStory::Endnote { id: note.id },
152 };
153 for paragraph_index in 0..note.paragraphs.len() {
154 registry.insert(WordSourcePath {
155 story: story.clone(),
156 children: vec![paragraph_index],
157 });
158 }
159 }
160 }
161
162 registry
163 }
164
165 fn collect_table(&mut self, table: &CT_Tbl, story: &WordStory, prefix: &[usize]) {
166 for (row_index, row) in table.rows.iter().enumerate() {
167 for (cell_index, cell) in row.cells.iter().enumerate() {
168 for (content_index, content) in cell.content.iter().enumerate() {
169 let mut children = prefix.to_vec();
170 children.extend([row_index, cell_index, content_index]);
171 match content {
172 CellContent::Paragraph(_) => self.insert(WordSourcePath {
173 story: story.clone(),
174 children,
175 }),
176 CellContent::Table(table) => {
177 self.collect_table(table, story, &children);
178 }
179 CellContent::ContentControl(_) => {}
180 }
181 }
182 }
183 }
184 }
185
186 fn insert(&mut self, path: WordSourcePath) {
187 if self.ids.contains_key(&path) {
188 return;
189 }
190 let id = self.insert_node(path.clone());
191 self.ids.insert(path, id);
192 }
193
194 fn insert_node(&mut self, path: WordSourcePath) -> SourceNodeId {
195 let index = u32::try_from(self.nodes.len() + 1)
196 .expect("a layout result cannot contain more than u32::MAX source paragraphs");
197 let id = SourceNodeId::new(index).expect("source ids are one based");
198 self.nodes.push(path);
199 id
200 }
201
202 fn body_id(&self, body_index: usize) -> Option<SourceNodeId> {
203 self.body_ids.get(body_index).copied().flatten()
204 }
205
206 pub(crate) fn id(&self, story: &WordStory, children: &[usize]) -> Option<SourceNodeId> {
207 self.ids
208 .get(&WordSourcePath {
209 story: story.clone(),
210 children: children.to_vec(),
211 })
212 .copied()
213 }
214
215 fn into_nodes(self) -> Vec<WordSourcePath> {
216 self.nodes
217 }
218}
219
220fn project_paragraph_runs(para: &CT_P, view: RevisionView) -> Vec<ProjectedRun<'_>> {
221 let mut projected = Vec::new();
222 for boundary in 0..=para.runs.len() {
223 for (_, slot, revision) in para.revisions.iter().filter(|(at, _, _)| *at == boundary) {
224 let hyperlink_index = hyperlink_revision_index(*slot);
225 let raw_order = match hyperlink_index {
226 Some(index) => {
227 if let Some(raw_before) = para
228 .hyperlinks
229 .get(index)
230 .and_then(|hyperlink| hyperlink.preserved_raw_before)
231 {
232 RawOrder::Raw(raw_before)
233 } else if para
234 .hyperlinks
235 .get(index)
236 .is_some_and(|hyperlink| boundary == hyperlink.run_end)
237 {
238 RawOrder::BeforeRaw
239 } else {
240 RawOrder::AfterRaw
241 }
242 }
243 None => RawOrder::Raw(*slot),
244 };
245 project_revision_runs(
246 revision,
247 view,
248 boundary,
249 raw_order,
250 hyperlink_index,
251 false,
252 false,
253 &mut projected,
254 );
255 }
256 if let Some(run) = para.runs.get(boundary) {
257 projected.push(ProjectedRun {
258 run,
259 boundary,
260 raw_order: RawOrder::AfterRaw,
261 ordinary_run_index: Some(boundary),
262 hyperlink_index: None,
263 force_underline: false,
264 force_strike: false,
265 });
266 }
267 }
268 projected
269}
270
271fn project_revision_runs<'a>(
272 revision: &'a CT_Revision,
273 view: RevisionView,
274 boundary: usize,
275 raw_order: RawOrder,
276 hyperlink_index: Option<usize>,
277 inherited_underline: bool,
278 inherited_strike: bool,
279 projected: &mut Vec<ProjectedRun<'a>>,
280) {
281 let included = match view {
282 RevisionView::Tracked => true,
283 RevisionView::Accepted => matches!(
284 revision.kind(),
285 RevisionKind::Insertion | RevisionKind::MoveTo
286 ),
287 };
288 if !included {
289 return;
290 }
291
292 let force_underline = inherited_underline
293 || (view == RevisionView::Tracked
294 && matches!(
295 revision.kind(),
296 RevisionKind::Insertion | RevisionKind::MoveTo
297 ));
298 let force_strike = inherited_strike
299 || (view == RevisionView::Tracked
300 && matches!(
301 revision.kind(),
302 RevisionKind::Deletion | RevisionKind::MoveFrom
303 ));
304 let runs = match revision.content() {
305 RevisionContent::Runs(runs) => runs.as_slice(),
306 RevisionContent::Marker => &[],
307 RevisionContent::PriorRunProperties(_)
308 | RevisionContent::PriorParagraphProperties(_)
309 | RevisionContent::PriorTableProperties(_)
310 | RevisionContent::PriorSectionProperties(_) => return,
311 };
312
313 for run_boundary in 0..=runs.len() {
314 for (_, nested) in revision
315 .nested_revisions()
316 .iter()
317 .filter(|(at, _)| *at == run_boundary)
318 {
319 project_revision_runs(
320 nested,
321 view,
322 boundary,
323 raw_order,
324 hyperlink_index,
325 force_underline,
326 force_strike,
327 projected,
328 );
329 }
330 if let Some(run) = runs.get(run_boundary) {
331 projected.push(ProjectedRun {
332 run,
333 boundary,
334 raw_order,
335 ordinary_run_index: None,
336 hyperlink_index,
337 force_underline,
338 force_strike,
339 });
340 }
341 }
342}
343
344fn projected_paragraph_text(para: &CT_P, view: RevisionView) -> String {
345 project_paragraph_runs(para, view)
346 .iter()
347 .map(|projected| projected.run.text())
348 .collect()
349}
350
351fn projected_content_char_starts(run: &CT_R) -> Vec<usize> {
352 let mut starts = Vec::with_capacity(run.content.len());
353 let mut char_offset = 0usize;
354 for content in &run.content {
355 starts.push(char_offset);
356 char_offset += match content {
357 RunContent::Text(text) | RunContent::DeletedText(text) => text.text.chars().count(),
358 RunContent::Tab | RunContent::Break(_) => 1,
359 RunContent::Field(field) => field
360 .projected_text()
361 .map_or(0, |text| text.chars().count()),
362 RunContent::Drawing(_)
363 | RunContent::FootnoteRef { .. }
364 | RunContent::EndnoteRef { .. }
365 | RunContent::CommentReference { .. } => 0,
366 };
367 }
368 debug_assert_eq!(char_offset, run.text().chars().count());
369 starts
370}
371
372fn paragraph_has_visible_revision(para: &CT_P) -> bool {
373 let property_revision = para.properties.as_ref().is_some_and(|properties| {
374 properties.numbering_revision.is_some()
375 || properties.change.is_some()
376 || properties
377 .sect_pr
378 .as_ref()
379 .is_some_and(|section| section.change.is_some())
380 || properties
381 .rpr
382 .as_ref()
383 .is_some_and(run_properties_have_revision)
384 });
385 property_revision
386 || para
387 .runs
388 .iter()
389 .filter_map(|run| run.properties.as_ref())
390 .any(run_properties_have_revision)
391 || para
392 .revisions
393 .iter()
394 .any(|(_, _, revision)| revision_is_visible(revision))
395}
396
397fn run_properties_have_revision(properties: &rdocx_oxml::properties::CT_RPr) -> bool {
398 properties.change.is_some() || !properties.revision_markers.is_empty()
399}
400
401fn revision_is_visible(revision: &CT_Revision) -> bool {
402 match revision.content() {
403 RevisionContent::Runs(runs) => {
404 runs.iter().any(|run| {
405 run.content.iter().any(|content| match content {
406 RunContent::Text(text) | RunContent::DeletedText(text) => !text.text.is_empty(),
407 RunContent::CommentReference { .. } => false,
408 RunContent::Tab
409 | RunContent::Break(_)
410 | RunContent::Drawing(_)
411 | RunContent::Field(_)
412 | RunContent::FootnoteRef { .. }
413 | RunContent::EndnoteRef { .. } => true,
414 })
415 }) || revision
416 .nested_revisions()
417 .iter()
418 .any(|(_, nested)| revision_is_visible(nested))
419 }
420 RevisionContent::Marker => revision
421 .nested_revisions()
422 .iter()
423 .any(|(_, nested)| revision_is_visible(nested)),
424 RevisionContent::PriorRunProperties(_)
425 | RevisionContent::PriorParagraphProperties(_)
426 | RevisionContent::PriorTableProperties(_)
427 | RevisionContent::PriorSectionProperties(_) => true,
428 }
429}
430
431pub struct Engine {
433 font_manager: FontManager,
434 caller_font_aliases: Vec<(String, String)>,
435 paragraph_cache_context: Option<ReusableEngineContext>,
436 paragraph_cache: VecDeque<ParagraphCacheEntry>,
437 paragraph_cache_bytes: usize,
438 paragraph_cache_hits: usize,
439 paragraph_cache_builds: usize,
440 pending_paragraph_cache: Option<VecDeque<ParagraphCacheEntry>>,
441 pending_paragraph_cache_bytes: usize,
442 #[cfg(test)]
443 pending_paragraph_cache_peak_entries: usize,
444 #[cfg(test)]
445 pending_paragraph_cache_peak_bytes: usize,
446 paragraph_cache_reads_enabled: bool,
447 table_cache: VecDeque<TableCacheEntry>,
448 table_cache_bytes: usize,
449 table_cache_hits: usize,
450 table_cache_builds: usize,
451 pending_table_cache: Option<VecDeque<TableCacheEntry>>,
452 pending_table_cache_bytes: usize,
453 #[cfg(test)]
454 pending_table_cache_peak_entries: usize,
455 #[cfg(test)]
456 pending_table_cache_peak_bytes: usize,
457 header_footer_cache: VecDeque<HeaderFooterCacheEntry>,
458 header_footer_cache_bytes: usize,
459 header_footer_cache_hits: usize,
460 header_footer_cache_builds: usize,
461 pending_header_footer_cache: Option<VecDeque<HeaderFooterCacheEntry>>,
462 pending_header_footer_cache_bytes: usize,
463 #[cfg(test)]
464 pending_header_footer_cache_peak_entries: usize,
465 #[cfg(test)]
466 pending_header_footer_cache_peak_bytes: usize,
467 header_footer_cache_reads_enabled: bool,
468 restart_cache: Option<RestartCache>,
469 #[cfg(test)]
470 owned_context_builds: usize,
471 #[cfg(test)]
472 body_debug_work: usize,
473 #[cfg(test)]
474 retained_page_deep_copies: usize,
475 #[cfg(test)]
476 last_restart_candidate_bytes: usize,
477 #[cfg(test)]
478 last_rebuilt_page_range: Option<std::ops::Range<usize>>,
479 #[cfg(test)]
480 last_shared_block_counts: (usize, usize),
481 #[cfg(test)]
482 page_layout_invocations: usize,
483}
484
485#[derive(Clone, PartialEq)]
486struct ReusableEngineContext {
487 revision_view: RevisionView,
488 automatic_hyphenation: bool,
489 has_wrapping_drawing: bool,
490 styles: CT_Styles,
491 numbering: Option<rdocx_oxml::numbering::CT_Numbering>,
492 sections: Vec<CT_SectPr>,
493 headers: HashMap<String, rdocx_oxml::header_footer::CT_HdrFtr>,
494 footers: HashMap<String, rdocx_oxml::header_footer::CT_HdrFtr>,
495 images: HashMap<String, crate::input::ImageData>,
496 charts: HashMap<String, std::result::Result<Box<oxml_chart::CT_ChartSpace>, String>>,
497 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet,
498 chart_color_map: oxml_drawing::color::ColorMap,
499 core_properties: Option<rdocx_oxml::core_properties::CoreProperties>,
500 hyperlink_urls: HashMap<String, String>,
501 footnotes: Option<rdocx_oxml::footnotes::CT_Footnotes>,
502 endnotes: Option<rdocx_oxml::footnotes::CT_Footnotes>,
503 theme: Option<rdocx_oxml::theme::Theme>,
504 fonts: Vec<oxml_layout::FontFile>,
505 caller_font_aliases: Vec<(String, String)>,
506 background_xml: Option<Vec<u8>>,
507}
508
509#[cfg(test)]
510thread_local! {
511 static RETAINED_CONTEXT_FONT_BYTES_COMPARED: Cell<usize> = const { Cell::new(0) };
512}
513
514fn retained_context_fonts_match(
515 retained: &[oxml_layout::FontFile],
516 input: &[oxml_layout::FontFile],
517) -> bool {
518 #[cfg(test)]
519 RETAINED_CONTEXT_FONT_BYTES_COMPARED.set(
520 RETAINED_CONTEXT_FONT_BYTES_COMPARED
521 .get()
522 .saturating_add(retained.iter().map(|font| font.data.len()).sum::<usize>()),
523 );
524 retained == input
525}
526
527#[cfg(test)]
528fn reset_retained_context_font_bytes_compared() {
529 RETAINED_CONTEXT_FONT_BYTES_COMPARED.set(0);
530}
531
532#[cfg(test)]
533fn retained_context_font_bytes_compared() -> usize {
534 RETAINED_CONTEXT_FONT_BYTES_COMPARED.get()
535}
536
537impl ReusableEngineContext {
538 #[cfg(test)]
539 fn for_input(input: &LayoutInput, caller_font_aliases: &[(String, String)]) -> Self {
540 Self::for_input_with_wrap(
541 input,
542 caller_font_aliases,
543 document_has_wrapping_drawing(input),
544 )
545 }
546
547 fn for_input_with_wrap(
548 input: &LayoutInput,
549 caller_font_aliases: &[(String, String)],
550 has_wrapping_drawing: bool,
551 ) -> Self {
552 let mut sections = input
553 .document
554 .body
555 .content
556 .iter()
557 .filter_map(|content| match content {
558 BodyContent::Paragraph(paragraph) => paragraph
559 .properties
560 .as_ref()
561 .and_then(|properties| properties.sect_pr.clone()),
562 _ => None,
563 })
564 .collect::<Vec<_>>();
565 sections.extend(input.document.body.sect_pr.iter().cloned());
566 Self {
567 revision_view: input.revision_view,
568 automatic_hyphenation: input.automatic_hyphenation,
569 has_wrapping_drawing,
570 styles: input.styles.clone(),
571 numbering: input.numbering.clone(),
572 sections,
573 headers: input.headers.clone(),
574 footers: input.footers.clone(),
575 images: input.images.clone(),
576 charts: input.charts.clone(),
577 chart_theme: input.chart_theme.clone(),
578 chart_color_map: input.chart_color_map.clone(),
579 core_properties: input.core_properties.clone(),
580 hyperlink_urls: input.hyperlink_urls.clone(),
581 footnotes: input.footnotes.clone(),
582 endnotes: input.endnotes.clone(),
583 theme: input.theme.clone(),
584 fonts: input.fonts.clone(),
585 caller_font_aliases: bounded_caller_aliases(caller_font_aliases),
586 background_xml: input.document.background_xml.clone(),
587 }
588 }
589
590 fn matches_input(
591 &self,
592 input: &LayoutInput,
593 caller_font_aliases: &[(String, String)],
594 has_wrapping_drawing: bool,
595 ) -> bool {
596 self.matches_input_after_unchanged_fonts(input, caller_font_aliases, has_wrapping_drawing)
597 && retained_context_fonts_match(&self.fonts, &input.fonts)
598 }
599
600 fn matches_input_after_unchanged_fonts(
601 &self,
602 input: &LayoutInput,
603 caller_font_aliases: &[(String, String)],
604 has_wrapping_drawing: bool,
605 ) -> bool {
606 let sections_match = self.sections.iter().eq(input
607 .document
608 .body
609 .content
610 .iter()
611 .filter_map(|content| match content {
612 BodyContent::Paragraph(paragraph) => paragraph
613 .properties
614 .as_ref()
615 .and_then(|properties| properties.sect_pr.as_ref()),
616 BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
617 None
618 }
619 })
620 .chain(input.document.body.sect_pr.iter()));
621 self.revision_view == input.revision_view
622 && self.automatic_hyphenation == input.automatic_hyphenation
623 && self.has_wrapping_drawing == has_wrapping_drawing
624 && self.styles == input.styles
625 && self.numbering == input.numbering
626 && sections_match
627 && self.headers == input.headers
628 && self.footers == input.footers
629 && self.images == input.images
630 && self.charts == input.charts
631 && self.chart_theme == input.chart_theme
632 && self.chart_color_map == input.chart_color_map
633 && self.core_properties == input.core_properties
634 && self.hyperlink_urls == input.hyperlink_urls
635 && self.footnotes == input.footnotes
636 && self.endnotes == input.endnotes
637 && self.theme == input.theme
638 && self.caller_font_aliases == caller_font_aliases
639 && self.background_xml == input.document.background_xml
640 }
641}
642
643#[derive(Clone, PartialEq)]
644struct ParagraphCacheKey {
645 paragraph: CT_P,
646 content_width_bits: u64,
647 revision_view: RevisionView,
648}
649
650struct ParagraphCacheEntry {
651 fingerprint: u64,
652 key: ParagraphCacheKey,
653 block: Arc<ParagraphBlock>,
654 diagnostics: Vec<Diagnostic>,
655 font_trace: Vec<FontId>,
656 reflow_direction: TextDirection,
657 bytes: usize,
658}
659
660#[derive(Clone, PartialEq)]
661struct TableCacheKey {
662 table: CT_Tbl,
663 content_width_bits: u64,
664 revision_view: RevisionView,
665 with_provenance: bool,
666}
667
668struct TableCacheEntry {
669 fingerprint: u64,
670 key: TableCacheKey,
671 block: Arc<table::TableBlock>,
672 semantics: TableSemantics,
673 diagnostics: Vec<Diagnostic>,
674 font_trace: Vec<FontId>,
675 bytes: usize,
676}
677
678#[derive(Clone, Copy, PartialEq, Eq)]
679enum HeaderFooterStoryKind {
680 Header,
681 Footer,
682}
683
684#[derive(Clone, PartialEq)]
685struct HeaderFooterCacheKey {
686 story: HeaderFooterStoryKind,
687 variant: HdrFtrType,
688 section: CT_SectPr,
689 relationship_id: String,
690 part: rdocx_oxml::header_footer::CT_HdrFtr,
691 resolved_part_bytes: Vec<u8>,
692 with_provenance: bool,
693}
694
695#[derive(Clone)]
696struct HeaderFooterVariantContent {
697 blocks: Vec<ParagraphBlock>,
698 directions: Vec<TextDirection>,
699 watermark: Option<GroupElement>,
700}
701
702struct HeaderFooterCacheEntry {
703 key: HeaderFooterCacheKey,
704 content: HeaderFooterVariantContent,
705 diagnostics: Vec<Diagnostic>,
706 font_trace: Vec<FontId>,
707 bytes: usize,
708}
709
710struct RestartCache {
711 body: Vec<RestartBodyEntry>,
712 with_provenance: bool,
713 raw_pages: Vec<Arc<PageFrame>>,
714 pages: Vec<Arc<PageFrame>>,
715 substitution_inputs: Vec<Option<FieldSubstitutionInputs>>,
716 outlines: Vec<oxml_layout::OutlineEntry>,
717 checkpoints: Vec<paginator::PaginationCheckpoint>,
718 font_trace: Vec<FontId>,
719 bytes: usize,
720}
721
722#[derive(Clone)]
723enum RestartBodyEntry {
724 Paragraph {
725 fingerprint: u64,
726 identity: Vec<u8>,
727 note_references: Vec<NoteRef>,
728 bytes: usize,
729 },
730 Table {
731 fingerprint: u64,
732 identity: Vec<u8>,
733 bytes: usize,
734 },
735}
736
737impl RestartBodyEntry {
738 fn matches(&self, content: &BodyContent) -> bool {
739 match (self, content) {
740 (
741 Self::Paragraph {
742 fingerprint,
743 identity,
744 ..
745 },
746 BodyContent::Paragraph(paragraph),
747 ) => {
748 *fingerprint == paragraph_fingerprint(paragraph)
749 && restart_body_identity(content).as_ref() == Some(identity)
750 }
751 (
752 Self::Table {
753 fingerprint,
754 identity,
755 ..
756 },
757 BodyContent::Table(table),
758 ) => {
759 *fingerprint == table_fingerprint(table)
760 && restart_body_identity(content).as_ref() == Some(identity)
761 }
762 _ => false,
763 }
764 }
765
766 fn for_content(content: &BodyContent, view: RevisionView) -> Option<Self> {
767 let identity = restart_body_identity(content)?;
768 match content {
769 BodyContent::Paragraph(paragraph) => {
770 let mut note_references = paragraph_note_references(paragraph, view);
771 note_references.shrink_to_fit();
772 let bytes = identity.capacity().saturating_add(
773 note_references
774 .capacity()
775 .saturating_mul(std::mem::size_of::<NoteRef>()),
776 );
777 Some(Self::Paragraph {
778 fingerprint: paragraph_fingerprint(paragraph),
779 identity,
780 note_references,
781 bytes,
782 })
783 }
784 BodyContent::Table(table) => Some(Self::Table {
785 fingerprint: table_fingerprint(table),
786 bytes: identity.capacity(),
787 identity,
788 }),
789 BodyContent::ContentControl(_) | BodyContent::RawXml(_) => None,
790 }
791 }
792
793 fn bytes(&self) -> usize {
794 match self {
795 Self::Paragraph { bytes, .. } | Self::Table { bytes, .. } => *bytes,
796 }
797 }
798
799 fn note_references(&self) -> &[NoteRef] {
800 match self {
801 Self::Paragraph {
802 note_references, ..
803 } => note_references,
804 Self::Table { .. } => &[],
805 }
806 }
807}
808
809fn restart_body_identity(content: &BodyContent) -> Option<Vec<u8>> {
810 if !matches!(content, BodyContent::Paragraph(_) | BodyContent::Table(_)) {
811 return None;
812 }
813 let mut document = CT_Document::new();
814 document.body.sect_pr = None;
815 document.body.content.push(content.clone());
816 let mut identity = document.to_xml().ok()?;
817 identity.shrink_to_fit();
818 Some(identity)
819}
820
821fn paragraph_note_references(paragraph: &CT_P, view: RevisionView) -> Vec<NoteRef> {
822 project_paragraph_runs(paragraph, view)
823 .into_iter()
824 .flat_map(|projected| projected.run.content.iter())
825 .filter_map(|content| match content {
826 RunContent::FootnoteRef { id } => Some(NoteRef {
827 stream: NoteStream::Footnote,
828 id: *id,
829 }),
830 RunContent::EndnoteRef { id } => Some(NoteRef {
831 stream: NoteStream::Endnote,
832 id: *id,
833 }),
834 _ => None,
835 })
836 .collect()
837}
838
839fn body_note_references(input: &LayoutInput) -> Vec<NoteRef> {
840 input
841 .document
842 .body
843 .content
844 .iter()
845 .flat_map(|content| match content {
846 BodyContent::Paragraph(paragraph) => {
847 paragraph_note_references(paragraph, input.revision_view)
848 }
849 BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
850 Vec::new()
851 }
852 })
853 .collect()
854}
855
856const fn arc_allocation_bytes<T>() -> usize {
857 std::mem::size_of::<T>() + 2 * std::mem::size_of::<usize>()
858}
859
860#[derive(Clone, PartialEq, Eq)]
861struct FieldSubstitutionInputs {
862 page_index: usize,
863 page_number: usize,
864 total_pages: usize,
865 bookmark_pages: Vec<(usize, usize)>,
866 font_identity: Vec<FontId>,
867 revision_view: RevisionView,
868}
869
870const CACHE_MAX_ENTRIES: usize = 5_216;
871const CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
872const PARAGRAPH_CACHE_MAX_ENTRIES: usize = 4_096;
873const PARAGRAPH_CACHE_MAX_BYTES: usize = 50 * 1024 * 1024;
874const TABLE_CACHE_MAX_ENTRIES: usize = 32;
875const TABLE_CACHE_MAX_BYTES: usize = 2 * 1024 * 1024;
876const HEADER_FOOTER_CACHE_MAX_ENTRIES: usize = 64;
877const HEADER_FOOTER_CACHE_MAX_BYTES: usize = 4 * 1024 * 1024;
878const RESTART_CACHE_MAX_ENTRIES: usize = 1_024;
879const CALLER_ALIAS_MAX_ENTRIES: usize = 256;
880const CALLER_ALIAS_MAX_RETAINED_BYTES: usize = 64 * 1024;
881const _: () = assert!(PARAGRAPH_CACHE_MAX_ENTRIES == 4_096);
882const _: () = assert!(PARAGRAPH_CACHE_MAX_BYTES == 50 * 1024 * 1024);
883const _: () = assert!(HEADER_FOOTER_CACHE_MAX_ENTRIES == 64);
884const _: () = assert!(HEADER_FOOTER_CACHE_MAX_BYTES == 4 * 1024 * 1024);
885const _: () = assert!(CACHE_MAX_ENTRIES == 5_216);
886const _: () = assert!(CACHE_MAX_BYTES == 64 * 1024 * 1024);
887const _: () = assert!(
888 PARAGRAPH_CACHE_MAX_ENTRIES
889 + TABLE_CACHE_MAX_ENTRIES
890 + HEADER_FOOTER_CACHE_MAX_ENTRIES
891 + RESTART_CACHE_MAX_ENTRIES
892 <= CACHE_MAX_ENTRIES
893);
894const _: () = assert!(
895 PARAGRAPH_CACHE_MAX_BYTES + TABLE_CACHE_MAX_BYTES + HEADER_FOOTER_CACHE_MAX_BYTES
896 <= CACHE_MAX_BYTES
897);
898const CACHE_SOURCE_NODE: SourceNodeId = match SourceNodeId::new(1) {
899 Some(node) => node,
900 None => panic!("one is a valid source node id"),
901};
902
903fn bounded_caller_aliases(aliases: &[(String, String)]) -> Vec<(String, String)> {
909 let mut bounded = Vec::with_capacity(aliases.len().min(CALLER_ALIAS_MAX_ENTRIES));
910 let mut retained_bytes = 0usize;
911 for (requested, target) in aliases {
912 if bounded.len() == CALLER_ALIAS_MAX_ENTRIES {
913 break;
914 }
915 let normalized_requested = requested.to_lowercase();
916 let entry_bytes = requested
917 .len()
918 .saturating_add(target.len())
919 .saturating_add(normalized_requested.len())
920 .saturating_add(target.len());
921 let next_retained_bytes = retained_bytes.saturating_add(entry_bytes);
922 if next_retained_bytes > CALLER_ALIAS_MAX_RETAINED_BYTES {
923 break;
924 }
925 bounded.push((requested.clone(), target.clone()));
926 retained_bytes = next_retained_bytes;
927 }
928 bounded
929}
930
931impl Default for Engine {
932 fn default() -> Self {
933 Self::new()
934 }
935}
936
937impl Engine {
938 fn with_font_manager(font_manager: FontManager) -> Self {
939 Self {
940 font_manager,
941 caller_font_aliases: Vec::new(),
942 paragraph_cache_context: None,
943 paragraph_cache: VecDeque::new(),
944 paragraph_cache_bytes: 0,
945 paragraph_cache_hits: 0,
946 paragraph_cache_builds: 0,
947 pending_paragraph_cache: None,
948 pending_paragraph_cache_bytes: 0,
949 #[cfg(test)]
950 pending_paragraph_cache_peak_entries: 0,
951 #[cfg(test)]
952 pending_paragraph_cache_peak_bytes: 0,
953 paragraph_cache_reads_enabled: false,
954 table_cache: VecDeque::new(),
955 table_cache_bytes: 0,
956 table_cache_hits: 0,
957 table_cache_builds: 0,
958 pending_table_cache: None,
959 pending_table_cache_bytes: 0,
960 #[cfg(test)]
961 pending_table_cache_peak_entries: 0,
962 #[cfg(test)]
963 pending_table_cache_peak_bytes: 0,
964 header_footer_cache: VecDeque::new(),
965 header_footer_cache_bytes: 0,
966 header_footer_cache_hits: 0,
967 header_footer_cache_builds: 0,
968 pending_header_footer_cache: None,
969 pending_header_footer_cache_bytes: 0,
970 #[cfg(test)]
971 pending_header_footer_cache_peak_entries: 0,
972 #[cfg(test)]
973 pending_header_footer_cache_peak_bytes: 0,
974 header_footer_cache_reads_enabled: false,
975 restart_cache: None,
976 #[cfg(test)]
977 owned_context_builds: 0,
978 #[cfg(test)]
979 body_debug_work: 0,
980 #[cfg(test)]
981 retained_page_deep_copies: 0,
982 #[cfg(test)]
983 last_restart_candidate_bytes: 0,
984 #[cfg(test)]
985 last_rebuilt_page_range: None,
986 #[cfg(test)]
987 last_shared_block_counts: (0, 0),
988 #[cfg(test)]
989 page_layout_invocations: 0,
990 }
991 }
992
993 pub fn new() -> Self {
994 Self::with_font_manager(FontManager::new())
995 }
996
997 pub fn new_deterministic() -> Result<Self> {
999 Ok(Self::with_font_manager(FontManager::new_deterministic()?))
1000 }
1001
1002 pub(crate) fn new_with_caller_fonts() -> Self {
1005 Self::with_font_manager(FontManager::new_with_fonts(Vec::new()))
1006 }
1007
1008 pub fn set_caller_font_aliases(&mut self, aliases: &[(String, String)]) {
1010 let aliases = bounded_caller_aliases(aliases);
1011 if self.caller_font_aliases != aliases {
1012 self.caller_font_aliases = aliases;
1013 }
1014 }
1015
1016 #[doc(hidden)]
1019 pub fn take_if_compatible(source: &mut Option<Self>, input: &LayoutInput) -> Option<Self> {
1020 Self::take_if_compatible_with_caller_aliases(source, input, &[])
1021 }
1022
1023 #[doc(hidden)]
1026 pub fn take_if_compatible_with_caller_aliases(
1027 source: &mut Option<Self>,
1028 input: &LayoutInput,
1029 caller_font_aliases: &[(String, String)],
1030 ) -> Option<Self> {
1031 let caller_font_aliases = bounded_caller_aliases(caller_font_aliases);
1032 let has_wrapping_drawing = document_has_wrapping_drawing(input);
1033 let compatible = source.as_ref().is_some_and(|engine| {
1034 engine.caller_font_aliases == caller_font_aliases
1035 && engine
1036 .paragraph_cache_context
1037 .as_ref()
1038 .is_some_and(|context| {
1039 context.matches_input(input, &caller_font_aliases, has_wrapping_drawing)
1040 })
1041 && engine.pending_paragraph_cache.is_none()
1042 && engine.pending_header_footer_cache.is_none()
1043 });
1044 compatible.then(|| source.take()).flatten()
1045 }
1046
1047 pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
1049 self.layout_inner(input, None)
1050 }
1051
1052 pub(crate) fn layout_with_provenance(
1054 &mut self,
1055 input: &LayoutInput,
1056 ) -> Result<(LayoutResult, Vec<WordSourcePath>)> {
1057 let sources = SourceRegistry::for_input(input);
1058 let result = self.layout_inner(input, Some(&sources))?;
1059 Ok((result, sources.into_nodes()))
1060 }
1061
1062 fn layout_inner(
1063 &mut self,
1064 input: &LayoutInput,
1065 sources: Option<&SourceRegistry>,
1066 ) -> Result<LayoutResult> {
1067 let font_context_changed = self.font_manager.load_additional_fonts(&input.fonts)
1070 | self
1071 .font_manager
1072 .set_caller_aliases(&self.caller_font_aliases);
1073 self.font_manager.begin_layout();
1074 let has_wrapping_drawing = document_has_wrapping_drawing(input);
1075
1076 if font_context_changed {
1077 self.paragraph_cache.clear();
1078 self.paragraph_cache_bytes = 0;
1079 self.table_cache.clear();
1080 self.table_cache_bytes = 0;
1081 self.header_footer_cache.clear();
1082 self.header_footer_cache_bytes = 0;
1083 }
1084 let context_matches = !font_context_changed
1085 && self
1086 .paragraph_cache_context
1087 .as_ref()
1088 .is_some_and(|context| {
1089 context.matches_input_after_unchanged_fonts(
1090 input,
1091 &self.caller_font_aliases,
1092 has_wrapping_drawing,
1093 )
1094 });
1095 self.paragraph_cache_reads_enabled = context_matches;
1096 self.header_footer_cache_reads_enabled = context_matches;
1097 self.pending_paragraph_cache = Some(VecDeque::new());
1098 self.pending_paragraph_cache_bytes = 0;
1099 self.pending_table_cache = Some(VecDeque::new());
1100 self.pending_table_cache_bytes = 0;
1101 self.pending_header_footer_cache = Some(VecDeque::new());
1102 self.pending_header_footer_cache_bytes = 0;
1103 #[cfg(test)]
1104 {
1105 self.pending_paragraph_cache_peak_entries = 0;
1106 self.pending_paragraph_cache_peak_bytes = 0;
1107 self.pending_table_cache_peak_entries = 0;
1108 self.pending_table_cache_peak_bytes = 0;
1109 self.pending_header_footer_cache_peak_entries = 0;
1110 self.pending_header_footer_cache_peak_bytes = 0;
1111 self.page_layout_invocations = 0;
1112 }
1113
1114 let result = self.layout_transaction(input, sources, has_wrapping_drawing);
1115 let pending = self.pending_paragraph_cache.take().unwrap_or_default();
1116 let pending_tables = self.pending_table_cache.take().unwrap_or_default();
1117 let pending_header_footers = self.pending_header_footer_cache.take().unwrap_or_default();
1118 self.pending_paragraph_cache_bytes = 0;
1119 self.pending_table_cache_bytes = 0;
1120 self.pending_header_footer_cache_bytes = 0;
1121 self.paragraph_cache_reads_enabled = false;
1122 self.header_footer_cache_reads_enabled = false;
1123 if result.is_ok() {
1124 if !context_matches {
1125 self.paragraph_cache.clear();
1126 self.paragraph_cache_bytes = 0;
1127 self.table_cache.clear();
1128 self.table_cache_bytes = 0;
1129 self.header_footer_cache.clear();
1130 self.header_footer_cache_bytes = 0;
1131 self.paragraph_cache_context = Some(ReusableEngineContext::for_input_with_wrap(
1132 input,
1133 &self.caller_font_aliases,
1134 has_wrapping_drawing,
1135 ));
1136 #[cfg(test)]
1137 {
1138 self.owned_context_builds += 1;
1139 }
1140 }
1141 for entry in pending {
1142 self.publish_paragraph_cache_entry(entry);
1143 }
1144 for entry in pending_tables {
1145 self.publish_table_cache_entry(entry);
1146 }
1147 for entry in pending_header_footers {
1148 self.publish_header_footer_cache_entry(entry);
1149 }
1150 }
1151 let current_fonts = self
1152 .font_manager
1153 .current_layout_fonts()
1154 .iter()
1155 .copied()
1156 .collect::<std::collections::HashSet<_>>();
1157 self.paragraph_cache.retain(|entry| {
1158 entry
1159 .font_trace
1160 .iter()
1161 .all(|font_id| current_fonts.contains(font_id))
1162 });
1163 self.paragraph_cache_bytes = self.paragraph_cache.iter().map(|entry| entry.bytes).sum();
1164 self.table_cache.retain(|entry| {
1165 entry
1166 .font_trace
1167 .iter()
1168 .all(|font_id| current_fonts.contains(font_id))
1169 });
1170 self.table_cache_bytes = self.table_cache.iter().map(|entry| entry.bytes).sum();
1171 self.header_footer_cache.retain(|entry| {
1172 entry
1173 .font_trace
1174 .iter()
1175 .all(|font_id| current_fonts.contains(font_id))
1176 });
1177 self.header_footer_cache_bytes = self
1178 .header_footer_cache
1179 .iter()
1180 .map(|entry| entry.bytes)
1181 .sum();
1182 self.font_manager.retain_current_fonts();
1183 result
1184 }
1185
1186 fn layout_transaction(
1187 &mut self,
1188 input: &LayoutInput,
1189 sources: Option<&SourceRegistry>,
1190 document_wraps: bool,
1191 ) -> Result<LayoutResult> {
1192 let retained_context_matches = self.paragraph_cache_reads_enabled;
1193 let styles = &input.styles;
1194 let mut num_state = NumberingState::new();
1195 let media = MediaRegistry::new(&input.images);
1196 let mut diagnostics = Vec::new();
1197
1198 let final_sect_pr = input
1203 .document
1204 .body
1205 .sect_pr
1206 .as_ref()
1207 .cloned()
1208 .unwrap_or_else(CT_SectPr::default_letter);
1209
1210 let mut sections: Vec<paginator::SharedSection> = Vec::new();
1212 let mut current_blocks: Vec<SharedLayoutBlock> = Vec::new();
1213 let mut current_sect_pr: Option<CT_SectPr> = None; for (body_index, content) in input.document.body.content.iter().enumerate() {
1216 match content {
1217 BodyContent::Paragraph(para) => {
1218 let para_sect_pr = para.properties.as_ref().and_then(|p| p.sect_pr.clone());
1220
1221 let sect_pr_for_layout = para_sect_pr
1222 .as_ref()
1223 .or(current_sect_pr.as_ref())
1224 .unwrap_or(&final_sect_pr);
1225 let geometry = sect_pr_to_geometry(sect_pr_for_layout);
1226
1227 let source = sources.and_then(|sources| sources.body_id(body_index));
1228 let mut block = self.layout_body_paragraph(
1229 para,
1230 geometry.content_width(),
1231 styles,
1232 input,
1233 &media,
1234 &mut num_state,
1235 &mut diagnostics,
1236 source,
1237 )?;
1238
1239 if let Some(level) = detect_heading_level(para, styles) {
1241 let heading_text = projected_paragraph_text(para, input.revision_view);
1242 let replacement = match &mut block {
1243 SharedLayoutBlock::Owned { block, .. } => match block.as_mut() {
1244 LayoutBlock::Paragraph(paragraph) => {
1245 paragraph.heading_level = Some(level);
1246 paragraph.heading_text = Some(heading_text);
1247 None
1248 }
1249 LayoutBlock::Table(_) => unreachable!(),
1250 },
1251 SharedLayoutBlock::Paragraph {
1252 block: shared,
1253 semantics,
1254 } => {
1255 let mut paragraph = shared.as_ref().clone();
1256 rebind_paragraph_source(&mut paragraph, semantics.source_node)?;
1257 paragraph.heading_level = Some(level);
1258 paragraph.heading_text = Some(heading_text);
1259 Some(SharedLayoutBlock::Owned {
1260 block: Box::new(LayoutBlock::Paragraph(paragraph)),
1261 reflow_direction: semantics.reflow_direction,
1262 })
1263 }
1264 SharedLayoutBlock::Table { .. } => unreachable!(),
1265 };
1266 if let Some(replacement) = replacement {
1267 block = replacement;
1268 }
1269 }
1270
1271 current_blocks.push(block);
1272
1273 if let Some(sect_pr) = para_sect_pr {
1275 let geometry = sect_pr_to_geometry(§_pr);
1276 let header_footer = layout_header_footer(
1277 self,
1278 §_pr,
1279 input,
1280 styles,
1281 &media,
1282 &mut num_state,
1283 &mut diagnostics,
1284 sources,
1285 )?;
1286 let title_pg = sect_pr.title_pg.unwrap_or(false);
1287 let (header_footer, header_footer_semantics) = header_footer
1288 .map_or((None, None), |(content, semantics)| {
1289 (Some(content), Some(semantics))
1290 });
1291 sections.push(paginator::SharedSection {
1292 blocks: std::mem::take(&mut current_blocks),
1293 geometry,
1294 header_footer,
1295 header_footer_semantics,
1296 title_pg,
1297 page_number_start: section_page_number_start(§_pr),
1298 });
1299 current_sect_pr = Some(sect_pr);
1300 }
1301 }
1302 BodyContent::Table(tbl) => {
1303 let sect_pr_for_layout = current_sect_pr.as_ref().unwrap_or(&final_sect_pr);
1304 let geometry = sect_pr_to_geometry(sect_pr_for_layout);
1305
1306 let table_block = self.layout_body_table(
1307 tbl,
1308 geometry.content_width(),
1309 styles,
1310 input,
1311 &media,
1312 &mut num_state,
1313 &mut diagnostics,
1314 sources,
1315 &WordStory::Document,
1316 &[body_index],
1317 )?;
1318 current_blocks.push(table_block);
1319 }
1320 _ => {} }
1322 }
1323
1324 let final_geometry = sect_pr_to_geometry(&final_sect_pr);
1326 let final_hf = layout_header_footer(
1327 self,
1328 &final_sect_pr,
1329 input,
1330 styles,
1331 &media,
1332 &mut num_state,
1333 &mut diagnostics,
1334 sources,
1335 )?;
1336 let final_title_pg = final_sect_pr.title_pg.unwrap_or(false);
1337 let (final_hf, final_hf_semantics) = final_hf
1338 .map_or((None, None), |(content, semantics)| {
1339 (Some(content), Some(semantics))
1340 });
1341 sections.push(paginator::SharedSection {
1342 blocks: current_blocks,
1343 geometry: final_geometry,
1344 header_footer: final_hf,
1345 header_footer_semantics: final_hf_semantics,
1346 title_pg: final_title_pg,
1347 page_number_start: section_page_number_start(&final_sect_pr),
1348 });
1349 let structure = assign_shared_document_structure(&mut sections);
1350 #[cfg(test)]
1351 {
1352 self.last_shared_block_counts = sections
1353 .iter()
1354 .flat_map(|section| §ion.blocks)
1355 .fold((0, 0), |(paragraphs, tables), block| match block {
1356 SharedLayoutBlock::Paragraph { block, .. } if Arc::strong_count(block) >= 2 => {
1357 (paragraphs + 1, tables)
1358 }
1359 SharedLayoutBlock::Table { block, .. } if Arc::strong_count(block) >= 2 => {
1360 (paragraphs, tables + 1)
1361 }
1362 SharedLayoutBlock::Owned { .. }
1363 | SharedLayoutBlock::Paragraph { .. }
1364 | SharedLayoutBlock::Table { .. } => (paragraphs, tables),
1365 });
1366 }
1367
1368 let content_widths: Vec<f64> = sections
1375 .iter()
1376 .map(|section| section.geometry.content_width())
1377 .collect();
1378 let notes = NoteRegistry::build(
1379 input,
1380 styles,
1381 &media,
1382 &mut self.font_manager,
1383 &mut num_state,
1384 &content_widths,
1385 &mut diagnostics,
1386 sources,
1387 )?;
1388
1389 let mut font_trace = self.font_manager.current_layout_fonts().to_vec();
1390 let restart_record_eligible = sections.len() == 1
1391 && input.document.background_xml.is_none()
1392 && !document_wraps
1393 && input
1394 .document
1395 .body
1396 .content
1397 .iter()
1398 .zip(§ions[0].blocks)
1399 .all(|(content, block)| match content {
1400 BodyContent::Paragraph(paragraph) if block.paragraph().is_some() => {
1401 paragraph_is_restart_record_source_safe(paragraph, styles)
1402 && restart_record_block_is_safe(block)
1403 }
1404 BodyContent::Table(table) if block.table().is_some() => {
1405 table_is_cache_safe(table, styles)
1406 }
1407 _ => false,
1408 });
1409 let restart_eligible = restart_record_eligible
1410 && input
1411 .document
1412 .body
1413 .content
1414 .iter()
1415 .zip(§ions[0].blocks)
1416 .all(|(content, block)| {
1417 !matches!(content, BodyContent::Paragraph(paragraph) if paragraph_has_field(paragraph))
1418 && restart_block_is_safe(block)
1419 });
1420 let reusable_restart_record = restart_record_eligible
1421 && retained_context_matches
1422 && self.restart_cache.as_ref().is_some_and(|cache| {
1423 cache.font_trace == font_trace
1424 && cache.with_provenance == sources.is_some()
1425 && cache
1426 .body
1427 .iter()
1428 .flat_map(|entry| entry.note_references().iter().copied())
1429 .eq(body_note_references(input))
1430 && (sources.is_none() || cache.body.len() == input.document.body.content.len())
1431 });
1432 let reusable_restart = restart_eligible && reusable_restart_record;
1433 let body_unchanged = reusable_restart_record
1434 && self.restart_cache.as_ref().is_some_and(|cache| {
1435 cache.body.len() == input.document.body.content.len()
1436 && input
1437 .document
1438 .body
1439 .content
1440 .iter()
1441 .zip(&cache.body)
1442 .all(|(content, retained)| retained.matches(content))
1443 });
1444 let first_changed = reusable_restart.then(|| {
1445 let cache = self.restart_cache.as_ref().expect("restart cache exists");
1446 input
1447 .document
1448 .body
1449 .content
1450 .iter()
1451 .zip(&cache.body)
1452 .position(|(current, previous)| !previous.matches(current))
1453 .unwrap_or_else(|| input.document.body.content.len().min(cache.body.len()))
1454 });
1455 let common_suffix = reusable_restart.then(|| {
1456 let cache = self.restart_cache.as_ref().expect("restart cache exists");
1457 input
1458 .document
1459 .body
1460 .content
1461 .iter()
1462 .rev()
1463 .zip(cache.body.iter().rev())
1464 .take_while(|(current, previous)| previous.matches(current))
1465 .count()
1466 });
1467 let restart_checkpoint = first_changed.and_then(|first_changed| {
1468 self.restart_cache
1469 .as_ref()
1470 .expect("restart cache exists")
1471 .checkpoints
1472 .iter()
1473 .rev()
1474 .find(|checkpoint| checkpoint.next_block_index <= first_changed)
1475 .copied()
1476 });
1477 let tail_source = restart_checkpoint.and_then(|restart| {
1478 let cache = self.restart_cache.as_ref().expect("restart cache exists");
1479 let common_suffix = common_suffix.expect("reusable restart has an exact suffix");
1480 let new_tail = input.document.body.content.len() - common_suffix;
1481 let old_tail = cache.body.len() - common_suffix;
1482 let block_delta = new_tail as isize - old_tail as isize;
1483 cache
1484 .checkpoints
1485 .iter()
1486 .find(|checkpoint| {
1487 checkpoint.next_block_index >= old_tail
1488 && checkpoint
1489 .next_block_index
1490 .checked_add_signed(block_delta)
1491 .is_some_and(|next| next > restart.next_block_index)
1492 })
1493 .copied()
1494 .map(|old| {
1495 (
1496 paginator::PaginationCheckpoint {
1497 next_block_index: old
1498 .next_block_index
1499 .checked_add_signed(block_delta)
1500 .expect("common suffix block index remains in range"),
1501 page_count: old.page_count,
1502 next_header_page_number: old.next_header_page_number,
1503 },
1504 old,
1505 )
1506 })
1507 });
1508
1509 let (mut pages, mut outlines, mut checkpoints) = if restart_eligible {
1510 let mut recorded = paginator::paginate_shared_single_section_recorded(
1511 §ions[0],
1512 &self.font_manager,
1513 &media,
1514 ¬es,
1515 restart_checkpoint,
1516 tail_source.map(|(stop, _)| stop),
1517 );
1518 #[cfg(test)]
1519 {
1520 self.page_layout_invocations = recorded.pages.len();
1521 }
1522 if recorded.stopped_at.is_none() {
1523 if let Some(checkpoint) = restart_checkpoint {
1524 let references = body_note_references(input);
1525 paginator::append_endnote_pages_for_references(
1526 &mut recorded.pages,
1527 &references,
1528 ¬es,
1529 final_geometry,
1530 checkpoint.page_count,
1531 );
1532 } else {
1533 paginator::append_endnote_pages(&mut recorded.pages, ¬es, final_geometry);
1534 }
1535 }
1536 for page in &mut recorded.pages {
1537 mark_remaining_artifacts(&mut page.elements);
1538 }
1539 let mut pages = restart_checkpoint.map_or_else(Vec::new, |checkpoint| {
1540 self.restart_cache
1541 .as_ref()
1542 .expect("a restart checkpoint belongs to retained pages")
1543 .raw_pages[..checkpoint.page_count]
1544 .iter()
1545 .map(Arc::clone)
1546 .collect()
1547 });
1548 pages.extend(recorded.pages.into_iter().map(Arc::new));
1549 let mut outlines = restart_checkpoint.map_or_else(Vec::new, |checkpoint| {
1550 self.restart_cache
1551 .as_ref()
1552 .expect("a restart checkpoint belongs to retained outlines")
1553 .outlines
1554 .iter()
1555 .filter(|outline| outline.page_index < checkpoint.page_count)
1556 .cloned()
1557 .collect()
1558 });
1559 outlines.extend(recorded.outlines);
1560 let mut checkpoints = restart_checkpoint.map_or_else(Vec::new, |checkpoint| {
1561 self.restart_cache
1562 .as_ref()
1563 .expect("a restart checkpoint belongs to retained state")
1564 .checkpoints
1565 .iter()
1566 .copied()
1567 .filter(|candidate| candidate.page_count <= checkpoint.page_count)
1568 .collect()
1569 });
1570 checkpoints.extend(recorded.checkpoints);
1571 if let (Some(stopped), Some((_, old_tail))) = (recorded.stopped_at, tail_source) {
1572 debug_assert_eq!(stopped.page_count, old_tail.page_count);
1573 let cache = self.restart_cache.as_ref().expect("restart cache exists");
1574 pages.extend(
1575 cache.raw_pages[old_tail.page_count..]
1576 .iter()
1577 .map(Arc::clone),
1578 );
1579 outlines.extend(
1580 cache
1581 .outlines
1582 .iter()
1583 .filter(|outline| outline.page_index >= old_tail.page_count)
1584 .cloned(),
1585 );
1586 let block_delta =
1587 stopped.next_block_index as isize - old_tail.next_block_index as isize;
1588 checkpoints.extend(
1589 cache
1590 .checkpoints
1591 .iter()
1592 .filter(|candidate| candidate.page_count > old_tail.page_count)
1593 .map(|candidate| paginator::PaginationCheckpoint {
1594 next_block_index: candidate
1595 .next_block_index
1596 .checked_add_signed(block_delta)
1597 .expect("common suffix block index remains in range"),
1598 page_count: candidate.page_count,
1599 next_header_page_number: candidate.next_header_page_number,
1600 }),
1601 );
1602 }
1603 checkpoints.sort_unstable_by_key(|checkpoint| checkpoint.next_block_index);
1604 checkpoints.dedup();
1605 (pages, outlines, checkpoints)
1606 } else {
1607 let (mut pages, outlines) =
1608 paginator::paginate_shared_sections(§ions, &self.font_manager, &media, ¬es);
1609 #[cfg(test)]
1610 {
1611 self.page_layout_invocations = pages.len();
1612 }
1613 paginator::append_endnote_pages(&mut pages, ¬es, final_geometry);
1616 apply_page_background(&mut pages, input);
1617 for page in &mut pages {
1618 mark_remaining_artifacts(&mut page.elements);
1619 }
1620 (
1621 pages.into_iter().map(Arc::new).collect(),
1622 outlines,
1623 Vec::new(),
1624 )
1625 };
1626 if body_unchanged
1627 && let Some(cache) = self.restart_cache.as_ref()
1628 && cache.raw_pages.len() == pages.len()
1629 {
1630 for (page, retained) in pages.iter_mut().zip(&cache.raw_pages) {
1631 *page = Arc::clone(retained);
1632 }
1633 }
1634 let mut raw_pages = restart_record_eligible.then(|| pages.clone());
1635
1636 let total_pages = pages.len();
1638 let bookmark_pages = pages
1639 .iter()
1640 .flat_map(|page| {
1641 let mut targets = Vec::new();
1642 oxml_layout::walk(&page.elements, &mut |element, _| {
1643 if let PositionedElement::Text(run) = element
1644 && let Some(FieldKind::Target(target)) = run.field_kind
1645 {
1646 targets.push((target, page.page_number));
1647 }
1648 });
1649 targets
1650 })
1651 .collect::<HashMap<_, _>>();
1652 let mut bookmark_identity = bookmark_pages
1653 .iter()
1654 .map(|(&target, &page_number)| (target, page_number))
1655 .collect::<Vec<_>>();
1656 bookmark_identity.sort_unstable();
1657 let mut substitution_inputs = Vec::with_capacity(pages.len());
1658 let mut reuse_result_pages = vec![false; pages.len()];
1659 for (page_index, page) in pages.iter_mut().enumerate() {
1660 if !page_has_substitution_state(page) {
1661 reuse_result_pages[page_index] = self.restart_cache.as_ref().is_some_and(|cache| {
1662 cache.substitution_inputs.get(page_index) == Some(&None)
1663 && cache
1664 .raw_pages
1665 .get(page_index)
1666 .is_some_and(|retained| Arc::ptr_eq(page, retained))
1667 });
1668 substitution_inputs.push(None);
1669 continue;
1670 }
1671 let inputs = FieldSubstitutionInputs {
1672 page_index,
1673 page_number: page.page_number,
1674 total_pages,
1675 bookmark_pages: bookmark_identity.clone(),
1676 font_identity: font_trace.clone(),
1677 revision_view: input.revision_view,
1678 };
1679 let reusable = self.restart_cache.as_ref().is_some_and(|cache| {
1680 cache
1681 .substitution_inputs
1682 .get(page_index)
1683 .and_then(Option::as_ref)
1684 == Some(&inputs)
1685 && cache
1686 .raw_pages
1687 .get(page_index)
1688 .is_some_and(|retained| Arc::ptr_eq(page, retained))
1689 });
1690 if reusable {
1691 reuse_result_pages[page_index] = true;
1692 substitution_inputs.push(Some(inputs));
1693 continue;
1694 }
1695 let page = Arc::make_mut(page);
1696 let page_num = page.page_number;
1697 substitute_fields(
1698 &mut page.elements,
1699 page_num,
1700 total_pages,
1701 &bookmark_pages,
1702 &mut self.font_manager,
1703 );
1704 substitution_inputs.push(Some(inputs));
1705 }
1706
1707 #[cfg(test)]
1708 {
1709 self.last_rebuilt_page_range = Some(0..pages.len());
1710 }
1711 if restart_checkpoint.is_some()
1712 && let Some(cache) = self.restart_cache.as_ref()
1713 && cache.font_trace == font_trace
1714 {
1715 let mut rebuilt_start = pages.len();
1716 let mut rebuilt_end = 0;
1717 for (page_index, (page, retained)) in pages.iter().zip(&cache.raw_pages).enumerate() {
1718 if Arc::ptr_eq(page, retained) {
1719 reuse_result_pages[page_index] = true;
1720 } else {
1721 rebuilt_start = rebuilt_start.min(page_index);
1722 rebuilt_end = page_index + 1;
1723 }
1724 }
1725 if pages.len() != cache.pages.len() {
1726 rebuilt_start = rebuilt_start.min(pages.len().min(cache.pages.len()));
1727 rebuilt_end = pages.len();
1728 }
1729 let rebuilt_range = if rebuilt_start < rebuilt_end {
1730 rebuilt_start..rebuilt_end
1731 } else {
1732 0..0
1733 };
1734 #[cfg(test)]
1735 {
1736 self.last_rebuilt_page_range = Some(rebuilt_range);
1737 }
1738 #[cfg(not(test))]
1739 {
1740 let _ = rebuilt_range;
1741 }
1742 }
1743 let mut carrier_fonts = Vec::new();
1747 fn collect_carrier_fonts(elements: &[PositionedElement], fonts: &mut Vec<FontId>) {
1748 for element in elements {
1749 match element {
1750 PositionedElement::Text(run)
1751 if run.text.is_empty() && run.glyph_ids.is_empty() =>
1752 {
1753 if !fonts.contains(&run.font_id) {
1754 fonts.push(run.font_id);
1755 }
1756 }
1757 PositionedElement::Group(group) => {
1758 collect_carrier_fonts(&group.children, fonts)
1759 }
1760 PositionedElement::MarkedContent { children, .. } => {
1761 collect_carrier_fonts(children, fonts)
1762 }
1763 _ => {}
1764 }
1765 }
1766 }
1767 for page in &pages {
1768 collect_carrier_fonts(&page.elements, &mut carrier_fonts);
1769 }
1770 self.font_manager.replay_layout_font_trace(&carrier_fonts);
1771
1772 let fonts = if self.font_manager.every_loaded_font_is_current() {
1775 self.font_manager.all_font_data()
1776 } else {
1777 let current_fonts = self.font_manager.current_layout_fonts().to_vec();
1778 canonicalize_layout_fonts(&mut pages, &self.font_manager, ¤t_fonts)?
1779 };
1780 if let Some(cache) = self.restart_cache.as_ref() {
1781 for (page_index, reuse) in reuse_result_pages.into_iter().enumerate() {
1782 if reuse && let Some(retained) = cache.pages.get(page_index) {
1783 pages[page_index] = Arc::clone(retained);
1784 }
1785 }
1786 }
1787 let mut retained_pages = restart_record_eligible.then(|| pages.clone());
1788
1789 let metadata = input.core_properties.as_ref().map(|cp| DocumentMetadata {
1791 title: cp.title.clone(),
1792 author: cp.creator.clone(),
1793 subject: cp.subject.clone(),
1794 keywords: cp.keywords.clone(),
1795 creator: Some("rdocx".to_string()),
1796 });
1797
1798 if restart_record_eligible
1799 && pages.len().max(checkpoints.len()) <= RESTART_CACHE_MAX_ENTRIES
1800 && let Some(raw_pages) = raw_pages.as_mut()
1801 && let Some(retained_pages) = retained_pages.as_mut()
1802 {
1803 let old_body = self
1804 .restart_cache
1805 .as_ref()
1806 .map(|cache| cache.body.as_slice());
1807 let prefix = first_changed.unwrap_or(0);
1808 let suffix = common_suffix.unwrap_or(0);
1809 let new_len = input.document.body.content.len();
1810 let old_len = old_body.map_or(0, <[RestartBodyEntry]>::len);
1811 let mut body = input
1812 .document
1813 .body
1814 .content
1815 .iter()
1816 .enumerate()
1817 .filter_map(|(index, content)| {
1818 if index < prefix {
1819 return old_body.and_then(|body| body.get(index)).cloned();
1820 }
1821 if index >= new_len.saturating_sub(suffix) {
1822 let old_index = old_len.saturating_sub(new_len - index);
1823 return old_body.and_then(|body| body.get(old_index)).cloned();
1824 }
1825 RestartBodyEntry::for_content(content, input.revision_view)
1826 })
1827 .collect::<Vec<_>>();
1828 let body_complete = body.len() == new_len;
1829 body.shrink_to_fit();
1830 raw_pages.shrink_to_fit();
1831 retained_pages.shrink_to_fit();
1832 substitution_inputs.shrink_to_fit();
1833 outlines.shrink_to_fit();
1834 checkpoints.shrink_to_fit();
1835 font_trace.shrink_to_fit();
1836 let mut candidate = RestartCache {
1837 body,
1838 with_provenance: sources.is_some(),
1839 raw_pages: std::mem::take(raw_pages),
1840 pages: std::mem::take(retained_pages),
1841 substitution_inputs,
1842 outlines: outlines.clone(),
1843 checkpoints,
1844 font_trace,
1845 bytes: 0,
1846 };
1847 candidate.outlines.shrink_to_fit();
1848 for inputs in candidate.substitution_inputs.iter_mut().flatten() {
1849 inputs.bookmark_pages.shrink_to_fit();
1850 inputs.font_identity.shrink_to_fit();
1851 }
1852 let bytes = if body_complete {
1853 restart_cache_bytes(&candidate)
1854 } else {
1855 usize::MAX
1856 };
1857 #[cfg(test)]
1858 {
1859 self.last_restart_candidate_bytes = bytes;
1860 }
1861 let entries = restart_cache_entries(&candidate);
1862 if self.restart_candidate_fits_aggregate(entries, bytes) {
1863 candidate.bytes = bytes;
1864 self.restart_cache = Some(candidate);
1865 } else {
1866 self.restart_cache = None;
1867 }
1868 } else {
1869 self.restart_cache = None;
1870 }
1871 let mut result = LayoutResult::new(pages, fonts, metadata, outlines);
1872 result.diagnostics = diagnostics;
1873 result.structure = Some(structure);
1874 Ok(result)
1875 }
1876
1877 #[allow(clippy::too_many_arguments)]
1878 fn layout_body_paragraph(
1879 &mut self,
1880 paragraph: &CT_P,
1881 content_width: f64,
1882 styles: &CT_Styles,
1883 input: &LayoutInput,
1884 media: &MediaRegistry,
1885 numbering: &mut NumberingState,
1886 diagnostics: &mut Vec<Diagnostic>,
1887 source_node: Option<SourceNodeId>,
1888 ) -> Result<SharedLayoutBlock> {
1889 if !paragraph_is_cache_safe(paragraph, styles) {
1890 self.paragraph_cache_reads_enabled = false;
1894 let (block, reflow_direction) = layout_paragraph_with_source_and_direction(
1895 paragraph,
1896 content_width,
1897 styles,
1898 input,
1899 media,
1900 &mut self.font_manager,
1901 numbering,
1902 diagnostics,
1903 source_node,
1904 )?;
1905 return Ok(SharedLayoutBlock::Owned {
1906 block: Box::new(LayoutBlock::Paragraph(block)),
1907 reflow_direction,
1908 });
1909 }
1910
1911 let fingerprint = paragraph_fingerprint(paragraph);
1912 if self.paragraph_cache_reads_enabled
1913 && let Some(entry) = self.paragraph_cache.iter().find(|entry| {
1914 entry.fingerprint == fingerprint
1915 && entry.key.paragraph == *paragraph
1916 && entry.key.content_width_bits == content_width.to_bits()
1917 && entry.key.revision_view == input.revision_view
1918 })
1919 {
1920 diagnostics.extend(entry.diagnostics.iter().cloned());
1921 self.font_manager
1922 .replay_layout_font_trace(&entry.font_trace);
1923 self.paragraph_cache_hits += 1;
1924 return Ok(SharedLayoutBlock::Paragraph {
1925 block: Arc::clone(&entry.block),
1926 semantics: ParagraphSemantics {
1927 source_node,
1928 structure_id: None,
1929 reflow_direction: entry.reflow_direction,
1930 },
1931 });
1932 }
1933
1934 let diagnostics_start = diagnostics.len();
1935 self.font_manager.begin_paragraph_font_trace();
1936 let block_result = layout_paragraph_with_source_and_direction(
1937 paragraph,
1938 content_width,
1939 styles,
1940 input,
1941 media,
1942 &mut self.font_manager,
1943 numbering,
1944 diagnostics,
1945 Some(CACHE_SOURCE_NODE),
1946 );
1947 let font_trace = self.font_manager.finish_paragraph_font_trace();
1948 let (mut block, reflow_direction) = block_result?;
1949 self.paragraph_cache_builds += 1;
1950
1951 let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
1952 if let Some(font_trace) = font_trace {
1953 let bytes = paragraph_cache_entry_bytes(
1954 paragraph,
1955 &block,
1956 &cached_diagnostics,
1957 font_trace.len(),
1958 );
1959 let block = Arc::new(block);
1960 self.stage_paragraph_cache_entry(ParagraphCacheEntry {
1961 fingerprint,
1962 key: ParagraphCacheKey {
1963 paragraph: paragraph.clone(),
1964 content_width_bits: content_width.to_bits(),
1965 revision_view: input.revision_view,
1966 },
1967 block: Arc::clone(&block),
1968 diagnostics: cached_diagnostics,
1969 font_trace,
1970 reflow_direction,
1971 bytes,
1972 });
1973 return Ok(SharedLayoutBlock::Paragraph {
1974 block,
1975 semantics: ParagraphSemantics {
1976 source_node,
1977 structure_id: None,
1978 reflow_direction,
1979 },
1980 });
1981 }
1982
1983 rebind_paragraph_source(&mut block, source_node)?;
1984 Ok(SharedLayoutBlock::Owned {
1985 block: Box::new(LayoutBlock::Paragraph(block)),
1986 reflow_direction,
1987 })
1988 }
1989
1990 #[allow(clippy::too_many_arguments)]
1991 fn layout_body_table(
1992 &mut self,
1993 table: &CT_Tbl,
1994 content_width: f64,
1995 styles: &CT_Styles,
1996 input: &LayoutInput,
1997 media: &MediaRegistry,
1998 numbering: &mut NumberingState,
1999 diagnostics: &mut Vec<Diagnostic>,
2000 sources: Option<&SourceRegistry>,
2001 story: &WordStory,
2002 path: &[usize],
2003 ) -> Result<SharedLayoutBlock> {
2004 if !table_is_cache_safe(table, styles) {
2005 self.paragraph_cache_reads_enabled = false;
2006 return table::layout_table_with_provenance(
2007 table,
2008 content_width,
2009 styles,
2010 input,
2011 media,
2012 &mut self.font_manager,
2013 numbering,
2014 diagnostics,
2015 sources,
2016 story,
2017 path,
2018 )
2019 .map(|(block, semantics)| SharedLayoutBlock::Table {
2020 block: Arc::new(block),
2021 semantics,
2022 });
2023 }
2024
2025 let fingerprint = table_fingerprint(table);
2026 if self.paragraph_cache_reads_enabled
2027 && let Some(entry) = self.table_cache.iter().find(|entry| {
2028 entry.fingerprint == fingerprint
2029 && entry.key.table == *table
2030 && entry.key.content_width_bits == content_width.to_bits()
2031 && entry.key.revision_view == input.revision_view
2032 && entry.key.with_provenance == sources.is_some()
2033 })
2034 {
2035 diagnostics.extend(entry.diagnostics.iter().cloned());
2036 self.font_manager
2037 .replay_layout_font_trace(&entry.font_trace);
2038 self.table_cache_hits += 1;
2039 return Ok(SharedLayoutBlock::Table {
2040 block: Arc::clone(&entry.block),
2041 semantics: table_semantics(
2042 table,
2043 entry.block.as_ref(),
2044 &entry.semantics,
2045 sources,
2046 story,
2047 path,
2048 ),
2049 });
2050 }
2051
2052 let diagnostics_start = diagnostics.len();
2053 self.font_manager.begin_paragraph_font_trace();
2054 let block_result = table::layout_table_with_provenance(
2055 table,
2056 content_width,
2057 styles,
2058 input,
2059 media,
2060 &mut self.font_manager,
2061 numbering,
2062 diagnostics,
2063 sources,
2064 story,
2065 path,
2066 );
2067 let font_trace = self.font_manager.finish_paragraph_font_trace();
2068 let (mut block, semantics) = block_result?;
2069 self.table_cache_builds += 1;
2070
2071 let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
2072 if let Some(font_trace) = font_trace {
2073 canonicalize_table_sources(&mut block)?;
2074 let key = TableCacheKey {
2075 table: table.clone(),
2076 content_width_bits: content_width.to_bits(),
2077 revision_view: input.revision_view,
2078 with_provenance: sources.is_some(),
2079 };
2080 let bytes = table_cache_entry_bytes(
2081 &key,
2082 &block,
2083 &semantics,
2084 &cached_diagnostics,
2085 font_trace.len(),
2086 );
2087 let block = Arc::new(block);
2088 self.stage_table_cache_entry(TableCacheEntry {
2089 fingerprint,
2090 key,
2091 block: Arc::clone(&block),
2092 semantics: semantics.clone(),
2093 diagnostics: cached_diagnostics,
2094 font_trace,
2095 bytes,
2096 });
2097 return Ok(SharedLayoutBlock::Table { block, semantics });
2098 }
2099 Ok(SharedLayoutBlock::Table {
2100 block: Arc::new(block),
2101 semantics,
2102 })
2103 }
2104
2105 #[cfg(test)]
2106 fn paragraph_cache_counts(&self) -> (usize, usize) {
2107 (self.paragraph_cache_hits, self.paragraph_cache_builds)
2108 }
2109
2110 #[cfg(test)]
2111 fn table_cache_counts(&self) -> (usize, usize) {
2112 (self.table_cache_hits, self.table_cache_builds)
2113 }
2114
2115 #[cfg(test)]
2116 fn owned_context_build_count(&self) -> usize {
2117 self.owned_context_builds
2118 }
2119
2120 #[cfg(test)]
2121 fn hot_path_work_counts(&self) -> (usize, usize) {
2122 (self.body_debug_work, self.retained_page_deep_copies)
2123 }
2124
2125 #[cfg(test)]
2126 fn page_layout_invocation_count(&self) -> usize {
2127 self.page_layout_invocations
2128 }
2129
2130 fn publish_paragraph_cache_entry(&mut self, entry: ParagraphCacheEntry) {
2131 if entry.bytes > PARAGRAPH_CACHE_MAX_BYTES {
2132 return;
2133 }
2134 while self.paragraph_cache.len() >= PARAGRAPH_CACHE_MAX_ENTRIES
2135 || self.paragraph_cache_bytes.saturating_add(entry.bytes) > PARAGRAPH_CACHE_MAX_BYTES
2136 {
2137 let Some(evicted) = self.paragraph_cache.pop_front() else {
2138 break;
2139 };
2140 self.paragraph_cache_bytes = self.paragraph_cache_bytes.saturating_sub(evicted.bytes);
2141 }
2142 let Some(bytes) = self.paragraph_cache_bytes.checked_add(entry.bytes) else {
2143 return;
2144 };
2145 if bytes > PARAGRAPH_CACHE_MAX_BYTES {
2146 return;
2147 }
2148 self.paragraph_cache_bytes = bytes;
2149 self.paragraph_cache.push_back(entry);
2150 }
2151
2152 fn restart_candidate_fits_aggregate(
2153 &self,
2154 candidate_entries: usize,
2155 candidate_bytes: usize,
2156 ) -> bool {
2157 let pending_paragraph_entries = self
2158 .pending_paragraph_cache
2159 .as_ref()
2160 .map_or(0, VecDeque::len);
2161 let pending_table_entries = self.pending_table_cache.as_ref().map_or(0, VecDeque::len);
2162 let pending_header_footer_entries = self
2163 .pending_header_footer_cache
2164 .as_ref()
2165 .map_or(0, VecDeque::len);
2166 let entries = self
2167 .paragraph_cache
2168 .len()
2169 .checked_add(self.table_cache.len())
2170 .and_then(|entries| entries.checked_add(self.header_footer_cache.len()))
2171 .and_then(|entries| entries.checked_add(pending_paragraph_entries))
2172 .and_then(|entries| entries.checked_add(pending_table_entries))
2173 .and_then(|entries| entries.checked_add(pending_header_footer_entries))
2174 .and_then(|entries| entries.checked_add(candidate_entries));
2175 let bytes = self
2176 .paragraph_cache_bytes
2177 .checked_add(self.table_cache_bytes)
2178 .and_then(|bytes| bytes.checked_add(self.header_footer_cache_bytes))
2179 .and_then(|bytes| bytes.checked_add(self.pending_paragraph_cache_bytes))
2180 .and_then(|bytes| bytes.checked_add(self.pending_table_cache_bytes))
2181 .and_then(|bytes| bytes.checked_add(self.pending_header_footer_cache_bytes))
2182 .and_then(|bytes| bytes.checked_add(candidate_bytes));
2183 entries.is_some_and(|entries| entries <= CACHE_MAX_ENTRIES)
2184 && bytes.is_some_and(|bytes| bytes <= CACHE_MAX_BYTES)
2185 }
2186
2187 fn stage_paragraph_cache_entry(&mut self, entry: ParagraphCacheEntry) {
2188 if entry.bytes > PARAGRAPH_CACHE_MAX_BYTES {
2189 return;
2190 }
2191 let Some(pending) = self.pending_paragraph_cache.as_mut() else {
2192 return;
2193 };
2194 while pending.len() >= PARAGRAPH_CACHE_MAX_ENTRIES
2195 || self
2196 .pending_paragraph_cache_bytes
2197 .saturating_add(entry.bytes)
2198 > PARAGRAPH_CACHE_MAX_BYTES
2199 {
2200 let Some(evicted) = pending.pop_front() else {
2201 break;
2202 };
2203 self.pending_paragraph_cache_bytes = self
2204 .pending_paragraph_cache_bytes
2205 .saturating_sub(evicted.bytes);
2206 }
2207 self.pending_paragraph_cache_bytes += entry.bytes;
2208 pending.push_back(entry);
2209 #[cfg(test)]
2210 {
2211 self.pending_paragraph_cache_peak_entries =
2212 self.pending_paragraph_cache_peak_entries.max(pending.len());
2213 self.pending_paragraph_cache_peak_bytes = self
2214 .pending_paragraph_cache_peak_bytes
2215 .max(self.pending_paragraph_cache_bytes);
2216 }
2217 }
2218
2219 fn publish_table_cache_entry(&mut self, entry: TableCacheEntry) {
2220 if entry.bytes > TABLE_CACHE_MAX_BYTES {
2221 return;
2222 }
2223 while self.table_cache.len() >= TABLE_CACHE_MAX_ENTRIES
2224 || self.table_cache_bytes.saturating_add(entry.bytes) > TABLE_CACHE_MAX_BYTES
2225 {
2226 let Some(evicted) = self.table_cache.pop_front() else {
2227 break;
2228 };
2229 self.table_cache_bytes = self.table_cache_bytes.saturating_sub(evicted.bytes);
2230 }
2231 self.table_cache_bytes += entry.bytes;
2232 self.table_cache.push_back(entry);
2233 let restart_entries = self.restart_cache.as_ref().map_or(0, restart_cache_entries);
2234 let restart_bytes = self.restart_cache.as_ref().map_or(0, |cache| cache.bytes);
2235 debug_assert!(
2236 self.paragraph_cache.len() + self.table_cache.len() + restart_entries
2237 <= CACHE_MAX_ENTRIES
2238 );
2239 debug_assert!(
2240 self.paragraph_cache_bytes + self.table_cache_bytes + restart_bytes <= CACHE_MAX_BYTES
2241 );
2242 }
2243
2244 fn stage_table_cache_entry(&mut self, entry: TableCacheEntry) {
2245 if entry.bytes > TABLE_CACHE_MAX_BYTES {
2246 return;
2247 }
2248 let Some(pending) = self.pending_table_cache.as_mut() else {
2249 return;
2250 };
2251 while pending.len() >= TABLE_CACHE_MAX_ENTRIES
2252 || self.pending_table_cache_bytes.saturating_add(entry.bytes) > TABLE_CACHE_MAX_BYTES
2253 {
2254 let Some(evicted) = pending.pop_front() else {
2255 break;
2256 };
2257 self.pending_table_cache_bytes =
2258 self.pending_table_cache_bytes.saturating_sub(evicted.bytes);
2259 }
2260 self.pending_table_cache_bytes += entry.bytes;
2261 pending.push_back(entry);
2262 #[cfg(test)]
2263 {
2264 self.pending_table_cache_peak_entries =
2265 self.pending_table_cache_peak_entries.max(pending.len());
2266 self.pending_table_cache_peak_bytes = self
2267 .pending_table_cache_peak_bytes
2268 .max(self.pending_table_cache_bytes);
2269 }
2270 }
2271
2272 #[cfg(test)]
2273 fn header_footer_cache_counts(&self) -> (usize, usize) {
2274 (
2275 self.header_footer_cache_hits,
2276 self.header_footer_cache_builds,
2277 )
2278 }
2279
2280 fn publish_header_footer_cache_entry(&mut self, entry: HeaderFooterCacheEntry) {
2281 if entry.bytes > HEADER_FOOTER_CACHE_MAX_BYTES {
2282 return;
2283 }
2284 while self.header_footer_cache.len() >= HEADER_FOOTER_CACHE_MAX_ENTRIES
2285 || self.header_footer_cache_bytes.saturating_add(entry.bytes)
2286 > HEADER_FOOTER_CACHE_MAX_BYTES
2287 {
2288 let Some(evicted) = self.header_footer_cache.pop_front() else {
2289 break;
2290 };
2291 self.header_footer_cache_bytes =
2292 self.header_footer_cache_bytes.saturating_sub(evicted.bytes);
2293 }
2294 self.header_footer_cache_bytes += entry.bytes;
2295 self.header_footer_cache.push_back(entry);
2296 let restart_entries = self.restart_cache.as_ref().map_or(0, restart_cache_entries);
2297 let restart_bytes = self.restart_cache.as_ref().map_or(0, |cache| cache.bytes);
2298 debug_assert!(
2299 self.paragraph_cache.len()
2300 + self.table_cache.len()
2301 + self.header_footer_cache.len()
2302 + restart_entries
2303 <= CACHE_MAX_ENTRIES
2304 );
2305 debug_assert!(
2306 self.paragraph_cache_bytes
2307 + self.table_cache_bytes
2308 + self.header_footer_cache_bytes
2309 + restart_bytes
2310 <= CACHE_MAX_BYTES
2311 );
2312 }
2313
2314 fn stage_header_footer_cache_entry(&mut self, entry: HeaderFooterCacheEntry) {
2315 if entry.bytes > HEADER_FOOTER_CACHE_MAX_BYTES {
2316 return;
2317 }
2318 let Some(pending) = self.pending_header_footer_cache.as_mut() else {
2319 return;
2320 };
2321 while pending.len() >= HEADER_FOOTER_CACHE_MAX_ENTRIES
2322 || self
2323 .pending_header_footer_cache_bytes
2324 .saturating_add(entry.bytes)
2325 > HEADER_FOOTER_CACHE_MAX_BYTES
2326 {
2327 let Some(evicted) = pending.pop_front() else {
2328 break;
2329 };
2330 self.pending_header_footer_cache_bytes = self
2331 .pending_header_footer_cache_bytes
2332 .saturating_sub(evicted.bytes);
2333 }
2334 self.pending_header_footer_cache_bytes += entry.bytes;
2335 pending.push_back(entry);
2336 #[cfg(test)]
2337 {
2338 self.pending_header_footer_cache_peak_entries = self
2339 .pending_header_footer_cache_peak_entries
2340 .max(pending.len());
2341 self.pending_header_footer_cache_peak_bytes = self
2342 .pending_header_footer_cache_peak_bytes
2343 .max(self.pending_header_footer_cache_bytes);
2344 }
2345 }
2346}
2347
2348fn paragraph_source_is_cache_safe(
2349 paragraph: &CT_P,
2350 styles: &CT_Styles,
2351 allow_fields: bool,
2352 allow_bookmarks: bool,
2353) -> bool {
2354 let extra_xml_is_represented = paragraph.extra_xml.is_empty()
2355 || (allow_bookmarks && paragraph_bookmark_raw_is_exact(paragraph));
2356 if !paragraph.hyperlinks.is_empty()
2357 || !paragraph.comment_ranges.is_empty()
2358 || (!allow_bookmarks && !paragraph.bookmark_markers.is_empty())
2359 || !extra_xml_is_represented
2360 || !paragraph.content_controls.is_empty()
2361 || !paragraph.revisions.is_empty()
2362 {
2363 return false;
2364 }
2365
2366 let style_id = paragraph
2367 .properties
2368 .as_ref()
2369 .and_then(|properties| properties.style_id.as_deref());
2370 let resolved = style_resolver::resolve_paragraph_properties(style_id, styles);
2371 if resolved.num_id.is_some()
2372 || paragraph.properties.as_ref().is_some_and(|properties| {
2373 properties.num_id.is_some()
2374 || properties.sect_pr.is_some()
2375 || properties.numbering_revision.is_some()
2376 || !properties.numbering_revision_xml.is_empty()
2377 || properties.change.is_some()
2378 || !properties.revision_xml.is_empty()
2379 || properties.rpr.as_ref().is_some_and(|rpr| {
2380 !rpr.revision_markers.is_empty()
2381 || rpr.change.is_some()
2382 || !rpr.revision_xml.is_empty()
2383 || !rpr.revision_xml_positions.is_empty()
2384 })
2385 })
2386 {
2387 return false;
2388 }
2389
2390 paragraph.runs.iter().all(|run| {
2391 run.alt_drawings.is_empty()
2392 && run.extra_xml.is_empty()
2393 && run.extra_xml_positions.is_empty()
2394 && run.properties.as_ref().is_none_or(|rpr| {
2395 rpr.revision_markers.is_empty()
2396 && rpr.change.is_none()
2397 && rpr.revision_xml.is_empty()
2398 && rpr.revision_xml_positions.is_empty()
2399 })
2400 && run.content.iter().all(|content| match content {
2401 RunContent::Text(_)
2402 | RunContent::Tab
2403 | RunContent::Break(_)
2404 | RunContent::FootnoteRef { .. }
2405 | RunContent::EndnoteRef { .. } => true,
2406 RunContent::Field(_) => allow_fields,
2407 _ => false,
2408 })
2409 })
2410}
2411
2412fn paragraph_is_restart_record_source_safe(paragraph: &CT_P, styles: &CT_Styles) -> bool {
2413 paragraph_source_is_cache_safe(paragraph, styles, true, true)
2414 && paragraph.runs.iter().all(|run| {
2415 run.properties
2416 .as_ref()
2417 .is_none_or(|properties| properties.language.is_none())
2418 })
2419}
2420
2421fn paragraph_is_cache_safe(paragraph: &CT_P, styles: &CT_Styles) -> bool {
2422 paragraph_source_is_cache_safe(paragraph, styles, false, false)
2423}
2424
2425fn paragraph_bookmark_raw_is_exact(paragraph: &CT_P) -> bool {
2426 paragraph.extra_xml.len() == paragraph.bookmark_markers.len()
2427 && paragraph
2428 .extra_xml
2429 .iter()
2430 .enumerate()
2431 .zip(¶graph.bookmark_markers)
2432 .all(|((raw_index, (run_index, raw)), marker)| {
2433 let raw_before = paragraph.extra_xml[..raw_index]
2434 .iter()
2435 .filter(|(at, _)| at == run_index)
2436 .count();
2437 *run_index == marker.run_index()
2438 && raw_before == marker.raw_before()
2439 && raw_xml_is_exact_word_bookmark(raw, marker)
2440 })
2441}
2442
2443fn raw_xml_is_exact_word_bookmark(raw: &[u8], marker: &BookmarkMarker) -> bool {
2444 let Some(start) = raw.iter().position(|byte| !byte.is_ascii_whitespace()) else {
2445 return false;
2446 };
2447 let end = raw
2448 .iter()
2449 .rposition(|byte| !byte.is_ascii_whitespace())
2450 .expect("non-empty raw XML has a final byte");
2451 let raw = &raw[start..=end];
2452 if !raw.ends_with(b"/>") {
2453 return false;
2454 }
2455 let Some((root_name, raw_attributes)) = raw_root_start_tag(raw) else {
2456 return false;
2457 };
2458 let Some(attributes) = parse_raw_attributes(raw_attributes) else {
2459 return false;
2460 };
2461 let expected_local_name = if marker.is_start() {
2462 b"bookmarkStart".as_slice()
2463 } else {
2464 b"bookmarkEnd".as_slice()
2465 };
2466 if xml_local_name(root_name) != expected_local_name
2467 || !raw_name_has_namespace(root_name, &attributes, rdocx_oxml::namespace::W_NS, false)
2468 {
2469 return false;
2470 }
2471 let word_attribute_count = |local: &[u8]| {
2472 attributes
2473 .iter()
2474 .filter(|(name, _)| {
2475 xml_local_name(name) == local
2476 && raw_name_has_namespace(name, &attributes, rdocx_oxml::namespace::W_NS, true)
2477 })
2478 .count()
2479 };
2480 if word_attribute_count(b"id") != 1
2481 || word_attribute_count(b"name") != usize::from(marker.is_start())
2482 {
2483 return false;
2484 }
2485
2486 let mut document_xml = Vec::with_capacity(raw.len() + 160);
2487 document_xml.extend_from_slice(
2488 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>"#,
2489 );
2490 document_xml.extend_from_slice(raw);
2491 document_xml.extend_from_slice(b"</w:p></w:body></w:document>");
2492 let Ok(document) = CT_Document::from_xml(&document_xml) else {
2493 return false;
2494 };
2495 let [BodyContent::Paragraph(parsed)] = document.body.content.as_slice() else {
2496 return false;
2497 };
2498 let [(run_index, parsed_raw)] = parsed.extra_xml.as_slice() else {
2499 return false;
2500 };
2501 let [parsed_marker] = parsed.bookmark_markers.as_slice() else {
2502 return false;
2503 };
2504 parsed.properties.is_none()
2505 && parsed.runs.is_empty()
2506 && parsed.hyperlinks.is_empty()
2507 && parsed.comment_ranges.is_empty()
2508 && parsed.content_controls.is_empty()
2509 && parsed.revisions.is_empty()
2510 && *run_index == 0
2511 && parsed_raw == raw
2512 && parsed_marker.raw_before() == 0
2513 && parsed_marker.is_start() == marker.is_start()
2514 && parsed_marker.id().is_some()
2515 && parsed_marker.id() == marker.id()
2516 && parsed_marker.name() == marker.name()
2517 && if parsed_marker.is_start() {
2518 parsed_marker.name().is_some()
2519 } else {
2520 parsed_marker.name().is_none()
2521 }
2522}
2523
2524fn paragraph_has_field(paragraph: &CT_P) -> bool {
2525 paragraph
2526 .runs
2527 .iter()
2528 .flat_map(|run| &run.content)
2529 .any(|content| matches!(content, RunContent::Field(_)))
2530}
2531
2532fn paragraph_has_note_reference(paragraph: &CT_P) -> bool {
2533 paragraph.runs.iter().any(|run| {
2534 run.content.iter().any(|content| {
2535 matches!(
2536 content,
2537 RunContent::FootnoteRef { .. } | RunContent::EndnoteRef { .. }
2538 )
2539 })
2540 })
2541}
2542
2543fn header_footer_part_is_cache_safe(
2544 part: &rdocx_oxml::header_footer::CT_HdrFtr,
2545 styles: &CT_Styles,
2546) -> bool {
2547 if !part.extra_xml.is_empty() {
2548 return false;
2549 }
2550 let raw_watermark_count = part
2551 .paragraphs
2552 .iter()
2553 .flat_map(|paragraph| ¶graph.runs)
2554 .flat_map(|run| &run.extra_xml)
2555 .filter(|raw| raw_xml_root_is_word_pict(raw, &part.extra_namespaces))
2556 .count();
2557 if raw_watermark_count != part.watermarks().len() {
2558 return false;
2559 }
2560 part.paragraphs.iter().all(|paragraph| {
2561 if !paragraph.extra_xml.is_empty() {
2562 return false;
2563 }
2564 let mut projected = paragraph.clone();
2565 for run in &mut projected.runs {
2566 if run.extra_xml.len() != run.extra_xml_positions.len()
2567 || !run
2568 .extra_xml
2569 .iter()
2570 .all(|raw| raw_xml_root_is_word_pict(raw, &part.extra_namespaces))
2571 {
2572 return false;
2573 }
2574 run.extra_xml.clear();
2575 run.extra_xml_positions.clear();
2576 }
2577 !paragraph_has_note_reference(&projected) && paragraph_is_cache_safe(&projected, styles)
2578 })
2579}
2580
2581fn raw_xml_root_is_word_pict(raw: &[u8], namespaces: &[(String, String)]) -> bool {
2582 let raw = raw
2583 .iter()
2584 .position(|byte| !byte.is_ascii_whitespace())
2585 .map_or(raw, |start| &raw[start..]);
2586 let Some((name, attributes)) = raw.strip_prefix(b"<").and_then(|raw| {
2587 let name_end = raw
2588 .iter()
2589 .position(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/'))?;
2590 Some((&raw[..name_end], &raw[name_end..]))
2591 }) else {
2592 return false;
2593 };
2594 let mut components = name.rsplitn(2, |byte| *byte == b':');
2595 if components.next() != Some(b"pict".as_slice()) {
2596 return false;
2597 }
2598 let prefix = components.next();
2599 let declaration = prefix.map_or_else(
2600 || "xmlns".to_owned(),
2601 |prefix| format!("xmlns:{}", String::from_utf8_lossy(prefix)),
2602 );
2603 if let Some(namespace) = raw_xml_start_attribute(attributes, declaration.as_bytes()) {
2604 return namespace == rdocx_oxml::namespace::W_NS.as_bytes();
2605 }
2606 let Some(prefix) = prefix.and_then(|prefix| std::str::from_utf8(prefix).ok()) else {
2607 return false;
2608 };
2609 if prefix == "w" {
2610 return !namespaces.iter().any(|(name, namespace)| {
2611 name != "xmlns:w" && namespace == rdocx_oxml::namespace::W_NS
2612 });
2613 }
2614 namespaces.iter().any(|(name, namespace)| {
2615 name.strip_prefix("xmlns:") == Some(prefix) && namespace == rdocx_oxml::namespace::W_NS
2616 })
2617}
2618
2619fn raw_xml_start_attribute<'a>(mut input: &'a [u8], expected: &[u8]) -> Option<&'a [u8]> {
2620 loop {
2621 input = input
2622 .iter()
2623 .position(|byte| !byte.is_ascii_whitespace())
2624 .map_or(input, |start| &input[start..]);
2625 if input.first().is_none_or(|byte| matches!(byte, b'>' | b'/')) {
2626 return None;
2627 }
2628 let name_end = input
2629 .iter()
2630 .position(|byte| byte.is_ascii_whitespace() || matches!(byte, b'=' | b'>' | b'/'))?;
2631 let name = &input[..name_end];
2632 input = &input[name_end..];
2633 input = input
2634 .iter()
2635 .position(|byte| !byte.is_ascii_whitespace())
2636 .map_or(input, |start| &input[start..]);
2637 if input.first() != Some(&b'=') {
2638 return None;
2639 }
2640 input = &input[1..];
2641 input = input
2642 .iter()
2643 .position(|byte| !byte.is_ascii_whitespace())
2644 .map_or(input, |start| &input[start..]);
2645 let quote = *input.first()?;
2646 if !matches!(quote, b'\'' | b'"') {
2647 return None;
2648 }
2649 input = &input[1..];
2650 let value_end = input.iter().position(|byte| *byte == quote)?;
2651 let value = &input[..value_end];
2652 input = &input[value_end + 1..];
2653 if name == expected {
2654 return Some(value);
2655 }
2656 }
2657}
2658
2659fn header_footer_section_is_cache_safe(section: &CT_SectPr) -> bool {
2660 section.change.is_none() && section.extra_xml.is_empty()
2661}
2662
2663fn table_is_cache_safe(table: &CT_Tbl, styles: &CT_Styles) -> bool {
2664 table.extra_xml.is_empty()
2665 && table.content_controls.is_empty()
2666 && table.properties.as_ref().is_none_or(|properties| {
2667 properties.change.is_none() && properties.revision_xml.is_empty()
2668 })
2669 && table.rows.iter().all(|row| {
2670 row.extra_xml.is_empty()
2671 && row.content_controls.is_empty()
2672 && row.properties.as_ref().is_none_or(|properties| {
2673 properties.revision_markers.is_empty() && properties.revision_xml.is_empty()
2674 })
2675 && row.cells.iter().all(|cell| {
2676 cell.extra_xml.is_empty()
2677 && cell
2678 .properties
2679 .as_ref()
2680 .is_none_or(|properties| properties.extra_xml.is_empty())
2681 && cell.content.iter().all(|content| match content {
2682 CellContent::Paragraph(paragraph) => {
2683 !paragraph_has_note_reference(paragraph)
2684 && paragraph_is_cache_safe(paragraph, styles)
2685 }
2686 CellContent::Table(table) => table_is_cache_safe(table, styles),
2687 CellContent::ContentControl(_) => false,
2688 })
2689 })
2690 })
2691}
2692
2693fn table_semantics(
2694 table: &CT_Tbl,
2695 block: &table::TableBlock,
2696 retained: &TableSemantics,
2697 sources: Option<&SourceRegistry>,
2698 story: &WordStory,
2699 table_path: &[usize],
2700) -> TableSemantics {
2701 let rows = table
2702 .rows
2703 .iter()
2704 .zip(&block.rows)
2705 .zip(&retained.rows)
2706 .enumerate()
2707 .map(|(row_index, ((row, block_row), retained_row))| {
2708 let cells = row
2709 .cells
2710 .iter()
2711 .zip(&block_row.cells)
2712 .zip(&retained_row.cells)
2713 .enumerate()
2714 .map(|(cell_index, ((cell, block_cell), retained_cell))| {
2715 let blocks = cell
2716 .content
2717 .iter()
2718 .zip(&block_cell.blocks)
2719 .zip(&retained_cell.blocks)
2720 .enumerate()
2721 .map(|(content_index, ((content, block_item), retained_item))| {
2722 let mut source_path = table_path.to_vec();
2723 source_path.extend([row_index, cell_index, content_index]);
2724 match (content, block_item, retained_item) {
2725 (
2726 CellContent::Paragraph(_),
2727 table::CellBlock::Paragraph(_),
2728 CellBlockSemantics::Paragraph(retained),
2729 ) => CellBlockSemantics::Paragraph(ParagraphSemantics {
2730 source_node: sources
2731 .and_then(|sources| sources.id(story, &source_path)),
2732 structure_id: None,
2733 reflow_direction: retained.reflow_direction,
2734 }),
2735 (
2736 CellContent::Table(table),
2737 table::CellBlock::Table(block),
2738 CellBlockSemantics::Table(retained),
2739 ) => CellBlockSemantics::Table(table_semantics(
2740 table,
2741 block,
2742 retained,
2743 sources,
2744 story,
2745 &source_path,
2746 )),
2747 _ => unreachable!("cache-safe table topology stays aligned"),
2748 }
2749 })
2750 .collect();
2751 block::CellSemantics { blocks }
2752 })
2753 .collect();
2754 block::RowSemantics { cells }
2755 })
2756 .collect();
2757 TableSemantics { rows }
2758}
2759
2760fn canonicalize_table_sources(block: &mut table::TableBlock) -> Result<()> {
2761 for row in &mut block.rows {
2762 for cell in &mut row.cells {
2763 for block in &mut cell.blocks {
2764 match block {
2765 table::CellBlock::Paragraph(paragraph) => {
2766 rebind_paragraph_source(paragraph, Some(CACHE_SOURCE_NODE))?;
2767 }
2768 table::CellBlock::Table(table) => canonicalize_table_sources(table)?,
2769 }
2770 }
2771 }
2772 }
2773 Ok(())
2774}
2775
2776fn table_cache_entry_bytes(
2777 key: &TableCacheKey,
2778 block: &table::TableBlock,
2779 semantics: &TableSemantics,
2780 diagnostics: &[Diagnostic],
2781 font_trace_len: usize,
2782) -> usize {
2783 let diagnostic_bytes = diagnostics
2784 .len()
2785 .saturating_mul(std::mem::size_of::<Diagnostic>())
2786 .saturating_add(
2787 diagnostics
2788 .iter()
2789 .map(|diagnostic| diagnostic.message.capacity())
2790 .fold(0usize, usize::saturating_add),
2791 );
2792 std::mem::size_of::<TableCacheEntry>()
2793 .saturating_add(table_key_retained_bytes(&key.table))
2794 .saturating_add(table_block_retained_bytes(block))
2795 .saturating_add(table_semantics_retained_bytes(semantics))
2796 .saturating_add(2 * std::mem::size_of::<usize>())
2797 .saturating_add(font_trace_len.saturating_mul(std::mem::size_of::<FontId>()))
2798 .saturating_add(diagnostic_bytes)
2799}
2800
2801fn table_semantics_retained_bytes(semantics: &TableSemantics) -> usize {
2802 let mut bytes = semantics
2803 .rows
2804 .capacity()
2805 .saturating_mul(std::mem::size_of::<block::RowSemantics>());
2806 for row in &semantics.rows {
2807 bytes = bytes.saturating_add(
2808 row.cells
2809 .capacity()
2810 .saturating_mul(std::mem::size_of::<block::CellSemantics>()),
2811 );
2812 for cell in &row.cells {
2813 bytes = bytes.saturating_add(
2814 cell.blocks
2815 .capacity()
2816 .saturating_mul(std::mem::size_of::<CellBlockSemantics>()),
2817 );
2818 for block in &cell.blocks {
2819 if let CellBlockSemantics::Table(table) = block {
2820 bytes = bytes.saturating_add(table_semantics_retained_bytes(table));
2821 }
2822 }
2823 }
2824 }
2825 bytes
2826}
2827
2828fn paragraph_key_retained_bytes(paragraph: &CT_P) -> usize {
2829 fn option_string_bytes(value: &Option<String>) -> usize {
2830 value.as_ref().map_or(0, String::capacity)
2831 }
2832 fn raw_vectors_bytes(values: &[Vec<u8>]) -> usize {
2833 values
2834 .len()
2835 .saturating_mul(std::mem::size_of::<Vec<u8>>())
2836 .saturating_add(
2837 values
2838 .iter()
2839 .map(Vec::capacity)
2840 .fold(0usize, usize::saturating_add),
2841 )
2842 }
2843 fn run_properties_bytes(properties: &CT_RPr) -> usize {
2844 [
2845 &properties.style_id,
2846 &properties.font_ascii,
2847 &properties.font_hansi,
2848 &properties.font_east_asia,
2849 &properties.font_cs,
2850 &properties.font_ascii_theme,
2851 &properties.font_hansi_theme,
2852 &properties.color,
2853 &properties.color_theme,
2854 &properties.vert_align,
2855 ]
2856 .into_iter()
2857 .map(option_string_bytes)
2858 .fold(0usize, usize::saturating_add)
2859 .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
2860 .saturating_add(
2861 properties
2862 .revision_markers
2863 .capacity()
2864 .saturating_mul(std::mem::size_of::<CT_Revision>()),
2865 )
2866 .saturating_add(raw_vectors_bytes(&properties.revision_xml))
2867 .saturating_add(
2868 properties
2869 .revision_xml_positions
2870 .capacity()
2871 .saturating_mul(std::mem::size_of::<(u8, usize)>()),
2872 )
2873 }
2874 fn shading_bytes(shading: &CT_Shd) -> usize {
2875 shading
2876 .val
2877 .capacity()
2878 .saturating_add(option_string_bytes(&shading.color))
2879 .saturating_add(option_string_bytes(&shading.fill))
2880 }
2881 fn paragraph_border_bytes(borders: &CT_PBdr) -> usize {
2882 [
2883 &borders.top,
2884 &borders.bottom,
2885 &borders.left,
2886 &borders.right,
2887 &borders.between,
2888 &borders.bar,
2889 ]
2890 .into_iter()
2891 .filter_map(Option::as_ref)
2892 .map(|edge| option_string_bytes(&edge.color))
2893 .fold(0usize, usize::saturating_add)
2894 }
2895 fn paragraph_properties_bytes(properties: &CT_PPr) -> usize {
2896 option_string_bytes(&properties.style_id)
2897 .saturating_add(option_string_bytes(&properties.line_rule))
2898 .saturating_add(
2899 properties
2900 .borders
2901 .as_ref()
2902 .map_or(0, paragraph_border_bytes),
2903 )
2904 .saturating_add(properties.tabs.as_ref().map_or(0, |tabs| {
2905 tabs.tabs
2906 .capacity()
2907 .saturating_mul(std::mem::size_of::<CT_TabStop>())
2908 }))
2909 .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
2910 .saturating_add(properties.rpr.as_ref().map_or(0, run_properties_bytes))
2911 .saturating_add(raw_vectors_bytes(&properties.numbering_revision_xml))
2912 .saturating_add(raw_vectors_bytes(&properties.revision_xml))
2913 }
2914
2915 let run_bytes = paragraph
2916 .runs
2917 .capacity()
2918 .saturating_mul(std::mem::size_of::<CT_R>())
2919 .saturating_add(
2920 paragraph
2921 .runs
2922 .iter()
2923 .map(|run| {
2924 run.content
2925 .capacity()
2926 .saturating_mul(std::mem::size_of::<RunContent>())
2927 .saturating_add(
2928 run.content
2929 .iter()
2930 .map(|content| match content {
2931 RunContent::Text(text) | RunContent::DeletedText(text) => {
2932 text.text.capacity()
2933 }
2934 RunContent::Tab
2935 | RunContent::Break(_)
2936 | RunContent::Drawing(_)
2937 | RunContent::Field(_)
2938 | RunContent::FootnoteRef { .. }
2939 | RunContent::EndnoteRef { .. }
2940 | RunContent::CommentReference { .. } => 0,
2941 })
2942 .fold(0usize, usize::saturating_add),
2943 )
2944 .saturating_add(run.properties.as_ref().map_or(0, run_properties_bytes))
2945 .saturating_add(raw_vectors_bytes(&run.extra_xml))
2946 .saturating_add(
2947 run.extra_xml_positions
2948 .capacity()
2949 .saturating_mul(std::mem::size_of::<usize>()),
2950 )
2951 })
2952 .fold(0usize, usize::saturating_add),
2953 );
2954 let paragraph_vectors = paragraph
2955 .hyperlinks
2956 .capacity()
2957 .saturating_mul(std::mem::size_of::<rdocx_oxml::text::HyperlinkSpan>())
2958 .saturating_add(
2959 paragraph
2960 .comment_ranges
2961 .capacity()
2962 .saturating_mul(std::mem::size_of::<rdocx_oxml::text::CommentRangeMarker>()),
2963 )
2964 .saturating_add(
2965 paragraph
2966 .extra_xml
2967 .capacity()
2968 .saturating_mul(std::mem::size_of::<(usize, Vec<u8>)>()),
2969 )
2970 .saturating_add(
2971 paragraph
2972 .extra_xml
2973 .iter()
2974 .map(|(_, raw)| raw.capacity())
2975 .fold(0usize, usize::saturating_add),
2976 );
2977 run_bytes.saturating_add(paragraph_vectors).saturating_add(
2978 paragraph
2979 .properties
2980 .as_ref()
2981 .map_or(0, paragraph_properties_bytes),
2982 )
2983}
2984
2985fn table_key_retained_bytes(table: &CT_Tbl) -> usize {
2986 fn option_string_bytes(value: &Option<String>) -> usize {
2987 value.as_ref().map_or(0, String::capacity)
2988 }
2989 fn raw_entries_bytes(values: &[(usize, Vec<u8>)], capacity: usize) -> usize {
2990 capacity
2991 .saturating_mul(std::mem::size_of::<(usize, Vec<u8>)>())
2992 .saturating_add(
2993 values
2994 .iter()
2995 .map(|(_, raw)| raw.capacity())
2996 .fold(0usize, usize::saturating_add),
2997 )
2998 }
2999 fn raw_vectors_bytes(values: &[Vec<u8>]) -> usize {
3000 values
3001 .len()
3002 .saturating_mul(std::mem::size_of::<Vec<u8>>())
3003 .saturating_add(
3004 values
3005 .iter()
3006 .map(Vec::capacity)
3007 .fold(0usize, usize::saturating_add),
3008 )
3009 }
3010 fn shading_bytes(shading: &CT_Shd) -> usize {
3011 shading
3012 .val
3013 .capacity()
3014 .saturating_add(option_string_bytes(&shading.color))
3015 .saturating_add(option_string_bytes(&shading.fill))
3016 }
3017 fn table_border_bytes(borders: &rdocx_oxml::table::CT_TblBorders) -> usize {
3018 [
3019 &borders.top,
3020 &borders.bottom,
3021 &borders.left,
3022 &borders.right,
3023 &borders.inside_h,
3024 &borders.inside_v,
3025 ]
3026 .into_iter()
3027 .filter_map(Option::as_ref)
3028 .map(|edge| option_string_bytes(&edge.color))
3029 .fold(0usize, usize::saturating_add)
3030 }
3031 fn width_bytes(width: &rdocx_oxml::table::CT_TblWidth) -> usize {
3032 width.width_type.capacity()
3033 }
3034 fn table_properties_bytes(properties: &rdocx_oxml::table::CT_TblPr) -> usize {
3035 option_string_bytes(&properties.style_id)
3036 .saturating_add(option_string_bytes(&properties.layout))
3037 .saturating_add(properties.width.as_ref().map_or(0, width_bytes))
3038 .saturating_add(properties.indent.as_ref().map_or(0, width_bytes))
3039 .saturating_add(properties.borders.as_ref().map_or(0, table_border_bytes))
3040 .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3041 .saturating_add(
3042 properties
3043 .look
3044 .as_ref()
3045 .map_or(0, |look| option_string_bytes(&look.val)),
3046 )
3047 .saturating_add(raw_vectors_bytes(&properties.revision_xml))
3048 }
3049 fn row_properties_bytes(properties: &rdocx_oxml::table::CT_TrPr) -> usize {
3050 option_string_bytes(&properties.height_rule)
3051 .saturating_add(option_string_bytes(&properties.cnf_style))
3052 .saturating_add(
3053 properties
3054 .revision_markers
3055 .capacity()
3056 .saturating_mul(std::mem::size_of::<CT_Revision>()),
3057 )
3058 .saturating_add(raw_vectors_bytes(&properties.revision_xml))
3059 }
3060 fn cell_properties_bytes(properties: &rdocx_oxml::table::CT_TcPr) -> usize {
3061 properties
3062 .width
3063 .as_ref()
3064 .map_or(0, width_bytes)
3065 .saturating_add(properties.borders.as_ref().map_or(0, table_border_bytes))
3066 .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3067 .saturating_add(option_string_bytes(&properties.text_direction))
3068 .saturating_add(option_string_bytes(&properties.cnf_style))
3069 .saturating_add(raw_entries_bytes(
3070 &properties.extra_xml,
3071 properties.extra_xml.capacity(),
3072 ))
3073 }
3074
3075 let grid_bytes = table.grid.as_ref().map_or(0, |grid| {
3076 grid.columns
3077 .capacity()
3078 .saturating_mul(std::mem::size_of::<rdocx_oxml::table::CT_TblGridCol>())
3079 });
3080 table
3081 .rows
3082 .capacity()
3083 .saturating_mul(std::mem::size_of::<CT_Row>())
3084 .saturating_add(grid_bytes)
3085 .saturating_add(
3086 table
3087 .rows
3088 .iter()
3089 .map(|row| {
3090 row.properties
3091 .as_ref()
3092 .map_or(0, row_properties_bytes)
3093 .saturating_add(raw_entries_bytes(&row.extra_xml, row.extra_xml.capacity()))
3094 .saturating_add(
3095 row.content_controls
3096 .capacity()
3097 .saturating_mul(std::mem::size_of::<(usize, usize, CT_Sdt)>()),
3098 )
3099 .saturating_add(
3100 row.cells
3101 .capacity()
3102 .saturating_mul(std::mem::size_of::<CT_Tc>())
3103 .saturating_add(
3104 row.cells
3105 .iter()
3106 .map(|cell| {
3107 cell.properties
3108 .as_ref()
3109 .map_or(0, cell_properties_bytes)
3110 .saturating_add(raw_entries_bytes(
3111 &cell.extra_xml,
3112 cell.extra_xml.capacity(),
3113 ))
3114 .saturating_add(cell.content
3115 .capacity()
3116 .saturating_mul(std::mem::size_of::<CellContent>())
3117 .saturating_add(
3118 cell.content
3119 .iter()
3120 .map(|content| match content {
3121 CellContent::Paragraph(paragraph) => {
3122 paragraph_key_retained_bytes(paragraph)
3123 }
3124 CellContent::Table(table) => {
3125 table_key_retained_bytes(table)
3126 }
3127 CellContent::ContentControl(_) => usize::MAX,
3128 })
3129 .fold(0usize, usize::saturating_add),
3130 ))
3131 })
3132 .fold(0usize, usize::saturating_add),
3133 ),
3134 )
3135 })
3136 .fold(0usize, usize::saturating_add),
3137 )
3138 .saturating_add(table.properties.as_ref().map_or(0, table_properties_bytes))
3139 .saturating_add(raw_entries_bytes(
3140 &table.extra_xml,
3141 table.extra_xml.capacity(),
3142 ))
3143 .saturating_add(
3144 table
3145 .content_controls
3146 .capacity()
3147 .saturating_mul(std::mem::size_of::<(usize, usize, CT_Sdt)>()),
3148 )
3149}
3150
3151fn table_block_retained_bytes(block: &table::TableBlock) -> usize {
3152 fn border_bytes(borders: &rdocx_oxml::table::CT_TblBorders) -> usize {
3153 [
3154 &borders.top,
3155 &borders.bottom,
3156 &borders.left,
3157 &borders.right,
3158 &borders.inside_h,
3159 &borders.inside_v,
3160 ]
3161 .into_iter()
3162 .map(|edge| {
3163 edge.as_ref()
3164 .and_then(|edge| edge.color.as_ref())
3165 .map_or(0, String::capacity)
3166 })
3167 .fold(0usize, usize::saturating_add)
3168 }
3169
3170 let rows = block
3171 .rows
3172 .iter()
3173 .map(|row| {
3174 row.cells
3175 .capacity()
3176 .saturating_mul(std::mem::size_of::<table::TableCell>())
3177 .saturating_add(
3178 row.cells
3179 .iter()
3180 .map(|cell| {
3181 cell.blocks
3182 .capacity()
3183 .saturating_mul(std::mem::size_of::<table::CellBlock>())
3184 .saturating_add(
3185 cell.blocks
3186 .iter()
3187 .map(|block| match block {
3188 table::CellBlock::Paragraph(paragraph) => {
3189 paragraph_cache_entry_bytes(
3190 &CT_P::new(),
3191 paragraph,
3192 &[],
3193 0,
3194 )
3195 }
3196 table::CellBlock::Table(table) => {
3197 table_block_retained_bytes(table)
3198 }
3199 })
3200 .fold(0usize, usize::saturating_add),
3201 )
3202 .saturating_add(cell.borders.as_ref().map_or(0, border_bytes))
3203 })
3204 .fold(0usize, usize::saturating_add),
3205 )
3206 })
3207 .fold(0usize, usize::saturating_add);
3208 std::mem::size_of::<table::TableBlock>()
3209 .saturating_add(
3210 block
3211 .col_widths
3212 .capacity()
3213 .saturating_mul(std::mem::size_of::<f64>()),
3214 )
3215 .saturating_add(
3216 block
3217 .rows
3218 .capacity()
3219 .saturating_mul(std::mem::size_of::<table::TableRow>()),
3220 )
3221 .saturating_add(
3222 block
3223 .header_row_indices
3224 .capacity()
3225 .saturating_mul(std::mem::size_of::<usize>()),
3226 )
3227 .saturating_add(rows)
3228 .saturating_add(block.borders.as_ref().map_or(0, border_bytes))
3229}
3230
3231fn canonicalize_layout_fonts(
3232 pages: &mut [Arc<PageFrame>],
3233 font_manager: &FontManager,
3234 current_fonts: &[FontId],
3235) -> Result<Vec<oxml_layout::FontData>> {
3236 fn collect(
3237 elements: &[PositionedElement],
3238 remap: &mut HashMap<FontId, FontId>,
3239 order: &mut Vec<FontId>,
3240 ) {
3241 for element in elements {
3242 match element {
3243 PositionedElement::Text(run) => {
3244 if let std::collections::hash_map::Entry::Vacant(entry) =
3245 remap.entry(run.font_id)
3246 {
3247 let local = FontId(order.len() as u32);
3248 entry.insert(local);
3249 order.push(run.font_id);
3250 }
3251 }
3252 PositionedElement::MultilingualText(run) => {
3253 if let std::collections::hash_map::Entry::Vacant(entry) =
3254 remap.entry(run.font_id)
3255 {
3256 let local = FontId(order.len() as u32);
3257 entry.insert(local);
3258 order.push(run.font_id);
3259 }
3260 }
3261 PositionedElement::Group(group) => collect(&group.children, remap, order),
3262 PositionedElement::MarkedContent { children, .. } => {
3263 collect(children, remap, order)
3264 }
3265 _ => {}
3266 }
3267 }
3268 }
3269
3270 fn rewrite(elements: &mut [PositionedElement], remap: &HashMap<FontId, FontId>) {
3271 for element in elements {
3272 match element {
3273 PositionedElement::Text(run) => {
3274 run.font_id = remap[&run.font_id];
3275 }
3276 PositionedElement::MultilingualText(run) => {
3277 run.font_id = remap[&run.font_id];
3278 }
3279 PositionedElement::Group(group) => rewrite(&mut group.children, remap),
3280 PositionedElement::MarkedContent { children, .. } => rewrite(children, remap),
3281 _ => {}
3282 }
3283 }
3284 }
3285
3286 let mut remap = HashMap::new();
3287 let mut order = Vec::with_capacity(current_fonts.len());
3288 for &font_id in current_fonts {
3289 if let std::collections::hash_map::Entry::Vacant(entry) = remap.entry(font_id) {
3290 let local = FontId(order.len() as u32);
3291 entry.insert(local);
3292 order.push(font_id);
3293 }
3294 }
3295 for page in pages.iter() {
3296 collect(&page.elements, &mut remap, &mut order);
3297 }
3298 let mut fonts = Vec::with_capacity(order.len());
3299 for persistent_id in order {
3300 let mut font = font_manager.font_data(persistent_id)?;
3301 font.id = remap[&persistent_id];
3302 fonts.push(font);
3303 }
3304 for page in pages {
3305 rewrite(&mut Arc::make_mut(page).elements, &remap);
3306 }
3307 Ok(fonts)
3308}
3309
3310fn restart_block_is_safe<B: LayoutBlockLike>(block: &B) -> bool {
3311 if let Some(paragraph) = block.paragraph() {
3312 restart_record_block_is_safe(block)
3313 && paragraph.lines.iter().all(|line| {
3314 line.items.iter().all(|item| match item {
3315 LineItem::Text(text) | LineItem::Marker(text) => text.field_kind.is_none(),
3316 LineItem::MultilingualText(text) => text.base().field_kind.is_none(),
3317 LineItem::Tab {
3318 leader: Some(text), ..
3319 } => text.field_kind.is_none(),
3320 _ => true,
3321 })
3322 })
3323 } else {
3324 restart_record_block_is_safe(block)
3325 }
3326}
3327
3328fn restart_record_block_is_safe<B: LayoutBlockLike>(block: &B) -> bool {
3329 if let Some(paragraph) = block.paragraph() {
3330 paragraph.anchored.is_empty()
3331 && paragraph.lines.iter().all(|line| {
3332 line.items.iter().all(|item| match item {
3333 LineItem::Text(_) | LineItem::Marker(_) => true,
3334 LineItem::MultilingualText(_) => false,
3335 LineItem::Tab {
3336 leader: Some(_), ..
3337 } => true,
3338 LineItem::Tab { leader: None, .. } => true,
3339 LineItem::Image { .. } | LineItem::Group { .. } => false,
3340 _ => false,
3341 })
3342 })
3343 } else {
3344 block.table().is_some()
3345 }
3346}
3347
3348fn page_has_substitution_state(page: &PageFrame) -> bool {
3349 let mut found = false;
3350 oxml_layout::walk(&page.elements, &mut |element, _| {
3351 found |= match element {
3352 PositionedElement::Text(run) => run.field_kind.is_some(),
3353 PositionedElement::MultilingualText(run) => run.field_kind.is_some(),
3354 _ => false,
3355 };
3356 });
3357 found
3358}
3359
3360fn restart_cache_entries(cache: &RestartCache) -> usize {
3361 cache.raw_pages.len().max(cache.checkpoints.len())
3362}
3363
3364fn restart_cache_bytes(cache: &RestartCache) -> usize {
3365 let vector_bytes = cache
3366 .body
3367 .capacity()
3368 .saturating_mul(std::mem::size_of::<RestartBodyEntry>())
3369 .saturating_add(
3370 cache
3371 .raw_pages
3372 .capacity()
3373 .saturating_add(cache.pages.capacity())
3374 .saturating_mul(std::mem::size_of::<Arc<PageFrame>>()),
3375 )
3376 .saturating_add(
3377 cache
3378 .substitution_inputs
3379 .capacity()
3380 .saturating_mul(std::mem::size_of::<Option<FieldSubstitutionInputs>>()),
3381 )
3382 .saturating_add(
3383 cache
3384 .outlines
3385 .capacity()
3386 .saturating_mul(std::mem::size_of::<oxml_layout::OutlineEntry>()),
3387 )
3388 .saturating_add(
3389 cache
3390 .checkpoints
3391 .capacity()
3392 .saturating_mul(std::mem::size_of::<paginator::PaginationCheckpoint>()),
3393 )
3394 .saturating_add(
3395 cache
3396 .font_trace
3397 .capacity()
3398 .saturating_mul(std::mem::size_of::<FontId>()),
3399 );
3400 let body_bytes = cache
3401 .body
3402 .iter()
3403 .map(RestartBodyEntry::bytes)
3404 .fold(0usize, usize::saturating_add);
3405 debug_assert_eq!(cache.raw_pages.len(), cache.pages.len());
3406 let page_bytes = cache
3407 .raw_pages
3408 .iter()
3409 .zip(&cache.pages)
3410 .map(|(pristine, substituted)| {
3411 let substituted_bytes = if Arc::ptr_eq(pristine, substituted) {
3412 0
3413 } else {
3414 page_frame_retained_bytes(substituted)
3415 };
3416 page_frame_retained_bytes(pristine)
3417 .saturating_add(2 * std::mem::size_of::<usize>())
3418 .saturating_add(substituted_bytes)
3419 .saturating_add(if Arc::ptr_eq(pristine, substituted) {
3420 0
3421 } else {
3422 2 * std::mem::size_of::<usize>()
3423 })
3424 })
3425 .fold(0usize, usize::saturating_add);
3426 let outline_bytes = cache
3427 .outlines
3428 .iter()
3429 .map(|outline| outline.title.capacity())
3430 .fold(0usize, usize::saturating_add);
3431 let substitution_bytes = cache
3432 .substitution_inputs
3433 .iter()
3434 .filter_map(Option::as_ref)
3435 .map(|inputs| {
3436 inputs
3437 .bookmark_pages
3438 .capacity()
3439 .saturating_mul(std::mem::size_of::<(usize, usize)>())
3440 .saturating_add(
3441 inputs
3442 .font_identity
3443 .capacity()
3444 .saturating_mul(std::mem::size_of::<FontId>()),
3445 )
3446 })
3447 .fold(0usize, usize::saturating_add);
3448 std::mem::size_of::<RestartCache>()
3449 .saturating_add(vector_bytes)
3450 .saturating_add(body_bytes)
3451 .saturating_add(page_bytes)
3452 .saturating_add(substitution_bytes)
3453 .saturating_add(outline_bytes)
3454}
3455
3456fn page_frame_retained_bytes(page: &PageFrame) -> usize {
3457 fn glyph_bytes(run: &GlyphRun) -> usize {
3458 run.text
3459 .capacity()
3460 .saturating_add(
3461 run.glyph_ids
3462 .capacity()
3463 .saturating_mul(std::mem::size_of::<u16>()),
3464 )
3465 .saturating_add(
3466 run.advances
3467 .capacity()
3468 .saturating_mul(std::mem::size_of::<f64>()),
3469 )
3470 }
3471
3472 fn multilingual_glyph_bytes(run: &oxml_layout::MultilingualGlyphRun) -> usize {
3473 run.logical_text
3474 .capacity()
3475 .saturating_add(run.language.as_ref().map_or(0, String::capacity))
3476 .saturating_add(
3477 run.glyph_ids
3478 .capacity()
3479 .saturating_mul(std::mem::size_of::<u16>()),
3480 )
3481 .saturating_add(
3482 [
3483 run.x_advances.capacity(),
3484 run.y_advances.capacity(),
3485 run.x_offsets.capacity(),
3486 run.y_offsets.capacity(),
3487 ]
3488 .into_iter()
3489 .sum::<usize>()
3490 .saturating_mul(std::mem::size_of::<f64>()),
3491 )
3492 .saturating_add(
3493 run.clusters
3494 .capacity()
3495 .saturating_mul(std::mem::size_of::<oxml_layout::GlyphCluster>()),
3496 )
3497 }
3498
3499 fn element_bytes(element: &PositionedElement) -> usize {
3500 match element {
3501 PositionedElement::Text(run) => glyph_bytes(run),
3502 PositionedElement::MultilingualText(run) => multilingual_glyph_bytes(run),
3503 PositionedElement::Image {
3504 data, content_type, ..
3505 } => data.capacity().saturating_add(content_type.capacity()),
3506 PositionedElement::LinkAnnotation { url, .. } => url.capacity(),
3507 PositionedElement::Group(group) => group
3508 .children
3509 .capacity()
3510 .saturating_mul(std::mem::size_of::<PositionedElement>())
3511 .saturating_add(
3512 group
3513 .children
3514 .iter()
3515 .map(element_bytes)
3516 .fold(0usize, usize::saturating_add),
3517 )
3518 .saturating_add(format!("{:?}", group.effects).len()),
3519 PositionedElement::MarkedContent { children, .. } => children
3520 .capacity()
3521 .saturating_mul(std::mem::size_of::<PositionedElement>())
3522 .saturating_add(
3523 children
3524 .iter()
3525 .map(element_bytes)
3526 .fold(0usize, usize::saturating_add),
3527 ),
3528 PositionedElement::Path(path) => format!("{path:?}").len(),
3529 _ => 0,
3530 }
3531 }
3532
3533 std::mem::size_of::<PageFrame>()
3534 .saturating_add(
3535 page.elements
3536 .capacity()
3537 .saturating_mul(std::mem::size_of::<PositionedElement>()),
3538 )
3539 .saturating_add(
3540 page.elements
3541 .iter()
3542 .map(element_bytes)
3543 .fold(0usize, usize::saturating_add),
3544 )
3545 .saturating_add(format!("{:?}", page.background).len())
3546}
3547
3548fn rebind_text_source(text: &mut TextSegment, source_node: Option<SourceNodeId>) {
3549 match (text.source.as_mut(), source_node) {
3550 (Some(source), Some(node)) => source.node = node,
3551 (Some(_), None) => text.source = None,
3552 (None, _) => {}
3553 }
3554}
3555
3556fn rebind_multilingual_source(
3557 text: &mut oxml_layout::MultilingualTextSegment,
3558 source_node: Option<SourceNodeId>,
3559) -> Result<()> {
3560 let mut base = text.base().clone();
3561 rebind_text_source(&mut base, source_node);
3562 *text = oxml_layout::MultilingualTextSegment::new(
3563 base,
3564 text.logical_index(),
3565 text.language().map(str::to_owned),
3566 text.script(),
3567 text.direction(),
3568 text.bidi_level(),
3569 text.x_advances().to_vec(),
3570 text.y_advances().to_vec(),
3571 text.x_offsets().to_vec(),
3572 text.y_offsets().to_vec(),
3573 text.clusters().to_vec(),
3574 text.break_after(),
3575 )?;
3576 Ok(())
3577}
3578
3579fn rebind_paragraph_source(
3580 block: &mut ParagraphBlock,
3581 source_node: Option<SourceNodeId>,
3582) -> Result<()> {
3583 for line in &mut block.lines {
3584 for item in &mut line.items {
3585 match item {
3586 LineItem::Text(text) | LineItem::Marker(text) => {
3587 rebind_text_source(text, source_node)
3588 }
3589 LineItem::MultilingualText(text) => rebind_multilingual_source(text, source_node)?,
3590 LineItem::Tab {
3591 leader: Some(leader),
3592 ..
3593 } => rebind_text_source(leader, source_node),
3594 _ => {}
3595 }
3596 }
3597 }
3598 if let Some(reflow) = block.reflow.as_mut() {
3599 for item in &mut reflow.items {
3600 match item {
3601 InlineItem::Text(text) | InlineItem::Marker(text) => {
3602 rebind_text_source(text, source_node)
3603 }
3604 InlineItem::MultilingualText(text) => {
3605 rebind_multilingual_source(text, source_node)?
3606 }
3607 InlineItem::HyphenatedText { segment, .. } => {
3608 rebind_text_source(segment, source_node)
3609 }
3610 _ => {}
3611 }
3612 }
3613 }
3614 Ok(())
3615}
3616
3617fn rebind_header_footer_sources(
3618 story_kind: HeaderFooterStoryKind,
3619 relationship_id: &str,
3620 part: &rdocx_oxml::header_footer::CT_HdrFtr,
3621 blocks: &mut [ParagraphBlock],
3622 sources: Option<&SourceRegistry>,
3623) -> Result<()> {
3624 let story = match story_kind {
3625 HeaderFooterStoryKind::Header => WordStory::Header {
3626 relationship_id: relationship_id.to_owned(),
3627 },
3628 HeaderFooterStoryKind::Footer => WordStory::Footer {
3629 relationship_id: relationship_id.to_owned(),
3630 },
3631 };
3632 for (paragraph_index, block) in blocks.iter_mut().enumerate() {
3633 debug_assert!(paragraph_index < part.paragraphs.len());
3634 let source = sources.and_then(|sources| sources.id(&story, &[paragraph_index]));
3635 rebind_paragraph_source(block, source)?;
3636 }
3637 Ok(())
3638}
3639
3640fn header_footer_cache_entry_bytes(
3641 key: &HeaderFooterCacheKey,
3642 content: &HeaderFooterVariantContent,
3643 diagnostics: &Vec<Diagnostic>,
3644 font_trace: &Vec<FontId>,
3645) -> usize {
3646 let block_bytes = key
3647 .part
3648 .paragraphs
3649 .iter()
3650 .zip(&content.blocks)
3651 .map(|(paragraph, block)| paragraph_cache_entry_bytes(paragraph, block, &[], 0))
3652 .fold(0usize, usize::saturating_add);
3653 let direction_bytes = content
3654 .directions
3655 .capacity()
3656 .saturating_mul(std::mem::size_of::<TextDirection>());
3657 let diagnostic_bytes = diagnostics
3658 .capacity()
3659 .saturating_mul(std::mem::size_of::<Diagnostic>())
3660 .saturating_add(
3661 diagnostics
3662 .iter()
3663 .map(|diagnostic| diagnostic.message.capacity())
3664 .fold(0usize, usize::saturating_add),
3665 );
3666 let watermark_bytes = content.watermark.as_ref().map_or(0, |watermark| {
3667 page_frame_retained_bytes(&PageFrame::new(
3668 1,
3669 0.0,
3670 0.0,
3671 vec![PositionedElement::Group(watermark.clone())],
3672 ))
3673 });
3674 let section_capacity = key
3675 .section
3676 .header_refs
3677 .capacity()
3678 .saturating_mul(std::mem::size_of::<rdocx_oxml::header_footer::HdrFtrRef>())
3679 .saturating_add(
3680 key.section
3681 .footer_refs
3682 .capacity()
3683 .saturating_mul(std::mem::size_of::<rdocx_oxml::header_footer::HdrFtrRef>()),
3684 )
3685 .saturating_add(key.section.columns.as_ref().map_or(0, |columns| {
3686 columns
3687 .columns
3688 .capacity()
3689 .saturating_mul(std::mem::size_of::<rdocx_oxml::document::CT_Column>())
3690 }))
3691 .saturating_add(
3692 key.section
3693 .extra_xml
3694 .capacity()
3695 .saturating_mul(std::mem::size_of::<Vec<u8>>()),
3696 )
3697 .saturating_add(
3698 key.section
3699 .header_refs
3700 .iter()
3701 .chain(&key.section.footer_refs)
3702 .map(|reference| reference.rel_id.capacity())
3703 .fold(0usize, usize::saturating_add),
3704 )
3705 .saturating_add(
3706 key.section
3707 .extra_xml
3708 .iter()
3709 .map(Vec::capacity)
3710 .fold(0usize, usize::saturating_add),
3711 );
3712 let paragraph_raw_capacity = key
3713 .part
3714 .paragraphs
3715 .iter()
3716 .map(|paragraph| {
3717 paragraph
3718 .extra_xml
3719 .capacity()
3720 .saturating_mul(std::mem::size_of::<(usize, Vec<u8>)>())
3721 .saturating_add(
3722 paragraph
3723 .extra_xml
3724 .iter()
3725 .map(|(_, raw)| raw.capacity())
3726 .fold(0usize, usize::saturating_add),
3727 )
3728 .saturating_add(
3729 paragraph
3730 .runs
3731 .iter()
3732 .map(|run| {
3733 run.extra_xml
3734 .capacity()
3735 .saturating_mul(std::mem::size_of::<Vec<u8>>())
3736 .saturating_add(
3737 run.extra_xml
3738 .iter()
3739 .map(Vec::capacity)
3740 .fold(0usize, usize::saturating_add),
3741 )
3742 .saturating_add(
3743 run.extra_xml_positions
3744 .capacity()
3745 .saturating_mul(std::mem::size_of::<usize>()),
3746 )
3747 })
3748 .fold(0usize, usize::saturating_add),
3749 )
3750 })
3751 .fold(0usize, usize::saturating_add);
3752 let watermark_capacity = key
3753 .part
3754 .watermarks()
3755 .iter()
3756 .map(|watermark| {
3757 std::mem::size_of::<VmlWatermark>().saturating_add(match watermark {
3758 VmlWatermark::Text {
3759 text,
3760 color,
3761 font_family,
3762 ..
3763 } => text
3764 .capacity()
3765 .saturating_add(color.capacity())
3766 .saturating_add(font_family.as_ref().map_or(0, String::capacity)),
3767 VmlWatermark::Image {
3768 relationship_id, ..
3769 } => relationship_id.capacity(),
3770 })
3771 })
3772 .fold(0usize, usize::saturating_add);
3773 let part_capacity = key
3774 .part
3775 .paragraphs
3776 .capacity()
3777 .saturating_mul(std::mem::size_of::<CT_P>())
3778 .saturating_add(
3779 content
3780 .blocks
3781 .capacity()
3782 .saturating_mul(std::mem::size_of::<ParagraphBlock>()),
3783 )
3784 .saturating_add(
3785 key.part
3786 .extra_namespaces
3787 .capacity()
3788 .saturating_mul(std::mem::size_of::<(String, String)>()),
3789 )
3790 .saturating_add(
3791 key.part
3792 .extra_xml
3793 .capacity()
3794 .saturating_mul(std::mem::size_of::<Vec<u8>>()),
3795 )
3796 .saturating_add(
3797 key.part
3798 .extra_namespaces
3799 .iter()
3800 .map(|(prefix, namespace)| prefix.capacity().saturating_add(namespace.capacity()))
3801 .fold(0usize, usize::saturating_add),
3802 )
3803 .saturating_add(
3804 key.part
3805 .extra_xml
3806 .iter()
3807 .map(Vec::capacity)
3808 .fold(0usize, usize::saturating_add),
3809 );
3810 std::mem::size_of::<HeaderFooterCacheEntry>()
3811 .saturating_add(key.relationship_id.capacity())
3812 .saturating_add(key.resolved_part_bytes.capacity())
3813 .saturating_add(format!("{:?}", key.section).len())
3814 .saturating_add(format!("{:?}", key.part).len())
3815 .saturating_add(section_capacity)
3816 .saturating_add(part_capacity)
3817 .saturating_add(paragraph_raw_capacity)
3818 .saturating_add(watermark_capacity)
3819 .saturating_add(block_bytes)
3820 .saturating_add(direction_bytes)
3821 .saturating_add(watermark_bytes)
3822 .saturating_add(diagnostic_bytes)
3823 .saturating_add(
3824 font_trace
3825 .capacity()
3826 .saturating_mul(std::mem::size_of::<FontId>()),
3827 )
3828}
3829
3830fn paragraph_cache_entry_bytes(
3831 paragraph: &CT_P,
3832 block: &ParagraphBlock,
3833 diagnostics: &[Diagnostic],
3834 font_trace_len: usize,
3835) -> usize {
3836 fn option_string_bytes(value: &Option<String>) -> usize {
3837 value.as_ref().map_or(0, String::capacity)
3838 }
3839 fn shading_bytes(shading: &CT_Shd) -> usize {
3840 shading
3841 .val
3842 .capacity()
3843 .saturating_add(option_string_bytes(&shading.color))
3844 .saturating_add(option_string_bytes(&shading.fill))
3845 }
3846 fn run_properties_bytes(properties: &CT_RPr) -> usize {
3847 [
3848 &properties.style_id,
3849 &properties.font_ascii,
3850 &properties.font_hansi,
3851 &properties.font_east_asia,
3852 &properties.font_cs,
3853 &properties.font_ascii_theme,
3854 &properties.font_hansi_theme,
3855 &properties.color,
3856 &properties.color_theme,
3857 &properties.vert_align,
3858 ]
3859 .into_iter()
3860 .map(option_string_bytes)
3861 .fold(0usize, usize::saturating_add)
3862 .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3863 }
3864 fn border_bytes(borders: &CT_PBdr) -> usize {
3865 [
3866 &borders.top,
3867 &borders.bottom,
3868 &borders.left,
3869 &borders.right,
3870 &borders.between,
3871 &borders.bar,
3872 ]
3873 .into_iter()
3874 .map(|edge| {
3875 edge.as_ref()
3876 .and_then(|edge| edge.color.as_ref())
3877 .map_or(0, String::capacity)
3878 })
3879 .fold(0usize, usize::saturating_add)
3880 }
3881 fn paragraph_properties_bytes(properties: &CT_PPr) -> usize {
3882 option_string_bytes(&properties.style_id)
3883 .saturating_add(option_string_bytes(&properties.line_rule))
3884 .saturating_add(properties.borders.as_ref().map_or(0, border_bytes))
3885 .saturating_add(properties.tabs.as_ref().map_or(0, |tabs| {
3886 tabs.tabs
3887 .capacity()
3888 .saturating_mul(std::mem::size_of::<CT_TabStop>())
3889 }))
3890 .saturating_add(properties.shading.as_ref().map_or(0, shading_bytes))
3891 .saturating_add(properties.rpr.as_ref().map_or(0, run_properties_bytes))
3892 }
3893 fn paragraph_key_bytes(paragraph: &CT_P) -> usize {
3894 paragraph
3895 .runs
3896 .capacity()
3897 .saturating_mul(std::mem::size_of::<CT_R>())
3898 .saturating_add(
3899 paragraph
3900 .runs
3901 .iter()
3902 .map(|run| {
3903 run.content
3904 .capacity()
3905 .saturating_mul(std::mem::size_of::<RunContent>())
3906 .saturating_add(
3907 run.content
3908 .iter()
3909 .map(|content| match content {
3910 RunContent::Text(text) => text.text.capacity(),
3911 _ => 0,
3912 })
3913 .fold(0usize, usize::saturating_add),
3914 )
3915 .saturating_add(run.properties.as_ref().map_or(0, run_properties_bytes))
3916 })
3917 .fold(0usize, usize::saturating_add),
3918 )
3919 .saturating_add(
3920 paragraph
3921 .properties
3922 .as_ref()
3923 .map_or(0, paragraph_properties_bytes),
3924 )
3925 }
3926 fn text_bytes(text: &TextSegment) -> usize {
3927 text.text.capacity()
3928 + text.glyph_ids.capacity() * std::mem::size_of::<u16>()
3929 + text.advances.capacity() * std::mem::size_of::<f64>()
3930 + text.hyperlink_url.as_ref().map_or(0, String::capacity)
3931 }
3932 fn inline_bytes(item: &InlineItem) -> usize {
3933 match item {
3934 InlineItem::Text(text) | InlineItem::Marker(text) => text_bytes(text),
3935 InlineItem::MultilingualText(_) => usize::MAX,
3936 InlineItem::Group { .. } => usize::MAX,
3937 _ => 0,
3938 }
3939 }
3940 fn line_item_bytes(item: &LineItem) -> usize {
3941 match item {
3942 LineItem::Text(text) | LineItem::Marker(text) => text_bytes(text),
3943 LineItem::MultilingualText(_) => usize::MAX,
3944 LineItem::Tab { leader, .. } => leader.as_ref().map_or(0, text_bytes),
3945 LineItem::Group { .. } => usize::MAX,
3946 _ => 0,
3947 }
3948 }
3949
3950 let paragraph_bytes = paragraph_key_bytes(paragraph);
3951 let line_bytes = block
3952 .lines
3953 .capacity()
3954 .saturating_mul(std::mem::size_of::<oxml_layout::LayoutLine>())
3955 .saturating_add(
3956 block
3957 .lines
3958 .iter()
3959 .map(|line| {
3960 line.items
3961 .capacity()
3962 .saturating_mul(std::mem::size_of::<LineItem>())
3963 .saturating_add(
3964 line.items
3965 .iter()
3966 .map(line_item_bytes)
3967 .fold(0usize, usize::saturating_add),
3968 )
3969 })
3970 .fold(0usize, usize::saturating_add),
3971 );
3972 let reflow_bytes = block.reflow.as_ref().map_or(0, |reflow| {
3973 std::mem::size_of_val(reflow.as_ref())
3974 .saturating_add(
3975 reflow
3976 .items
3977 .capacity()
3978 .saturating_mul(std::mem::size_of::<InlineItem>()),
3979 )
3980 .saturating_add(
3981 reflow
3982 .items
3983 .iter()
3984 .map(inline_bytes)
3985 .fold(0usize, usize::saturating_add),
3986 )
3987 .saturating_add(
3988 reflow
3989 .params
3990 .tab_stops
3991 .capacity()
3992 .saturating_mul(std::mem::size_of::<oxml_layout::TabStop>()),
3993 )
3994 .saturating_add(
3995 reflow
3996 .params
3997 .line_prefix_widths
3998 .capacity()
3999 .saturating_mul(std::mem::size_of::<f64>()),
4000 )
4001 .saturating_add(
4002 reflow
4003 .params
4004 .line_suffix_widths
4005 .capacity()
4006 .saturating_mul(std::mem::size_of::<f64>()),
4007 )
4008 });
4009 let diagnostic_bytes = diagnostics
4010 .len()
4011 .saturating_mul(std::mem::size_of::<Diagnostic>())
4012 .saturating_add(
4013 diagnostics
4014 .iter()
4015 .map(|diagnostic| diagnostic.message.capacity())
4016 .fold(0usize, usize::saturating_add),
4017 );
4018 std::mem::size_of::<ParagraphCacheEntry>()
4019 .saturating_add(arc_allocation_bytes::<ParagraphBlock>())
4020 .saturating_add(paragraph_bytes)
4021 .saturating_add(line_bytes)
4022 .saturating_add(reflow_bytes)
4023 .saturating_add(if block.anchored.is_empty() {
4024 0
4025 } else {
4026 usize::MAX
4027 })
4028 .saturating_add(block.heading_text.as_ref().map_or(0, String::capacity))
4029 .saturating_add(block.borders.as_ref().map_or(0, border_bytes))
4030 .saturating_add(font_trace_len * std::mem::size_of::<FontId>())
4031 .saturating_add(diagnostic_bytes)
4032}
4033
4034struct StableFingerprint(u64);
4035
4036impl StableFingerprint {
4037 fn new() -> Self {
4038 Self(0xcbf2_9ce4_8422_2325)
4039 }
4040
4041 fn write_bytes(&mut self, value: &[u8]) {
4042 for byte in value {
4043 self.0 ^= u64::from(*byte);
4044 self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
4045 }
4046 }
4047
4048 fn write_usize(&mut self, value: usize) {
4049 self.write_bytes(&value.to_le_bytes());
4050 }
4051
4052 fn write_tag(&mut self, value: u8) {
4053 self.write_bytes(&[value]);
4054 }
4055}
4056
4057fn paragraph_fingerprint(paragraph: &CT_P) -> u64 {
4058 let mut fingerprint = StableFingerprint::new();
4059 fingerprint.write_usize(paragraph.runs.len());
4060 fingerprint.write_tag(u8::from(paragraph.properties.is_some()));
4061 for run in ¶graph.runs {
4062 fingerprint.write_tag(u8::from(run.properties.is_some()));
4063 fingerprint.write_usize(run.content.len());
4064 for content in &run.content {
4065 match content {
4066 RunContent::Text(text) => {
4067 fingerprint.write_tag(0);
4068 fingerprint.write_bytes(text.text.as_bytes());
4069 fingerprint.write_tag(u8::from(text.preserve_space));
4070 }
4071 RunContent::DeletedText(text) => {
4072 fingerprint.write_tag(1);
4073 fingerprint.write_bytes(text.text.as_bytes());
4074 fingerprint.write_tag(u8::from(text.preserve_space));
4075 }
4076 RunContent::Tab => fingerprint.write_tag(2),
4077 RunContent::Break(kind) => {
4078 fingerprint.write_tag(3);
4079 fingerprint.write_tag(match kind {
4080 BreakType::Line => 0,
4081 BreakType::Page => 1,
4082 BreakType::Column => 2,
4083 });
4084 }
4085 RunContent::Drawing(_)
4086 | RunContent::Field(_)
4087 | RunContent::FootnoteRef { .. }
4088 | RunContent::EndnoteRef { .. }
4089 | RunContent::CommentReference { .. } => fingerprint.write_tag(4),
4090 }
4091 }
4092 }
4093 fingerprint.0
4094}
4095
4096fn table_fingerprint(table: &CT_Tbl) -> u64 {
4097 fn write_table(table: &CT_Tbl, fingerprint: &mut StableFingerprint) {
4098 fingerprint.write_tag(u8::from(table.properties.is_some()));
4099 fingerprint.write_usize(table.grid.as_ref().map_or(0, |grid| grid.columns.len()));
4100 fingerprint.write_usize(table.rows.len());
4101 for row in &table.rows {
4102 fingerprint.write_tag(u8::from(row.properties.is_some()));
4103 fingerprint.write_usize(row.cells.len());
4104 for cell in &row.cells {
4105 fingerprint.write_tag(u8::from(cell.properties.is_some()));
4106 fingerprint.write_usize(cell.content.len());
4107 for content in &cell.content {
4108 match content {
4109 CellContent::Paragraph(paragraph) => {
4110 fingerprint.write_tag(0);
4111 fingerprint
4112 .write_bytes(¶graph_fingerprint(paragraph).to_le_bytes());
4113 }
4114 CellContent::Table(table) => {
4115 fingerprint.write_tag(1);
4116 write_table(table, fingerprint);
4117 }
4118 CellContent::ContentControl(_) => fingerprint.write_tag(2),
4119 }
4120 }
4121 }
4122 }
4123 }
4124
4125 let mut fingerprint = StableFingerprint::new();
4126 write_table(table, &mut fingerprint);
4127 fingerprint.0
4128}
4129
4130fn apply_page_background(pages: &mut [PageFrame], input: &LayoutInput) {
4132 let bg_xml = match &input.document.background_xml {
4133 Some(xml) => xml,
4134 None => return,
4135 };
4136
4137 let xml_str = std::str::from_utf8(bg_xml).unwrap_or("");
4139 let color = extract_background_color(xml_str);
4140 let color = match color {
4141 Some(c) => c,
4142 None => return,
4143 };
4144
4145 for page in pages.iter_mut() {
4147 page.elements.insert(
4148 0,
4149 PositionedElement::FilledRect {
4150 rect: Rect {
4151 x: 0.0,
4152 y: 0.0,
4153 width: page.width,
4154 height: page.height,
4155 },
4156 color,
4157 },
4158 );
4159 }
4160}
4161
4162fn extract_background_color(xml: &str) -> Option<Color> {
4164 for attr in ["w:color=\"", "color=\""] {
4166 if let Some(start) = xml.find(attr) {
4167 let val_start = start + attr.len();
4168 if let Some(end) = xml[val_start..].find('"') {
4169 let hex = &xml[val_start..val_start + end];
4170 if hex.len() == 6 && hex != "auto" {
4171 return Some(Color::from_hex(hex));
4172 }
4173 }
4174 }
4175 }
4176 None
4177}
4178
4179fn substitute_fields(
4181 elements: &mut Vec<PositionedElement>,
4182 page_number: usize,
4183 total_pages: usize,
4184 bookmark_pages: &HashMap<usize, usize>,
4185 fm: &mut FontManager,
4186) {
4187 for element in elements.iter_mut() {
4188 match element {
4189 PositionedElement::Text(run) => {
4190 let Some(fk) = run.field_kind else {
4191 continue;
4192 };
4193 let value = match fk {
4194 FieldKind::Page => page_number.to_string(),
4195 FieldKind::NumPages => total_pages.to_string(),
4196 FieldKind::TargetPage(target) => bookmark_pages
4197 .get(&target)
4198 .map(usize::to_string)
4199 .unwrap_or_else(|| run.text.clone()),
4200 FieldKind::Target(_) => continue,
4201 };
4202 if let Ok(shaped) = fm.shape_text(run.font_id, &value, run.font_size) {
4203 run.text = value;
4204 run.glyph_ids = shaped.glyph_ids;
4205 run.advances = shaped.advances;
4206 }
4207 }
4208 PositionedElement::Group(group) => substitute_fields(
4209 &mut group.children,
4210 page_number,
4211 total_pages,
4212 bookmark_pages,
4213 fm,
4214 ),
4215 PositionedElement::MarkedContent { children, .. } => {
4216 substitute_fields(children, page_number, total_pages, bookmark_pages, fm)
4217 }
4218 _ => {}
4219 }
4220 }
4221 elements.retain(|element| match element {
4222 PositionedElement::Text(run) => !matches!(run.field_kind, Some(FieldKind::Target(_))),
4223 PositionedElement::MarkedContent { children, .. } => !children.is_empty(),
4224 _ => true,
4225 });
4226}
4227
4228fn mark_remaining_artifacts(elements: &mut Vec<PositionedElement>) {
4229 let unmarked = std::mem::take(elements);
4230 *elements = unmarked
4231 .into_iter()
4232 .map(|element| match element {
4233 PositionedElement::MarkedContent { .. } | PositionedElement::LinkAnnotation { .. } => {
4234 element
4235 }
4236 _ => PositionedElement::MarkedContent {
4237 structure: None,
4238 children: vec![element],
4239 },
4240 })
4241 .collect();
4242}
4243
4244struct StructureBuilder {
4245 nodes: Vec<StructureNode>,
4246}
4247
4248impl StructureBuilder {
4249 fn add(&mut self, role: StructureRole, parent: Option<StructureId>) -> StructureId {
4250 let id = StructureId::new(self.nodes.len() as u32 + 1)
4251 .expect("a structure node index is always non-zero");
4252 self.nodes.push(StructureNode {
4253 id,
4254 role,
4255 children: Vec::new(),
4256 alternate_text: None,
4257 });
4258 if let Some(parent) = parent
4259 && let Some(node) = self.nodes.get_mut(parent.get() as usize - 1)
4260 {
4261 node.children.push(id);
4262 }
4263 id
4264 }
4265
4266 fn set_alternate_text(&mut self, id: StructureId, text: String) {
4267 if let Some(node) = self.nodes.get_mut(id.get() as usize - 1) {
4268 node.alternate_text = Some(text);
4269 }
4270 }
4271}
4272
4273#[derive(Clone, Copy)]
4274struct ListFrame {
4275 num_id: u32,
4276 level: u8,
4277 list: StructureId,
4278 last_item: Option<StructureId>,
4279}
4280
4281fn assign_shared_document_structure(
4282 sections: &mut [paginator::SharedSection],
4283) -> DocumentStructure {
4284 let mut builder = StructureBuilder { nodes: Vec::new() };
4285 let root = builder.add(StructureRole::Document, None);
4286
4287 for section in sections {
4288 let mut lists: Vec<ListFrame> = Vec::new();
4289 for block in &mut section.blocks {
4290 match block {
4291 SharedLayoutBlock::Paragraph { block, semantics } => {
4292 lists.clear();
4293 let paragraph_id = builder.add(StructureRole::Paragraph, Some(root));
4294 semantics.structure_id = Some(paragraph_id);
4295 debug_assert!(block.list.is_none());
4296 debug_assert!(block.anchored.is_empty());
4297 }
4298 SharedLayoutBlock::Table { block, semantics } => {
4299 lists.clear();
4300 assign_shared_table_structure(&mut builder, block, semantics, root);
4301 }
4302 SharedLayoutBlock::Owned { block, .. } => match block.as_mut() {
4303 LayoutBlock::Paragraph(paragraph) => {
4304 assign_owned_paragraph_structure(&mut builder, paragraph, root, &mut lists);
4305 }
4306 LayoutBlock::Table(table) => {
4307 lists.clear();
4308 assign_owned_table_structure(&mut builder, table, root);
4309 }
4310 },
4311 }
4312 }
4313 }
4314
4315 DocumentStructure {
4316 root,
4317 nodes: builder.nodes,
4318 }
4319}
4320
4321fn assign_owned_paragraph_structure(
4322 builder: &mut StructureBuilder,
4323 paragraph: &mut ParagraphBlock,
4324 root: StructureId,
4325 lists: &mut Vec<ListFrame>,
4326) {
4327 if let Some((num_id, level)) = paragraph.list {
4328 if lists.last().is_some_and(|frame| frame.num_id != num_id) {
4329 lists.clear();
4330 }
4331 while lists.last().is_some_and(|frame| frame.level > level) {
4332 lists.pop();
4333 }
4334 if lists.is_empty() || lists.last().is_some_and(|frame| frame.level < level) {
4335 let parent = lists
4336 .last()
4337 .and_then(|frame| frame.last_item)
4338 .unwrap_or(root);
4339 let list = builder.add(StructureRole::List, Some(parent));
4340 lists.push(ListFrame {
4341 num_id,
4342 level,
4343 list,
4344 last_item: None,
4345 });
4346 }
4347 let frame = lists
4348 .last_mut()
4349 .expect("the requested list level has been allocated");
4350 let item = builder.add(StructureRole::ListItem, Some(frame.list));
4351 frame.last_item = Some(item);
4352 let paragraph_id = builder.add(StructureRole::Paragraph, Some(item));
4353 paragraph.structure_id = Some(paragraph_id);
4354 assign_paragraph_figures(builder, paragraph, paragraph_id, item);
4355 } else {
4356 lists.clear();
4357 let role = paragraph
4358 .heading_level
4359 .map(|level| StructureRole::Heading(level.min(6) as u8))
4360 .unwrap_or(StructureRole::Paragraph);
4361 let paragraph_id = builder.add(role, Some(root));
4362 paragraph.structure_id = Some(paragraph_id);
4363 assign_paragraph_figures(builder, paragraph, paragraph_id, root);
4364 }
4365}
4366
4367fn assign_owned_table_structure(
4368 builder: &mut StructureBuilder,
4369 table: &mut table::TableBlock,
4370 parent: StructureId,
4371) {
4372 let table_id = builder.add(StructureRole::Table, Some(parent));
4373 table.structure_id = Some(table_id);
4374 for row in &mut table.rows {
4375 let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4376 row.structure_id = Some(row_id);
4377 for cell in &mut row.cells {
4378 let role = if row.is_header {
4379 StructureRole::TableHeaderCell
4380 } else {
4381 StructureRole::TableCell
4382 };
4383 let cell_id = builder.add(role, Some(row_id));
4384 cell.structure_id = Some(cell_id);
4385 for block in &mut cell.blocks {
4386 assign_cell_block_structure(builder, block, cell_id);
4387 }
4388 }
4389 }
4390}
4391
4392fn assign_shared_table_structure(
4393 builder: &mut StructureBuilder,
4394 table: &table::TableBlock,
4395 semantics: &mut TableSemantics,
4396 parent: StructureId,
4397) {
4398 let table_id = builder.add(StructureRole::Table, Some(parent));
4399 for (row, row_semantics) in table.rows.iter().zip(&mut semantics.rows) {
4400 let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4401 for (cell, cell_semantics) in row.cells.iter().zip(&mut row_semantics.cells) {
4402 let role = if row.is_header {
4403 StructureRole::TableHeaderCell
4404 } else {
4405 StructureRole::TableCell
4406 };
4407 let cell_id = builder.add(role, Some(row_id));
4408 for (block, block_semantics) in cell.blocks.iter().zip(&mut cell_semantics.blocks) {
4409 match (block, block_semantics) {
4410 (
4411 table::CellBlock::Paragraph(paragraph),
4412 CellBlockSemantics::Paragraph(semantics),
4413 ) => {
4414 let role = paragraph
4415 .heading_level
4416 .map(|level| StructureRole::Heading(level.min(6) as u8))
4417 .unwrap_or(StructureRole::Paragraph);
4418 semantics.structure_id = Some(builder.add(role, Some(cell_id)));
4419 }
4420 (table::CellBlock::Table(table), CellBlockSemantics::Table(semantics)) => {
4421 assign_shared_table_structure(builder, table, semantics, cell_id)
4422 }
4423 _ => unreachable!("shared table semantics stay aligned"),
4424 }
4425 }
4426 }
4427 }
4428}
4429
4430#[cfg(test)]
4431fn assign_document_structure(sections: &mut [paginator::Section]) -> DocumentStructure {
4432 let mut builder = StructureBuilder { nodes: Vec::new() };
4433 let root = builder.add(StructureRole::Document, None);
4434
4435 for section in sections {
4436 let mut lists: Vec<ListFrame> = Vec::new();
4437 for block in &mut section.blocks {
4438 match block {
4439 LayoutBlock::Paragraph(paragraph) => {
4440 if let Some((num_id, level)) = paragraph.list {
4441 if lists.last().is_some_and(|frame| frame.num_id != num_id) {
4442 lists.clear();
4443 }
4444 while lists.last().is_some_and(|frame| frame.level > level) {
4445 lists.pop();
4446 }
4447 if lists.is_empty() || lists.last().is_some_and(|frame| frame.level < level)
4448 {
4449 let parent = lists
4450 .last()
4451 .and_then(|frame| frame.last_item)
4452 .unwrap_or(root);
4453 let list = builder.add(StructureRole::List, Some(parent));
4454 lists.push(ListFrame {
4455 num_id,
4456 level,
4457 list,
4458 last_item: None,
4459 });
4460 }
4461 let frame = lists
4462 .last_mut()
4463 .expect("the requested list level has been allocated");
4464 let item = builder.add(StructureRole::ListItem, Some(frame.list));
4465 frame.last_item = Some(item);
4466 let paragraph_id = builder.add(StructureRole::Paragraph, Some(item));
4467 paragraph.structure_id = Some(paragraph_id);
4468 assign_paragraph_figures(&mut builder, paragraph, paragraph_id, item);
4469 } else {
4470 lists.clear();
4471 let role = paragraph
4472 .heading_level
4473 .map(|level| StructureRole::Heading(level.min(6) as u8))
4474 .unwrap_or(StructureRole::Paragraph);
4475 let paragraph_id = builder.add(role, Some(root));
4476 paragraph.structure_id = Some(paragraph_id);
4477 assign_paragraph_figures(&mut builder, paragraph, paragraph_id, root);
4478 }
4479 }
4480 LayoutBlock::Table(table) => {
4481 lists.clear();
4482 let table_id = builder.add(StructureRole::Table, Some(root));
4483 table.structure_id = Some(table_id);
4484 for row in &mut table.rows {
4485 let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4486 row.structure_id = Some(row_id);
4487 for cell in &mut row.cells {
4488 let role = if row.is_header {
4489 StructureRole::TableHeaderCell
4490 } else {
4491 StructureRole::TableCell
4492 };
4493 let cell_id = builder.add(role, Some(row_id));
4494 cell.structure_id = Some(cell_id);
4495 for block in &mut cell.blocks {
4496 assign_cell_block_structure(&mut builder, block, cell_id);
4497 }
4498 }
4499 }
4500 }
4501 }
4502 }
4503 }
4504
4505 DocumentStructure {
4506 root,
4507 nodes: builder.nodes,
4508 }
4509}
4510
4511fn assign_cell_block_structure(
4512 builder: &mut StructureBuilder,
4513 block: &mut table::CellBlock,
4514 parent: StructureId,
4515) {
4516 match block {
4517 table::CellBlock::Paragraph(paragraph) => {
4518 let role = paragraph
4519 .heading_level
4520 .map(|level| StructureRole::Heading(level.min(6) as u8))
4521 .unwrap_or(StructureRole::Paragraph);
4522 let paragraph_id = builder.add(role, Some(parent));
4523 paragraph.structure_id = Some(paragraph_id);
4524 assign_paragraph_figures(builder, paragraph, paragraph_id, parent);
4525 }
4526 table::CellBlock::Table(table) => {
4527 let table_id = builder.add(StructureRole::Table, Some(parent));
4528 table.structure_id = Some(table_id);
4529 for row in &mut table.rows {
4530 let row_id = builder.add(StructureRole::TableRow, Some(table_id));
4531 row.structure_id = Some(row_id);
4532 for cell in &mut row.cells {
4533 let role = if row.is_header {
4534 StructureRole::TableHeaderCell
4535 } else {
4536 StructureRole::TableCell
4537 };
4538 let cell_id = builder.add(role, Some(row_id));
4539 cell.structure_id = Some(cell_id);
4540 for child in &mut cell.blocks {
4541 assign_cell_block_structure(builder, child, cell_id);
4542 }
4543 }
4544 }
4545 }
4546 }
4547}
4548
4549fn assign_paragraph_figures(
4550 builder: &mut StructureBuilder,
4551 paragraph: &mut ParagraphBlock,
4552 inline_parent: StructureId,
4553 anchored_parent: StructureId,
4554) {
4555 for line in &mut paragraph.lines {
4556 for item in &mut line.items {
4557 let (alternate_text, structure_id) = match item {
4558 LineItem::Figure {
4559 alternate_text,
4560 structure_id,
4561 ..
4562 } => (alternate_text, structure_id),
4563 _ => continue,
4564 };
4565 let figure = builder.add(StructureRole::Figure, Some(inline_parent));
4566 builder.set_alternate_text(figure, alternate_text.clone());
4567 *structure_id = Some(figure);
4568 }
4569 }
4570 for drawing in &mut paragraph.anchored {
4571 if let Some(text) = drawing
4572 .alternate_text
4573 .as_deref()
4574 .map(str::trim)
4575 .filter(|text| !text.is_empty())
4576 {
4577 let figure = builder.add(StructureRole::Figure, Some(anchored_parent));
4578 builder.set_alternate_text(figure, text.to_owned());
4579 drawing.structure_id = Some(figure);
4580 }
4581 }
4582}
4583
4584fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
4586 let style_id = para.properties.as_ref()?.style_id.as_deref()?;
4587 if let Some(rest) = style_id.strip_prefix("Heading") {
4589 return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
4590 }
4591 if let Some(style_def) = styles.get_by_id(style_id)
4593 && let Some(ref name) = style_def.name
4594 && let Some(rest) = name.strip_prefix("heading ")
4595 {
4596 return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
4597 }
4598 None
4599}
4600
4601pub fn layout_paragraph(
4603 para: &CT_P,
4604 available_width: f64,
4605 styles: &CT_Styles,
4606 input: &LayoutInput,
4607 media: &MediaRegistry,
4608 fm: &mut FontManager,
4609 num_state: &mut NumberingState,
4610 diagnostics: &mut Vec<Diagnostic>,
4611) -> Result<ParagraphBlock> {
4612 layout_paragraph_with_source(
4613 para,
4614 available_width,
4615 styles,
4616 input,
4617 media,
4618 fm,
4619 num_state,
4620 diagnostics,
4621 None,
4622 )
4623}
4624
4625pub(crate) fn layout_paragraph_with_source(
4626 para: &CT_P,
4627 available_width: f64,
4628 styles: &CT_Styles,
4629 input: &LayoutInput,
4630 media: &MediaRegistry,
4631 fm: &mut FontManager,
4632 num_state: &mut NumberingState,
4633 diagnostics: &mut Vec<Diagnostic>,
4634 source_node: Option<SourceNodeId>,
4635) -> Result<ParagraphBlock> {
4636 layout_paragraph_with_source_and_table(
4637 para,
4638 available_width,
4639 styles,
4640 input,
4641 media,
4642 fm,
4643 num_state,
4644 diagnostics,
4645 source_node,
4646 None,
4647 None,
4648 )
4649}
4650
4651#[allow(clippy::too_many_arguments)]
4652pub(crate) fn layout_paragraph_with_source_and_direction(
4653 para: &CT_P,
4654 available_width: f64,
4655 styles: &CT_Styles,
4656 input: &LayoutInput,
4657 media: &MediaRegistry,
4658 fm: &mut FontManager,
4659 num_state: &mut NumberingState,
4660 diagnostics: &mut Vec<Diagnostic>,
4661 source_node: Option<SourceNodeId>,
4662) -> Result<(ParagraphBlock, TextDirection)> {
4663 let mut direction = TextDirection::Auto;
4664 let block = layout_paragraph_with_source_and_table(
4665 para,
4666 available_width,
4667 styles,
4668 input,
4669 media,
4670 fm,
4671 num_state,
4672 diagnostics,
4673 source_node,
4674 None,
4675 Some(&mut direction),
4676 )?;
4677 Ok((block, direction))
4678}
4679
4680#[allow(clippy::too_many_arguments)]
4681pub(crate) fn layout_paragraph_with_source_in_table(
4682 para: &CT_P,
4683 available_width: f64,
4684 styles: &CT_Styles,
4685 input: &LayoutInput,
4686 media: &MediaRegistry,
4687 fm: &mut FontManager,
4688 num_state: &mut NumberingState,
4689 diagnostics: &mut Vec<Diagnostic>,
4690 source_node: Option<SourceNodeId>,
4691 table_properties: Option<&rdocx_oxml::properties::CT_PPr>,
4692) -> Result<(ParagraphBlock, TextDirection)> {
4693 let mut direction = TextDirection::Auto;
4694 let block = layout_paragraph_with_source_and_table(
4695 para,
4696 available_width,
4697 styles,
4698 input,
4699 media,
4700 fm,
4701 num_state,
4702 diagnostics,
4703 source_node,
4704 table_properties,
4705 Some(&mut direction),
4706 )?;
4707 Ok((block, direction))
4708}
4709
4710#[allow(clippy::too_many_arguments)]
4711fn layout_paragraph_with_source_and_table(
4712 para: &CT_P,
4713 available_width: f64,
4714 styles: &CT_Styles,
4715 input: &LayoutInput,
4716 media: &MediaRegistry,
4717 fm: &mut FontManager,
4718 num_state: &mut NumberingState,
4719 diagnostics: &mut Vec<Diagnostic>,
4720 source_node: Option<SourceNodeId>,
4721 table_properties: Option<&rdocx_oxml::properties::CT_PPr>,
4722 reflow_direction_out: Option<&mut TextDirection>,
4723) -> Result<ParagraphBlock> {
4724 let para_style_id = para.properties.as_ref().and_then(|p| p.style_id.as_deref());
4726
4727 let resolved_ppr = style_resolver::resolve_paragraph_properties_in_table(
4728 para_style_id,
4729 styles,
4730 table_properties,
4731 );
4732
4733 let mut effective_ppr = resolved_ppr;
4734
4735 let direct_ppr = para.properties.as_ref();
4740 let list_num_id = direct_ppr.and_then(|p| p.num_id).or(effective_ppr.num_id);
4741 let list_ilvl = direct_ppr
4742 .and_then(|p| p.num_ilvl)
4743 .or(effective_ppr.num_ilvl)
4744 .unwrap_or(0);
4745 if let (Some(num_id), Some(numbering)) = (list_num_id, input.numbering.as_ref())
4746 && let Some(lvl_ppr) =
4747 style_resolver::level_paragraph_properties(num_id, list_ilvl, numbering)
4748 {
4749 merge_direct_ppr(&mut effective_ppr, lvl_ppr);
4750 }
4751
4752 if let Some(direct_ppr) = direct_ppr {
4754 merge_direct_ppr(&mut effective_ppr, direct_ppr);
4755 }
4756
4757 let space_before = effective_ppr.space_before.map(|t| t.to_pt()).unwrap_or(0.0);
4759 let space_after = effective_ppr.space_after.map(|t| t.to_pt()).unwrap_or(0.0);
4760 let base_direction = match effective_ppr.bidi {
4761 Some(true) => TextDirection::RightToLeft,
4762 Some(false) => TextDirection::LeftToRight,
4763 None => TextDirection::Auto,
4764 };
4765 let keep_next = effective_ppr.keep_next.unwrap_or(false);
4766 let keep_lines = effective_ppr.keep_lines.unwrap_or(false);
4767 let page_break_before = effective_ppr.page_break_before.unwrap_or(false);
4768 let widow_control = effective_ppr.widow_control.unwrap_or(true);
4769 let automatic_hyphenation =
4770 input.automatic_hyphenation && effective_ppr.suppress_auto_hyphens != Some(true);
4771
4772 let shading = effective_ppr
4774 .shading
4775 .as_ref()
4776 .and_then(|shd| shd.fill.as_ref())
4777 .filter(|f| f != &"auto")
4778 .map(|f| Color::from_hex(f));
4779
4780 let mut inline_items = Vec::new();
4782 let mut multilingual_styles = HashMap::<usize, WordMultilingualStyle>::new();
4783
4784 if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) {
4786 let ilvl = effective_ppr.num_ilvl.unwrap_or(0);
4787 if let Some(marker) = style_resolver::generate_marker(num_id, ilvl, numbering, num_state) {
4788 let marker_rpr = marker.marker_rpr;
4790 let marker_font_size = marker_rpr.sz.map(|hp| hp.to_pt()).unwrap_or_else(|| {
4791 style_resolver::resolve_run_properties(para_style_id, None, styles)
4792 .sz
4793 .map(|hp| hp.to_pt())
4794 .unwrap_or(11.0)
4795 });
4796 let marker_bold = marker_rpr.bold.unwrap_or(false);
4797 let marker_italic = marker_rpr.italic.unwrap_or(false);
4798 let marker_font_family = marker_rpr.font_ascii.as_deref();
4799
4800 if let Ok(font_id) = fm.resolve_font_for_text(
4803 marker_font_family,
4804 marker_bold,
4805 marker_italic,
4806 &marker.marker_text,
4807 ) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size)
4808 {
4809 let metrics = fm.metrics(font_id, marker_font_size)?;
4810 let color = marker_rpr
4811 .color
4812 .as_ref()
4813 .map(|c| Color::from_hex(c))
4814 .unwrap_or(Color::BLACK);
4815
4816 inline_items.push(InlineItem::Marker(TextSegment {
4817 text: marker.marker_text,
4818 direction: TextDirection::Auto,
4819 source: None,
4820 font_id,
4821 font_size: marker_font_size,
4822 glyph_ids: shaped.glyph_ids,
4823 advances: shaped.advances,
4824 width: shaped.width,
4825 ascent: metrics.ascent,
4826 descent: metrics.descent,
4827 line_gap: 0.0,
4828 color,
4829 bold: marker_bold,
4830 italic: marker_italic,
4831 underline: None,
4832 strike: false,
4833 dstrike: false,
4834 highlight: None,
4835 baseline_offset: 0.0,
4836 hyperlink_url: None,
4837 field_kind: None,
4838 note: None,
4839 }));
4840
4841 match marker.suffix {
4842 ST_LvlSuffix::Tab => inline_items.push(InlineItem::Tab),
4843 ST_LvlSuffix::Space => {
4844 let shaped = fm.shape_text(font_id, " ", marker_font_size)?;
4845 inline_items.push(InlineItem::Text(TextSegment {
4846 text: " ".to_owned(),
4847 direction: TextDirection::Auto,
4848 source: None,
4849 font_id,
4850 font_size: marker_font_size,
4851 glyph_ids: shaped.glyph_ids,
4852 advances: shaped.advances,
4853 width: shaped.width,
4854 ascent: metrics.ascent,
4855 descent: metrics.descent,
4856 line_gap: 0.0,
4857 color,
4858 bold: marker_bold,
4859 italic: marker_italic,
4860 underline: None,
4861 strike: false,
4862 dstrike: false,
4863 highlight: None,
4864 baseline_offset: 0.0,
4865 hyperlink_url: None,
4866 field_kind: None,
4867 note: None,
4868 }));
4869 }
4870 ST_LvlSuffix::Nothing => {}
4871 }
4872 }
4873 }
4874 }
4875
4876 let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
4878 std::collections::HashMap::new();
4879 for hl in ¶.hyperlinks {
4880 if let Some(ref rel_id) = hl.rel_id
4881 && let Some(url) = input.hyperlink_urls.get(rel_id)
4882 {
4883 for run_idx in hl.run_start..hl.run_end {
4884 run_hyperlink_url.insert(run_idx, url.clone());
4885 }
4886 }
4887 }
4888
4889 let mut marker_boundary = None;
4891 let mut marker_raw_before = None;
4892 let mut projection_char_offset = 0usize;
4893 for projected in project_paragraph_runs(para, input.revision_view) {
4894 let run = projected.run;
4895 let projected_run_start = projection_char_offset;
4896 projection_char_offset += run.text().chars().count();
4897 if marker_boundary != Some(projected.boundary) {
4898 marker_boundary = Some(projected.boundary);
4899 marker_raw_before = None;
4900 }
4901 push_targeted_bookmark_markers(
4902 &mut inline_items,
4903 para,
4904 projected.boundary,
4905 marker_raw_before,
4906 projected.raw_order,
4907 input,
4908 fm,
4909 )?;
4910 marker_raw_before = Some(projected.raw_order);
4911 let current_hyperlink_url = projected
4912 .ordinary_run_index
4913 .and_then(|run_index| run_hyperlink_url.get(&run_index).cloned())
4914 .or_else(|| {
4915 projected
4916 .hyperlink_index
4917 .and_then(|index| para.hyperlinks.get(index))
4918 .and_then(|hyperlink| hyperlink.rel_id.as_deref())
4919 .and_then(|rel_id| input.hyperlink_urls.get(rel_id).cloned())
4920 });
4921
4922 let run_style_id = run.properties.as_ref().and_then(|p| p.style_id.as_deref());
4923
4924 let resolved_rpr =
4925 style_resolver::resolve_run_properties(para_style_id, run_style_id, styles);
4926
4927 let mut effective_rpr = resolved_rpr;
4929 if let Some(ref direct_rpr) = run.properties {
4930 effective_rpr.merge_from(direct_rpr);
4931 }
4932
4933 if effective_rpr.vanish == Some(true) {
4935 continue;
4936 }
4937
4938 let mut font_size = effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
4939 let bold = effective_rpr.bold.unwrap_or(false);
4940 let italic = effective_rpr.italic.unwrap_or(false);
4941
4942 let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
4944
4945 let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
4947
4948 let underline = if projected.force_underline {
4950 Some(Underline::Single)
4951 } else {
4952 convert::underline(effective_rpr.underline)
4953 };
4954 let strike = projected.force_strike || effective_rpr.strike.unwrap_or(false);
4955 let dstrike = effective_rpr.dstrike.unwrap_or(false);
4956 let highlight = effective_rpr.highlight.and_then(highlight_to_color);
4957
4958 let mut baseline_offset = 0.0;
4960 if let Some(ref va) = effective_rpr.vert_align {
4961 match va.as_str() {
4962 "superscript" => {
4963 let original_size = font_size;
4965 font_size *= 0.58;
4966 baseline_offset = original_size * 0.33; }
4968 "subscript" => {
4969 let original_size = font_size;
4971 font_size *= 0.58;
4972 baseline_offset = -(original_size * 0.14); }
4974 _ => {}
4975 }
4976 }
4977
4978 if let Some(pos) = effective_rpr.position {
4980 baseline_offset += pos as f64 / 2.0; }
4982
4983 let font_id =
4986 fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?;
4987 let metrics = fm.metrics(font_id, font_size)?;
4988
4989 let content_char_starts = projected_content_char_starts(run);
4990 for (content_index, content) in run.content.iter().enumerate() {
4991 let content_char_start = projected_run_start + content_char_starts[content_index];
4992 match content {
4993 RunContent::Text(ct_text) | RunContent::DeletedText(ct_text) => {
4994 let text = if effective_rpr.caps == Some(true) {
4995 ct_text.text.to_uppercase()
4996 } else {
4997 ct_text.text.clone()
4998 };
4999
5000 if text.is_empty() {
5001 continue;
5002 }
5003
5004 let mut shaped = fm.shape_text(font_id, &text, font_size)?;
5005 let source = if text == ct_text.text {
5006 source_node.and_then(|node| {
5007 let char_start = u32::try_from(content_char_start).ok()?;
5008 let char_end =
5009 u32::try_from(content_char_start + ct_text.text.chars().count())
5010 .ok()?;
5011 Some(SourceSpan {
5012 node,
5013 char_start,
5014 char_end,
5015 })
5016 })
5017 } else {
5018 None
5019 };
5020
5021 if let Some(spacing) = effective_rpr.spacing {
5023 let extra = spacing.to_pt();
5024 for advance in &mut shaped.advances {
5025 *advance += extra;
5026 }
5027 shaped.width += extra * shaped.advances.len() as f64;
5028 }
5029
5030 let segment = TextSegment {
5031 text,
5032 direction: TextDirection::Auto,
5033 source,
5034 font_id,
5035 font_size,
5036 glyph_ids: shaped.glyph_ids,
5037 advances: shaped.advances,
5038 width: shaped.width,
5039 ascent: metrics.ascent,
5040 descent: metrics.descent,
5041 line_gap: 0.0,
5042 color,
5043 bold,
5044 italic,
5045 underline,
5046 strike,
5047 dstrike,
5048 highlight,
5049 baseline_offset,
5050 hyperlink_url: current_hyperlink_url.clone(),
5051 field_kind: None,
5052 note: None,
5053 };
5054 let item_index = inline_items.len();
5055 multilingual_styles.insert(
5056 item_index,
5057 WordMultilingualStyle {
5058 language: effective_rpr.language.clone(),
5059 language_east_asia: effective_rpr.language_east_asia.clone(),
5060 language_bidi: effective_rpr.language_bidi.clone(),
5061 direction: word_text_direction(effective_rpr.rtl),
5062 spacing: effective_rpr.spacing.map_or(0.0, |value| value.to_pt()),
5063 },
5064 );
5065 if automatic_hyphenation && let Some(language) = effective_rpr.language.as_ref()
5066 {
5067 inline_items.push(InlineItem::HyphenatedText {
5068 segment,
5069 language: language.clone(),
5070 });
5071 } else {
5072 inline_items.push(InlineItem::Text(segment));
5073 }
5074 }
5075 RunContent::Tab => {
5076 inline_items.push(InlineItem::Tab);
5077 }
5078 RunContent::Break(bt) => match bt {
5079 BreakType::Line => inline_items.push(InlineItem::LineBreak),
5080 BreakType::Page => inline_items.push(InlineItem::PageBreak),
5081 BreakType::Column => inline_items.push(InlineItem::ColumnBreak),
5082 },
5083 RunContent::Drawing(drawing) => {
5084 if let Some(ref inline) = drawing.inline {
5085 let width = inline.extent_cx.to_pt();
5086 let height = inline.extent_cy.to_pt();
5087 let item = if let Some(relationship_id) = inline.chart_rel_id.as_deref() {
5088 InlineItem::Group {
5089 width,
5090 height,
5091 group: render_word_chart(
5092 relationship_id,
5093 width,
5094 height,
5095 input,
5096 fm,
5097 diagnostics,
5098 )?,
5099 }
5100 } else {
5101 InlineItem::Image {
5102 width,
5103 height,
5104 media_id: media.id_for_relationship(&inline.embed_id),
5105 }
5106 };
5107 if let Some(alternate_text) = inline
5108 .description
5109 .as_deref()
5110 .map(str::trim)
5111 .filter(|text| !text.is_empty())
5112 {
5113 inline_items.push(InlineItem::Figure {
5114 item: Box::new(item),
5115 alternate_text: alternate_text.to_owned(),
5116 structure_id: None,
5117 });
5118 } else {
5119 inline_items.push(item);
5120 }
5121 }
5122 }
5123 RunContent::Field(field) => {
5124 let (computed_value, field_kind) = match field.instruction.name.as_str() {
5125 "PAGE" => (Some("99".to_owned()), Some(FieldKind::Page)),
5126 "NUMPAGES" => (Some("99".to_owned()), Some(FieldKind::NumPages)),
5127 "REF" => {
5128 let Some(bookmark) = field_text_argument(field, 0) else {
5129 continue;
5130 };
5131 if let Some(text) = bookmark_text(input, bookmark) {
5132 (Some(text), None)
5133 } else {
5134 diagnostics.push(Diagnostic {
5135 message: format!(
5136 "REF target {bookmark} was not found, stored display retained"
5137 ),
5138 });
5139 (None, None)
5140 }
5141 }
5142 "PAGEREF" => {
5143 let Some(bookmark) = field_text_argument(field, 0) else {
5144 continue;
5145 };
5146 if bookmark_text(input, bookmark).is_none() {
5147 diagnostics.push(Diagnostic {
5148 message: format!(
5149 "PAGEREF target {bookmark} was not found, stored display retained"
5150 ),
5151 });
5152 (None, None)
5153 } else if let Some(target) = page_ref_id(input, bookmark) {
5154 (Some("99".to_owned()), Some(FieldKind::TargetPage(target)))
5155 } else {
5156 (None, None)
5157 }
5158 }
5159 _ => (None, None),
5160 };
5161 let stored_segments = field.cached_display_segments();
5162 let segments = if let Some(value) = computed_value.as_deref() {
5163 let stored_properties = stored_segments
5164 .first()
5165 .and_then(|(_, properties)| *properties);
5166 vec![(value, stored_properties)]
5167 } else {
5168 stored_segments
5169 };
5170 for (value, stored_properties) in segments {
5171 let segment_style_id =
5172 stored_properties.and_then(|properties| properties.style_id.as_deref());
5173 let mut segment_rpr = if stored_properties.is_some() {
5174 style_resolver::resolve_run_properties(
5175 para_style_id,
5176 segment_style_id,
5177 styles,
5178 )
5179 } else {
5180 effective_rpr.clone()
5181 };
5182 if let Some(properties) = stored_properties {
5183 segment_rpr.merge_from(properties);
5184 }
5185 if segment_rpr.vanish == Some(true) {
5186 continue;
5187 }
5188 let mut segment_font_size =
5189 segment_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
5190 let segment_bold = segment_rpr.bold.unwrap_or(false);
5191 let segment_italic = segment_rpr.italic.unwrap_or(false);
5192 let segment_font_family =
5193 resolve_font_family(&segment_rpr, input.theme.as_ref());
5194 let segment_color = resolve_run_color(&segment_rpr, input.theme.as_ref());
5195 let segment_underline = if projected.force_underline {
5196 Some(Underline::Single)
5197 } else {
5198 convert::underline(segment_rpr.underline)
5199 };
5200 let segment_strike =
5201 projected.force_strike || segment_rpr.strike.unwrap_or(false);
5202 let segment_dstrike = segment_rpr.dstrike.unwrap_or(false);
5203 let segment_highlight = segment_rpr.highlight.and_then(highlight_to_color);
5204 let mut segment_baseline_offset = 0.0;
5205 if let Some(vertical) = segment_rpr.vert_align.as_deref() {
5206 match vertical {
5207 "superscript" => {
5208 let original_size = segment_font_size;
5209 segment_font_size *= 0.58;
5210 segment_baseline_offset = original_size * 0.33;
5211 }
5212 "subscript" => {
5213 let original_size = segment_font_size;
5214 segment_font_size *= 0.58;
5215 segment_baseline_offset = -(original_size * 0.14);
5216 }
5217 _ => {}
5218 }
5219 }
5220 if let Some(position) = segment_rpr.position {
5221 segment_baseline_offset += position as f64 / 2.0;
5222 }
5223 let segment_font_id = fm.resolve_font_for_text(
5224 segment_font_family.as_deref(),
5225 segment_bold,
5226 segment_italic,
5227 value,
5228 )?;
5229 let segment_metrics = fm.metrics(segment_font_id, segment_font_size)?;
5230
5231 let mut start = 0usize;
5232 for (index, character) in value
5233 .char_indices()
5234 .chain(std::iter::once((value.len(), '\0')))
5235 {
5236 let control = match character {
5237 '\t' => Some(InlineItem::Tab),
5238 '\n' => Some(InlineItem::LineBreak),
5239 '\u{000c}' => Some(InlineItem::PageBreak),
5240 '\u{000b}' => Some(InlineItem::ColumnBreak),
5241 '\0' if index == value.len() => None,
5242 _ => continue,
5243 };
5244 if start < index {
5245 let mut text = value[start..index].to_owned();
5246 if segment_rpr.caps == Some(true) {
5247 text = text.to_uppercase();
5248 }
5249 let mut shaped =
5250 fm.shape_text(segment_font_id, &text, segment_font_size)?;
5251 if let Some(spacing) = segment_rpr.spacing {
5252 let extra = spacing.to_pt();
5253 for advance in &mut shaped.advances {
5254 *advance += extra;
5255 }
5256 shaped.width += extra * shaped.advances.len() as f64;
5257 }
5258 let item_index = inline_items.len();
5259 multilingual_styles.insert(
5260 item_index,
5261 WordMultilingualStyle {
5262 language: segment_rpr.language.clone(),
5263 language_east_asia: segment_rpr.language_east_asia.clone(),
5264 language_bidi: segment_rpr.language_bidi.clone(),
5265 direction: word_text_direction(segment_rpr.rtl),
5266 spacing: segment_rpr
5267 .spacing
5268 .map_or(0.0, |value| value.to_pt()),
5269 },
5270 );
5271 inline_items.push(InlineItem::Text(TextSegment {
5272 text,
5273 direction: word_text_direction(segment_rpr.rtl),
5274 source: None,
5275 font_id: segment_font_id,
5276 font_size: segment_font_size,
5277 glyph_ids: shaped.glyph_ids,
5278 advances: shaped.advances,
5279 width: shaped.width,
5280 ascent: segment_metrics.ascent,
5281 descent: segment_metrics.descent,
5282 line_gap: 0.0,
5283 color: segment_color,
5284 bold: segment_bold,
5285 italic: segment_italic,
5286 underline: segment_underline,
5287 strike: segment_strike,
5288 dstrike: segment_dstrike,
5289 highlight: segment_highlight,
5290 baseline_offset: segment_baseline_offset,
5291 hyperlink_url: current_hyperlink_url.clone(),
5292 field_kind,
5293 note: None,
5294 }));
5295 }
5296 if let Some(control) = control {
5297 inline_items.push(control);
5298 start = index + character.len_utf8();
5299 }
5300 }
5301 }
5302 }
5303 RunContent::FootnoteRef { id } | RunContent::EndnoteRef { id } => {
5304 let stream = match content {
5307 RunContent::EndnoteRef { .. } => NoteStream::Endnote,
5308 _ => NoteStream::Footnote,
5309 };
5310 let marker = id.to_string();
5312 let sup_size = font_size * 0.58;
5313 let sup_offset = font_size * 0.33; let shaped = fm.shape_text(font_id, &marker, sup_size)?;
5315 let sup_metrics = fm.metrics(font_id, sup_size)?;
5316 let revision_marker = input.revision_view == RevisionView::Tracked
5317 && projected.ordinary_run_index.is_none();
5318 inline_items.push(InlineItem::Text(TextSegment {
5319 text: marker,
5320 direction: TextDirection::Auto,
5321 source: None,
5322 font_id,
5323 font_size: sup_size,
5324 glyph_ids: shaped.glyph_ids,
5325 advances: shaped.advances,
5326 width: shaped.width,
5327 ascent: sup_metrics.ascent,
5328 descent: sup_metrics.descent,
5329 line_gap: 0.0,
5330 color,
5331 bold,
5332 italic,
5333 underline: revision_marker.then_some(underline).flatten(),
5334 strike: revision_marker && strike,
5335 dstrike: revision_marker && dstrike,
5336 highlight: revision_marker.then_some(highlight).flatten(),
5337 baseline_offset: sup_offset,
5338 hyperlink_url: None,
5339 field_kind: None,
5340 note: Some(NoteRef { stream, id: *id }),
5341 }));
5342 }
5343 RunContent::CommentReference { .. } => {}
5344 }
5345 }
5346 }
5347
5348 let final_marker_lower = (marker_boundary == Some(para.runs.len()))
5349 .then_some(marker_raw_before)
5350 .flatten();
5351 push_targeted_bookmark_markers(
5352 &mut inline_items,
5353 para,
5354 para.runs.len(),
5355 final_marker_lower,
5356 RawOrder::AfterRaw,
5357 input,
5358 fm,
5359 )?;
5360
5361 let attributed_empty_paragraph = inline_items.is_empty();
5362 if attributed_empty_paragraph {
5363 let mut caret_rpr = style_resolver::resolve_run_properties(para_style_id, None, styles);
5364 if let Some(paragraph_mark_rpr) = direct_ppr.and_then(|ppr| ppr.rpr.as_ref()) {
5365 caret_rpr.merge_from(paragraph_mark_rpr);
5366 }
5367 let font_size = caret_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
5368 let bold = caret_rpr.bold.unwrap_or(false);
5369 let italic = caret_rpr.italic.unwrap_or(false);
5370 let font_family = resolve_font_family(&caret_rpr, input.theme.as_ref());
5371 let font_id = fm.resolve_font_for_metrics(font_family.as_deref(), bold, italic)?;
5372 let metrics = fm.metrics(font_id, font_size)?;
5373 inline_items.push(InlineItem::Text(TextSegment {
5374 text: String::new(),
5375 direction: TextDirection::Auto,
5376 source: source_node.map(|node| SourceSpan {
5377 node,
5378 char_start: 0,
5379 char_end: 0,
5380 }),
5381 font_id,
5382 font_size,
5383 glyph_ids: Vec::new(),
5384 advances: Vec::new(),
5385 width: 0.0,
5386 ascent: metrics.ascent,
5387 descent: metrics.descent,
5388 line_gap: 0.0,
5389 color: resolve_run_color(&caret_rpr, input.theme.as_ref()),
5390 bold,
5391 italic,
5392 underline: None,
5393 strike: false,
5394 dstrike: false,
5395 highlight: None,
5396 baseline_offset: 0.0,
5397 hyperlink_url: None,
5398 field_kind: None,
5399 note: None,
5400 }));
5401 }
5402
5403 let layout_direction = match base_direction {
5404 TextDirection::Auto => inferred_word_base_direction(&inline_items),
5405 direction => direction,
5406 };
5407 if let Some(reflow_direction_out) = reflow_direction_out {
5408 *reflow_direction_out = layout_direction;
5409 }
5410 let logical_left = match layout_direction {
5411 TextDirection::RightToLeft => effective_ppr.ind_end,
5412 TextDirection::Auto | TextDirection::LeftToRight => effective_ppr.ind_start,
5413 };
5414 let logical_right = match layout_direction {
5415 TextDirection::RightToLeft => effective_ppr.ind_start,
5416 TextDirection::Auto | TextDirection::LeftToRight => effective_ppr.ind_end,
5417 };
5418 let ind_left = effective_ppr
5419 .ind_left
5420 .or(logical_left)
5421 .map(|t| t.to_pt())
5422 .unwrap_or(0.0);
5423 let ind_right = effective_ppr
5424 .ind_right
5425 .or(logical_right)
5426 .map(|t| t.to_pt())
5427 .unwrap_or(0.0);
5428 let jc = convert::alignment_for_direction(effective_ppr.jc, layout_direction);
5429
5430 let mut line_params = convert::line_break_params(&effective_ppr, available_width);
5432 line_params.ind_left = ind_left;
5433 line_params.ind_right = ind_right;
5434 line_params.jc = jc;
5435
5436 let legacy_empty_line = if attributed_empty_paragraph
5437 && direct_ppr
5438 .and_then(|properties| properties.rpr.as_ref())
5439 .is_none()
5440 {
5441 let mut lines = break_into_lines(&[], &line_params, fm)?;
5442 convert::restore_word_line_heights(&mut lines, &effective_ppr);
5443 lines.pop()
5444 } else {
5445 None
5446 };
5447
5448 let uses_multilingual_layout = base_direction != TextDirection::Auto
5449 || multilingual_styles
5450 .values()
5451 .any(|style| style.direction != TextDirection::Auto)
5452 || inline_items.iter().any(|item| {
5453 multilingual_candidate(item)
5454 .is_some_and(|segment| needs_word_multilingual_layout(&segment.text))
5455 });
5456 let uses_exact_word_baseline = uses_multilingual_layout
5457 && effective_ppr.line_rule.as_deref() == Some("exact")
5458 && effective_ppr.line_spacing.is_some();
5459 if uses_multilingual_layout {
5460 inline_items = shape_word_multilingual_items(
5461 fm,
5462 inline_items,
5463 &multilingual_styles,
5464 layout_direction,
5465 !line_params.wrap,
5466 uses_exact_word_baseline,
5467 )?;
5468 }
5469 let mut lines = if uses_multilingual_layout {
5470 break_multilingual_into_lines(&inline_items, &line_params, fm, layout_direction)?
5471 } else {
5472 break_into_lines(&inline_items, &line_params, fm)?
5473 };
5474 convert::restore_word_line_heights(&mut lines, &effective_ppr);
5475 if let (Some(line), Some(legacy)) = (lines.first_mut(), legacy_empty_line) {
5476 line.ascent = legacy.ascent;
5477 line.descent = legacy.descent;
5478 line.line_gap = legacy.line_gap;
5479 line.height = legacy.height;
5480 }
5481
5482 let mut result = block::build_paragraph_block(
5483 lines,
5484 space_before,
5485 space_after,
5486 effective_ppr.borders,
5487 shading,
5488 ind_left,
5489 ind_right,
5490 jc,
5491 keep_next,
5492 keep_lines,
5493 page_break_before,
5494 widow_control,
5495 );
5496 result.has_visible_revision =
5497 input.revision_view == RevisionView::Tracked && paragraph_has_visible_revision(para);
5498 result.list = list_num_id.map(|num_id| (num_id, list_ilvl.min(8) as u8));
5499 result.anchored =
5500 collect_anchored_drawings(para, styles, input, media, fm, num_state, diagnostics)?;
5501 result.reflow = Some(Box::new(block::ParagraphReflow {
5505 items: inline_items,
5506 params: line_params,
5507 }));
5508 Ok(result)
5509}
5510
5511fn push_targeted_bookmark_markers(
5512 items: &mut Vec<InlineItem>,
5513 paragraph: &CT_P,
5514 run_index: usize,
5515 after_raw: Option<RawOrder>,
5516 through_raw: RawOrder,
5517 input: &LayoutInput,
5518 fm: &mut FontManager,
5519) -> Result<()> {
5520 let mut font_id = None;
5521 for marker in paragraph.bookmark_markers.iter().filter(|marker| {
5522 marker.is_start()
5523 && marker.run_index() == run_index
5524 && after_raw.is_none_or(|after| RawOrder::Raw(marker.raw_before()) > after)
5525 && RawOrder::Raw(marker.raw_before()) <= through_raw
5526 && marker.name().is_some_and(|name| {
5527 document_has_page_ref(input, name) && bookmark_text(input, name).is_some()
5528 })
5529 }) {
5530 if let Some(target) = marker.name().and_then(|name| page_ref_id(input, name)) {
5531 let resolved_font = match font_id {
5532 Some(font_id) => font_id,
5533 None => {
5534 let resolved = fm.resolve_font_for_text(None, false, false, " ")?;
5535 font_id = Some(resolved);
5536 resolved
5537 }
5538 };
5539 push_bookmark_marker(items, target, resolved_font);
5540 }
5541 }
5542 Ok(())
5543}
5544
5545fn push_bookmark_marker(items: &mut Vec<InlineItem>, target: usize, font_id: oxml_layout::FontId) {
5546 items.push(InlineItem::Text(TextSegment {
5547 text: "\u{2060}".to_owned(),
5548 direction: TextDirection::Auto,
5549 source: None,
5550 font_id,
5551 font_size: 1.0,
5552 glyph_ids: vec![0],
5553 advances: vec![0.0],
5554 width: 0.0,
5555 ascent: 0.0,
5556 descent: 0.0,
5557 line_gap: 0.0,
5558 color: Color::BLACK,
5559 bold: false,
5560 italic: false,
5561 underline: None,
5562 strike: false,
5563 dstrike: false,
5564 highlight: None,
5565 baseline_offset: 0.0,
5566 hyperlink_url: None,
5567 field_kind: Some(FieldKind::Target(target)),
5568 note: None,
5569 }));
5570}
5571
5572fn page_ref_id(input: &LayoutInput, name: &str) -> Option<usize> {
5573 let mut names = Vec::<&str>::new();
5574 visit_document_paragraphs(input, &mut |paragraph| {
5575 for projected in project_paragraph_runs(paragraph, input.revision_view) {
5576 let run = projected.run;
5577 for content in &run.content {
5578 let RunContent::Field(field) = content else {
5579 continue;
5580 };
5581 if field.instruction.name != "PAGEREF" {
5582 continue;
5583 }
5584 let Some(bookmark) = field_text_argument(field, 0) else {
5585 continue;
5586 };
5587 if !names.contains(&bookmark) {
5588 names.push(bookmark);
5589 }
5590 }
5591 }
5592 });
5593 names.iter().position(|candidate| *candidate == name)
5594}
5595
5596fn field_text_argument(field: &Field, index: usize) -> Option<&str> {
5597 match field.instruction.arguments.get(index) {
5598 Some(FieldArgument::Text(value)) => Some(value),
5599 Some(FieldArgument::Nested(_)) | None => None,
5600 }
5601}
5602
5603fn document_has_page_ref(input: &LayoutInput, name: &str) -> bool {
5604 page_ref_id(input, name).is_some()
5605}
5606
5607fn visit_document_paragraphs<'a>(input: &'a LayoutInput, visit: &mut impl FnMut(&'a CT_P)) {
5608 for content in &input.document.body.content {
5609 match content {
5610 BodyContent::Paragraph(paragraph) => visit(paragraph),
5611 BodyContent::Table(table) => visit_table_paragraphs(table, visit),
5612 BodyContent::ContentControl(control) => visit_control_paragraphs(control, visit),
5613 BodyContent::RawXml(_) => {}
5614 }
5615 }
5616}
5617
5618fn visit_table_paragraphs<'a>(table: &'a CT_Tbl, visit: &mut impl FnMut(&'a CT_P)) {
5619 for (_, _, control) in &table.content_controls {
5620 visit_control_paragraphs(control, visit);
5621 }
5622 for row in &table.rows {
5623 visit_row_paragraphs(row, visit);
5624 }
5625}
5626
5627fn visit_row_paragraphs<'a>(row: &'a CT_Row, visit: &mut impl FnMut(&'a CT_P)) {
5628 for (_, _, control) in &row.content_controls {
5629 visit_control_paragraphs(control, visit);
5630 }
5631 for cell in &row.cells {
5632 visit_cell_paragraphs(cell, visit);
5633 }
5634}
5635
5636fn visit_cell_paragraphs<'a>(cell: &'a CT_Tc, visit: &mut impl FnMut(&'a CT_P)) {
5637 for content in &cell.content {
5638 match content {
5639 CellContent::Paragraph(paragraph) => visit(paragraph),
5640 CellContent::Table(table) => visit_table_paragraphs(table, visit),
5641 CellContent::ContentControl(control) => visit_control_paragraphs(control, visit),
5642 }
5643 }
5644}
5645
5646fn visit_control_paragraphs<'a>(control: &'a CT_Sdt, visit: &mut impl FnMut(&'a CT_P)) {
5647 for content in &control.content {
5648 match content {
5649 SdtContent::Paragraph(paragraph) => visit(paragraph),
5650 SdtContent::Table(table) => visit_table_paragraphs(table, visit),
5651 SdtContent::Row(row) => visit_row_paragraphs(row, visit),
5652 SdtContent::Cell(cell) => visit_cell_paragraphs(cell, visit),
5653 SdtContent::ContentControl(control) => visit_control_paragraphs(control, visit),
5654 SdtContent::Run(_) | SdtContent::RawXml(_) => {}
5655 }
5656 }
5657}
5658
5659fn bookmark_text(input: &LayoutInput, name: &str) -> Option<String> {
5660 type BodyRunPosition = (usize, usize, RawOrder);
5661 type BookmarkStart<'a> = (Option<&'a str>, BodyRunPosition);
5662
5663 let mut starts: HashMap<i32, Vec<BookmarkStart<'_>>> = HashMap::new();
5664 let mut ends: HashMap<i32, Vec<BodyRunPosition>> = HashMap::new();
5665 for (body_index, content) in input.document.body.content.iter().enumerate() {
5666 let BodyContent::Paragraph(paragraph) = content else {
5667 continue;
5668 };
5669 for marker in ¶graph.bookmark_markers {
5670 let Some(id) = marker.id() else {
5671 continue;
5672 };
5673 if marker.run_index() > paragraph.runs.len() {
5674 return None;
5675 }
5676 let position = (
5677 body_index,
5678 marker.run_index(),
5679 RawOrder::Raw(marker.raw_before()),
5680 );
5681 if marker.is_start() {
5682 starts
5683 .entry(id)
5684 .or_default()
5685 .push((marker.name(), position));
5686 } else {
5687 ends.entry(id).or_default().push(position);
5688 }
5689 }
5690 }
5691 let candidates = starts
5692 .iter()
5693 .filter_map(|(id, starts)| {
5694 let ends = ends.get(id)?;
5695 (starts.len() == 1 && starts[0].0 == Some(name) && ends.len() == 1)
5696 .then_some((starts[0].1, ends[0]))
5697 })
5698 .collect::<Vec<_>>();
5699 if candidates.len() != 1 {
5700 return None;
5701 }
5702 let (start, end) = candidates[0];
5703 if start > end {
5704 return None;
5705 }
5706 let mut parts = Vec::new();
5707 for body_index in start.0..=end.0 {
5708 let BodyContent::Paragraph(paragraph) = &input.document.body.content[body_index] else {
5709 continue;
5710 };
5711 parts.push(
5712 project_paragraph_runs(paragraph, input.revision_view)
5713 .iter()
5714 .filter(|projected| {
5715 let position = (body_index, projected.boundary, projected.raw_order);
5716 position >= start && position < end
5717 })
5718 .map(|projected| projected.run.text())
5719 .collect::<String>(),
5720 );
5721 }
5722 Some(parts.join("\n"))
5723}
5724
5725fn document_has_wrapping_drawing(input: &LayoutInput) -> bool {
5730 fn paragraph_wraps(para: &CT_P, view: RevisionView) -> bool {
5731 fn run_wraps(run: &CT_R) -> bool {
5732 run.content
5733 .iter()
5734 .filter_map(|rc| match rc {
5735 RunContent::Drawing(d) => Some(d),
5736 _ => None,
5737 })
5738 .chain(run.alt_drawings.iter())
5739 .any(|drawing| {
5740 drawing
5741 .anchor
5742 .as_ref()
5743 .is_some_and(|anchor| anchor.wrap != WrapType::None)
5744 })
5745 }
5746
5747 if para.revisions.is_empty() && para.content_controls.is_empty() {
5748 para.runs.iter().any(run_wraps)
5749 } else {
5750 project_paragraph_runs(para, view)
5751 .iter()
5752 .any(|projected| run_wraps(projected.run))
5753 }
5754 }
5755
5756 input
5757 .document
5758 .body
5759 .content
5760 .iter()
5761 .any(|content| match content {
5762 BodyContent::Paragraph(para) => paragraph_wraps(para, input.revision_view),
5763 BodyContent::Table(table) => table
5764 .rows
5765 .iter()
5766 .flat_map(|row| row.cells.iter())
5767 .flat_map(|cell| cell.content.iter())
5768 .any(|content| match content {
5769 rdocx_oxml::table::CellContent::Paragraph(para) => {
5770 paragraph_wraps(para, input.revision_view)
5771 }
5772 rdocx_oxml::table::CellContent::Table(_) => false,
5775 rdocx_oxml::table::CellContent::ContentControl(_) => false,
5776 }),
5777 _ => false,
5778 })
5779}
5780
5781fn render_word_chart(
5782 relationship_id: &str,
5783 width: f64,
5784 height: f64,
5785 input: &LayoutInput,
5786 fm: &mut FontManager,
5787 diagnostics: &mut Vec<Diagnostic>,
5788) -> Result<GroupElement> {
5789 let bounds = Rect {
5790 x: 0.0,
5791 y: 0.0,
5792 width,
5793 height,
5794 };
5795 let rendered = match input.charts.get(relationship_id) {
5796 Some(Ok(chart)) => oxml_chart::render_chart(
5797 &chart.chart,
5798 bounds,
5799 &input.chart_theme,
5800 &input.chart_color_map,
5801 fm,
5802 )
5803 .map_err(|error| error.to_string()),
5804 Some(Err(message)) => Err(message.clone()),
5805 None => Err("relationship was not resolved from the document part".to_owned()),
5806 };
5807 match rendered {
5808 Ok(group) => Ok(group),
5809 Err(detail) => {
5810 diagnostics.push(Diagnostic {
5811 message: format!("Word chart relationship {relationship_id}: {detail}"),
5812 });
5813 oxml_chart::render_chart_placeholder(bounds, fm)
5814 .map_err(|error| oxml_layout::LayoutError::Layout(error.to_string()))
5815 }
5816 }
5817}
5818
5819fn collect_anchored_drawings(
5828 para: &CT_P,
5829 styles: &CT_Styles,
5830 input: &LayoutInput,
5831 media: &MediaRegistry,
5832 fm: &mut FontManager,
5833 num_state: &mut NumberingState,
5834 diagnostics: &mut Vec<Diagnostic>,
5835) -> Result<Vec<block::AnchoredDrawing>> {
5836 let mut out = Vec::new();
5837
5838 for projected in project_paragraph_runs(para, input.revision_view) {
5841 let run = projected.run;
5842 let plain = run.content.iter().filter_map(|rc| match rc {
5843 RunContent::Drawing(d) => Some(d),
5844 _ => None,
5845 });
5846 for drawing in plain.chain(run.alt_drawings.iter()) {
5847 let Some(anchor) = drawing.anchor.as_ref() else {
5848 continue;
5849 };
5850
5851 let shape = if anchor.embed_id.is_empty() && anchor.chart_rel_id.is_none() {
5855 anchor.shape.as_ref()
5856 } else {
5857 None
5858 };
5859
5860 let content = if let Some(relationship_id) = anchor.chart_rel_id.as_deref() {
5861 block::AnchoredContent::Group(render_word_chart(
5862 relationship_id,
5863 anchor.extent_cx.to_pt(),
5864 anchor.extent_cy.to_pt(),
5865 input,
5866 fm,
5867 diagnostics,
5868 )?)
5869 } else {
5870 match shape {
5871 Some(shape) => {
5872 let mut text = Vec::new();
5874 for p in &shape.text {
5875 text.push(layout_paragraph(
5876 p,
5877 anchor.extent_cx.to_pt(),
5878 styles,
5879 input,
5880 media,
5881 fm,
5882 num_state,
5883 diagnostics,
5884 )?);
5885 }
5886 block::AnchoredContent::Shape {
5887 preset: block::ShapePreset::from_prst(shape.preset.as_deref()),
5888 fill: shape.solid_fill.as_deref().map(Color::from_hex),
5889 text,
5890 }
5891 }
5892 None if anchor.embed_id.is_empty() => continue,
5893 None => block::AnchoredContent::Image {
5894 media_id: media.id_for_relationship(&anchor.embed_id),
5895 },
5896 }
5897 };
5898
5899 out.push(block::AnchoredDrawing {
5900 behind_doc: anchor.behind_doc,
5901 rel_h: anchor.pos_h_relative_from,
5902 off_h: anchor.pos_h_offset.to_pt(),
5903 rel_v: anchor.pos_v_relative_from,
5904 off_v: anchor.pos_v_offset.to_pt(),
5905 width: anchor.extent_cx.to_pt(),
5906 height: anchor.extent_cy.to_pt(),
5907 alternate_text: anchor.description.clone(),
5908 structure_id: None,
5909 wrap: anchor.wrap,
5910 dist_top: anchor.dist_t.to_pt(),
5911 dist_bottom: anchor.dist_b.to_pt(),
5912 dist_left: anchor.dist_l.to_pt(),
5913 dist_right: anchor.dist_r.to_pt(),
5914 align_h: anchor.pos_h_align,
5915 align_v: anchor.pos_v_align,
5916 content,
5917 });
5918 }
5919 }
5920 Ok(out)
5921}
5922
5923fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
5925 if direct.jc.is_some() {
5927 effective.jc = direct.jc;
5928 }
5929 if direct.space_before.is_some() {
5930 effective.space_before = direct.space_before;
5931 }
5932 if direct.space_after.is_some() {
5933 effective.space_after = direct.space_after;
5934 }
5935 if direct.line_spacing.is_some() {
5936 effective.line_spacing = direct.line_spacing;
5937 }
5938 if direct.line_rule.is_some() {
5939 effective.line_rule = direct.line_rule.clone();
5940 }
5941 if direct.ind_left.is_some() {
5942 effective.ind_left = direct.ind_left;
5943 }
5944 if direct.ind_right.is_some() {
5945 effective.ind_right = direct.ind_right;
5946 }
5947 if direct.ind_start.is_some() {
5948 effective.ind_start = direct.ind_start;
5949 }
5950 if direct.ind_end.is_some() {
5951 effective.ind_end = direct.ind_end;
5952 }
5953 if direct.ind_first_line.is_some() {
5954 effective.ind_first_line = direct.ind_first_line;
5955 }
5956 if direct.ind_hanging.is_some() {
5957 effective.ind_hanging = direct.ind_hanging;
5958 }
5959 if direct.keep_next.is_some() {
5960 effective.keep_next = direct.keep_next;
5961 }
5962 if direct.keep_lines.is_some() {
5963 effective.keep_lines = direct.keep_lines;
5964 }
5965 if direct.page_break_before.is_some() {
5966 effective.page_break_before = direct.page_break_before;
5967 }
5968 if direct.widow_control.is_some() {
5969 effective.widow_control = direct.widow_control;
5970 }
5971 if direct.suppress_auto_hyphens.is_some() {
5972 effective.suppress_auto_hyphens = direct.suppress_auto_hyphens;
5973 }
5974 if direct.bidi.is_some() {
5975 effective.bidi = direct.bidi;
5976 }
5977 if direct.borders.is_some() {
5978 effective.borders = direct.borders.clone();
5979 }
5980 if direct.tabs.is_some() {
5981 effective.tabs = direct.tabs.clone();
5982 }
5983 if direct.shading.is_some() {
5984 effective.shading = direct.shading.clone();
5985 }
5986 if direct.num_id.is_some() {
5987 effective.num_id = direct.num_id;
5988 }
5989 if direct.num_ilvl.is_some() {
5990 effective.num_ilvl = direct.num_ilvl;
5991 }
5992}
5993
5994fn sect_pr_to_geometry(sect_pr: &CT_SectPr) -> PageGeometry {
5996 PageGeometry {
5997 page_width: sect_pr.page_width.map(|t| t.to_pt()).unwrap_or(612.0),
5998 page_height: sect_pr.page_height.map(|t| t.to_pt()).unwrap_or(792.0),
5999 margin_top: sect_pr.margin_top.map(|t| t.to_pt()).unwrap_or(72.0),
6000 margin_right: sect_pr.margin_right.map(|t| t.to_pt()).unwrap_or(72.0),
6001 margin_bottom: sect_pr.margin_bottom.map(|t| t.to_pt()).unwrap_or(72.0),
6002 margin_left: sect_pr.margin_left.map(|t| t.to_pt()).unwrap_or(72.0),
6003 header_distance: sect_pr.header_distance.map(|t| t.to_pt()).unwrap_or(36.0),
6004 footer_distance: sect_pr.footer_distance.map(|t| t.to_pt()).unwrap_or(36.0),
6005 }
6006}
6007
6008fn section_page_number_start(sect_pr: &CT_SectPr) -> Option<usize> {
6009 for raw in §_pr.extra_xml {
6010 let Some((name, raw_attributes)) = raw_root_start_tag(raw) else {
6011 continue;
6012 };
6013 let Some(attributes) = parse_raw_attributes(raw_attributes) else {
6014 continue;
6015 };
6016 if xml_local_name(name) != b"pgNumType"
6017 || !raw_name_has_namespace(name, &attributes, rdocx_oxml::namespace::W_NS, false)
6018 {
6019 continue;
6020 }
6021 let (_, value) = attributes.iter().find(|(attribute_name, _)| {
6022 xml_local_name(attribute_name) == b"start"
6023 && raw_name_has_namespace(
6024 attribute_name,
6025 &attributes,
6026 rdocx_oxml::namespace::W_NS,
6027 true,
6028 )
6029 })?;
6030 return decode_xml_attribute(value)?.parse().ok();
6031 }
6032 None
6033}
6034
6035fn xml_local_name(name: &[u8]) -> &[u8] {
6036 name.rsplit(|byte| *byte == b':').next().unwrap_or(name)
6037}
6038
6039fn raw_root_start_tag(raw: &[u8]) -> Option<(&[u8], &[u8])> {
6040 let mut cursor = 0usize;
6041 while cursor < raw.len() && raw[cursor].is_ascii_whitespace() {
6042 cursor += 1;
6043 }
6044 if raw.get(cursor) != Some(&b'<') {
6045 return None;
6046 }
6047 cursor += 1;
6048 if matches!(raw.get(cursor), Some(b'!' | b'?' | b'/')) {
6049 return None;
6050 }
6051 let name_start = cursor;
6052 while cursor < raw.len()
6053 && !raw[cursor].is_ascii_whitespace()
6054 && !matches!(raw[cursor], b'>' | b'/')
6055 {
6056 cursor += 1;
6057 }
6058 if cursor == name_start {
6059 return None;
6060 }
6061 let name_end = cursor;
6062 let attributes_start = cursor;
6063 let mut quote = None;
6064 while cursor < raw.len() {
6065 match (quote, raw[cursor]) {
6066 (None, b'\'' | b'"') => quote = Some(raw[cursor]),
6067 (Some(expected), found) if expected == found => quote = None,
6068 (None, b'>') => {
6069 return Some((&raw[name_start..name_end], &raw[attributes_start..cursor]));
6070 }
6071 _ => {}
6072 }
6073 cursor += 1;
6074 }
6075 None
6076}
6077
6078fn parse_raw_attributes(attributes: &[u8]) -> Option<Vec<(&[u8], &[u8])>> {
6079 let mut parsed = Vec::new();
6080 let mut cursor = 0usize;
6081 while cursor < attributes.len() {
6082 while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
6083 cursor += 1;
6084 }
6085 if cursor == attributes.len() || attributes[cursor] == b'/' {
6086 break;
6087 }
6088 let name_start = cursor;
6089 while cursor < attributes.len()
6090 && !attributes[cursor].is_ascii_whitespace()
6091 && attributes[cursor] != b'='
6092 {
6093 cursor += 1;
6094 }
6095 let name_end = cursor;
6096 while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
6097 cursor += 1;
6098 }
6099 if attributes.get(cursor) != Some(&b'=') {
6100 cursor = cursor.saturating_add(1);
6101 continue;
6102 }
6103 cursor += 1;
6104 while cursor < attributes.len() && attributes[cursor].is_ascii_whitespace() {
6105 cursor += 1;
6106 }
6107 let quote = *attributes.get(cursor)?;
6108 if !matches!(quote, b'\'' | b'"') {
6109 return None;
6110 }
6111 cursor += 1;
6112 let value_start = cursor;
6113 while cursor < attributes.len() && attributes[cursor] != quote {
6114 cursor += 1;
6115 }
6116 let value_end = cursor;
6117 cursor += 1;
6118 parsed.push((
6119 &attributes[name_start..name_end],
6120 &attributes[value_start..value_end],
6121 ));
6122 }
6123 Some(parsed)
6124}
6125
6126fn raw_name_has_namespace(
6127 name: &[u8],
6128 attributes: &[(&[u8], &[u8])],
6129 expected: &str,
6130 is_attribute: bool,
6131) -> bool {
6132 let prefix = name
6133 .iter()
6134 .rposition(|byte| *byte == b':')
6135 .map(|separator| &name[..separator]);
6136 let namespace = match prefix {
6137 Some(prefix) => attributes.iter().find_map(|(attribute_name, value)| {
6138 attribute_name
6139 .strip_prefix(b"xmlns:")
6140 .is_some_and(|declared| declared == prefix)
6141 .then_some(*value)
6142 }),
6143 None if !is_attribute => attributes
6144 .iter()
6145 .find_map(|(attribute_name, value)| (*attribute_name == b"xmlns").then_some(*value)),
6146 None => None,
6147 };
6148 match namespace {
6149 Some(namespace) => decode_xml_attribute(namespace).is_some_and(|value| value == expected),
6150 None => prefix == Some(b"w".as_slice()) && expected == rdocx_oxml::namespace::W_NS,
6151 }
6152}
6153
6154fn decode_xml_attribute(value: &[u8]) -> Option<String> {
6155 let value = std::str::from_utf8(value).ok()?;
6156 let mut decoded = String::with_capacity(value.len());
6157 let mut cursor = 0usize;
6158 while let Some(relative_start) = value[cursor..].find('&') {
6159 let entity_start = cursor + relative_start;
6160 decoded.push_str(&value[cursor..entity_start]);
6161 let entity_end = entity_start + value[entity_start..].find(';')?;
6162 let entity = &value[entity_start + 1..entity_end];
6163 match entity {
6164 "amp" => decoded.push('&'),
6165 "apos" => decoded.push('\''),
6166 "gt" => decoded.push('>'),
6167 "lt" => decoded.push('<'),
6168 "quot" => decoded.push('"'),
6169 numeric if numeric.starts_with("#x") => {
6170 decoded.push(char::from_u32(
6171 u32::from_str_radix(&numeric[2..], 16).ok()?,
6172 )?);
6173 }
6174 numeric if numeric.starts_with('#') => {
6175 decoded.push(char::from_u32(numeric[1..].parse().ok()?)?);
6176 }
6177 _ => return None,
6178 }
6179 cursor = entity_end + 1;
6180 }
6181 decoded.push_str(&value[cursor..]);
6182 Some(decoded)
6183}
6184
6185fn layout_header_footer(
6187 engine: &mut Engine,
6188 sect_pr: &CT_SectPr,
6189 input: &LayoutInput,
6190 styles: &CT_Styles,
6191 media: &MediaRegistry,
6192 num_state: &mut NumberingState,
6193 diagnostics: &mut Vec<Diagnostic>,
6194 sources: Option<&SourceRegistry>,
6195) -> Result<Option<(HeaderFooterContent, HeaderFooterSemantics)>> {
6196 let mut has_content = false;
6197 let mut header_blocks = Vec::new();
6198 let mut footer_blocks = Vec::new();
6199 let mut first_header_blocks = Vec::new();
6200 let mut first_footer_blocks = Vec::new();
6201 let mut even_header_blocks = Vec::new();
6202 let mut even_footer_blocks = Vec::new();
6203 let mut header_directions = Vec::new();
6204 let mut footer_directions = Vec::new();
6205 let mut first_header_directions = Vec::new();
6206 let mut first_footer_directions = Vec::new();
6207 let mut even_header_directions = Vec::new();
6208 let mut even_footer_directions = Vec::new();
6209 let mut watermark = None;
6210 let mut first_watermark = None;
6211 let mut even_watermark = None;
6212 let even_headers_active = sect_pr
6213 .header_refs
6214 .iter()
6215 .any(|reference| reference.hdr_ftr_type == HdrFtrType::Even);
6216
6217 let geometry = sect_pr_to_geometry(sect_pr);
6218 let width = geometry.content_width();
6219
6220 for href in §_pr.header_refs {
6221 let (target_blocks, target_directions, target_watermark) = match href.hdr_ftr_type {
6222 HdrFtrType::Default => (&mut header_blocks, &mut header_directions, &mut watermark),
6223 HdrFtrType::First => (
6224 &mut first_header_blocks,
6225 &mut first_header_directions,
6226 &mut first_watermark,
6227 ),
6228 HdrFtrType::Even => (
6229 &mut even_header_blocks,
6230 &mut even_header_directions,
6231 &mut even_watermark,
6232 ),
6233 };
6234 if let Some(hdr) = input.headers.get(&href.rel_id) {
6235 let content = layout_header_footer_variant(
6236 engine,
6237 HeaderFooterStoryKind::Header,
6238 href.hdr_ftr_type,
6239 sect_pr,
6240 &href.rel_id,
6241 hdr,
6242 input,
6243 styles,
6244 media,
6245 num_state,
6246 diagnostics,
6247 sources,
6248 width,
6249 geometry,
6250 )?;
6251 target_blocks.extend(content.blocks);
6252 target_directions.extend(content.directions);
6253 if target_watermark.is_none() {
6254 *target_watermark = content.watermark;
6255 }
6256 has_content = true;
6257 }
6258 }
6259
6260 for fref in §_pr.footer_refs {
6261 let (target_blocks, target_directions) = match fref.hdr_ftr_type {
6262 HdrFtrType::Default => (&mut footer_blocks, &mut footer_directions),
6263 HdrFtrType::First => (&mut first_footer_blocks, &mut first_footer_directions),
6264 HdrFtrType::Even => (&mut even_footer_blocks, &mut even_footer_directions),
6265 };
6266 if let Some(ftr) = input.footers.get(&fref.rel_id) {
6267 let content = layout_header_footer_variant(
6268 engine,
6269 HeaderFooterStoryKind::Footer,
6270 fref.hdr_ftr_type,
6271 sect_pr,
6272 &fref.rel_id,
6273 ftr,
6274 input,
6275 styles,
6276 media,
6277 num_state,
6278 diagnostics,
6279 sources,
6280 width,
6281 geometry,
6282 )?;
6283 target_blocks.extend(content.blocks);
6284 target_directions.extend(content.directions);
6285 has_content = true;
6286 }
6287 }
6288
6289 if has_content {
6290 Ok(Some((
6291 HeaderFooterContent {
6292 header_blocks,
6293 footer_blocks,
6294 first_header_blocks,
6295 first_footer_blocks,
6296 even_header_blocks,
6297 even_footer_blocks,
6298 even_headers_active,
6299 watermark,
6300 first_watermark,
6301 even_watermark,
6302 },
6303 HeaderFooterSemantics {
6304 header_directions,
6305 footer_directions,
6306 first_header_directions,
6307 first_footer_directions,
6308 even_header_directions,
6309 even_footer_directions,
6310 },
6311 )))
6312 } else {
6313 Ok(None)
6314 }
6315}
6316
6317#[allow(clippy::too_many_arguments)]
6318fn layout_header_footer_variant(
6319 engine: &mut Engine,
6320 story_kind: HeaderFooterStoryKind,
6321 variant: HdrFtrType,
6322 sect_pr: &CT_SectPr,
6323 relationship_id: &str,
6324 part: &rdocx_oxml::header_footer::CT_HdrFtr,
6325 input: &LayoutInput,
6326 styles: &CT_Styles,
6327 media: &MediaRegistry,
6328 num_state: &mut NumberingState,
6329 diagnostics: &mut Vec<Diagnostic>,
6330 sources: Option<&SourceRegistry>,
6331 width: f64,
6332 geometry: PageGeometry,
6333) -> Result<HeaderFooterVariantContent> {
6334 let cache_safe = header_footer_section_is_cache_safe(sect_pr)
6335 && header_footer_part_is_cache_safe(part, styles);
6336 let resolved_part_bytes = match story_kind {
6337 HeaderFooterStoryKind::Header => part.to_xml_header(),
6338 HeaderFooterStoryKind::Footer => part.to_xml_footer(),
6339 };
6340 if !cache_safe || resolved_part_bytes.is_err() {
6341 return layout_header_footer_variant_uncached(
6342 story_kind,
6343 relationship_id,
6344 part,
6345 input,
6346 styles,
6347 media,
6348 &mut engine.font_manager,
6349 num_state,
6350 diagnostics,
6351 sources,
6352 false,
6353 width,
6354 geometry,
6355 );
6356 }
6357
6358 let key = HeaderFooterCacheKey {
6359 story: story_kind,
6360 variant,
6361 section: sect_pr.clone(),
6362 relationship_id: relationship_id.to_owned(),
6363 part: part.clone(),
6364 resolved_part_bytes: resolved_part_bytes.expect("checked resolved part bytes"),
6365 with_provenance: sources.is_some(),
6366 };
6367 let hit = engine
6368 .header_footer_cache_reads_enabled
6369 .then(|| {
6370 engine
6371 .header_footer_cache
6372 .iter()
6373 .find(|entry| entry.key == key)
6374 .map(|entry| {
6375 (
6376 entry.content.clone(),
6377 entry.diagnostics.clone(),
6378 entry.font_trace.clone(),
6379 )
6380 })
6381 })
6382 .flatten();
6383 if let Some((mut content, cached_diagnostics, font_trace)) = hit {
6384 rebind_header_footer_sources(
6385 story_kind,
6386 relationship_id,
6387 part,
6388 &mut content.blocks,
6389 sources,
6390 )?;
6391 diagnostics.extend(cached_diagnostics);
6392 engine.font_manager.replay_layout_font_trace(&font_trace);
6393 engine.header_footer_cache_hits += 1;
6394 return Ok(content);
6395 }
6396
6397 let diagnostics_start = diagnostics.len();
6398 engine.font_manager.begin_paragraph_font_trace();
6399 let content_result = layout_header_footer_variant_uncached(
6400 story_kind,
6401 relationship_id,
6402 part,
6403 input,
6404 styles,
6405 media,
6406 &mut engine.font_manager,
6407 num_state,
6408 diagnostics,
6409 None,
6410 true,
6411 width,
6412 geometry,
6413 );
6414 let font_trace = engine.font_manager.finish_paragraph_font_trace();
6415 let mut content = content_result?;
6416 engine.header_footer_cache_builds += 1;
6417 let cached_diagnostics = diagnostics[diagnostics_start..].to_vec();
6418 if let Some(font_trace) = font_trace {
6419 let bytes =
6420 header_footer_cache_entry_bytes(&key, &content, &cached_diagnostics, &font_trace);
6421 engine.stage_header_footer_cache_entry(HeaderFooterCacheEntry {
6422 key,
6423 content: content.clone(),
6424 diagnostics: cached_diagnostics,
6425 font_trace,
6426 bytes,
6427 });
6428 }
6429 rebind_header_footer_sources(
6430 story_kind,
6431 relationship_id,
6432 part,
6433 &mut content.blocks,
6434 sources,
6435 )?;
6436 Ok(content)
6437}
6438
6439#[allow(clippy::too_many_arguments)]
6440fn layout_header_footer_variant_uncached(
6441 story_kind: HeaderFooterStoryKind,
6442 relationship_id: &str,
6443 part: &rdocx_oxml::header_footer::CT_HdrFtr,
6444 input: &LayoutInput,
6445 styles: &CT_Styles,
6446 media: &MediaRegistry,
6447 fm: &mut FontManager,
6448 num_state: &mut NumberingState,
6449 diagnostics: &mut Vec<Diagnostic>,
6450 sources: Option<&SourceRegistry>,
6451 cache_source: bool,
6452 width: f64,
6453 geometry: PageGeometry,
6454) -> Result<HeaderFooterVariantContent> {
6455 let watermark = if story_kind == HeaderFooterStoryKind::Header {
6456 match part.watermarks().first() {
6457 Some(projected) => layout_watermark(
6458 projected,
6459 relationship_id,
6460 input,
6461 media,
6462 fm,
6463 geometry,
6464 diagnostics,
6465 )?,
6466 None => None,
6467 }
6468 } else {
6469 None
6470 };
6471 let story = match story_kind {
6472 HeaderFooterStoryKind::Header => WordStory::Header {
6473 relationship_id: relationship_id.to_owned(),
6474 },
6475 HeaderFooterStoryKind::Footer => WordStory::Footer {
6476 relationship_id: relationship_id.to_owned(),
6477 },
6478 };
6479 let mut blocks = Vec::with_capacity(part.paragraphs.len());
6480 let mut directions = Vec::with_capacity(part.paragraphs.len());
6481 for (paragraph_index, paragraph) in part.paragraphs.iter().enumerate() {
6482 let source = if cache_source {
6483 Some(CACHE_SOURCE_NODE)
6484 } else {
6485 sources.and_then(|sources| sources.id(&story, &[paragraph_index]))
6486 };
6487 let (block, direction) = layout_paragraph_with_source_and_direction(
6488 paragraph,
6489 width,
6490 styles,
6491 input,
6492 media,
6493 fm,
6494 num_state,
6495 diagnostics,
6496 source,
6497 )?;
6498 blocks.push(block);
6499 directions.push(direction);
6500 }
6501 Ok(HeaderFooterVariantContent {
6502 blocks,
6503 directions,
6504 watermark,
6505 })
6506}
6507
6508fn layout_watermark(
6509 watermark: &VmlWatermark,
6510 header_relationship_id: &str,
6511 input: &LayoutInput,
6512 media: &MediaRegistry,
6513 fm: &mut FontManager,
6514 geometry: PageGeometry,
6515 diagnostics: &mut Vec<Diagnostic>,
6516) -> Result<Option<GroupElement>> {
6517 let (width, height, rotation, opacity) = match watermark {
6518 VmlWatermark::Text {
6519 width_pt,
6520 height_pt,
6521 rotation_degrees,
6522 opacity,
6523 ..
6524 }
6525 | VmlWatermark::Image {
6526 width_pt,
6527 height_pt,
6528 rotation_degrees,
6529 opacity,
6530 ..
6531 } => (*width_pt, *height_pt, *rotation_degrees, *opacity),
6532 };
6533 let translate = Transform {
6534 e: geometry.margin_left + (geometry.content_width() - width) / 2.0,
6535 f: geometry.margin_top + (geometry.content_height() - height) / 2.0,
6536 ..Transform::IDENTITY
6537 };
6538 let transform = Transform::rotate_about(rotation, width / 2.0, height / 2.0).then(translate);
6539 let children = match watermark {
6540 VmlWatermark::Text {
6541 text,
6542 color,
6543 font_family,
6544 ..
6545 } => {
6546 let Some(color) = vml_color(color) else {
6547 diagnostics.push(Diagnostic {
6548 message: format!("VML watermark colour {color:?} is unsupported"),
6549 });
6550 return Ok(None);
6551 };
6552 let estimated = width / (text.chars().count().max(1) as f64 * 0.62);
6553 let font_size = (height * 0.62).min(estimated).max(1.0);
6554 let font_id = fm.resolve_font_for_text(
6555 font_family.as_deref().or(Some("Calibri")),
6556 false,
6557 false,
6558 text,
6559 )?;
6560 let shaped = fm.shape_text(font_id, text, font_size)?;
6561 let metrics = fm.metrics(font_id, font_size)?;
6562 vec![PositionedElement::Text(GlyphRun {
6563 origin: Point {
6564 x: (width - shaped.width) / 2.0,
6565 y: (height + metrics.ascent - metrics.descent) / 2.0,
6566 },
6567 font_id,
6568 font_size,
6569 glyph_ids: shaped.glyph_ids,
6570 advances: shaped.advances,
6571 text: text.clone(),
6572 source: None,
6573 color,
6574 bold: false,
6575 italic: false,
6576 field_kind: None,
6577 note: None,
6578 })]
6579 }
6580 VmlWatermark::Image {
6581 relationship_id, ..
6582 } => {
6583 let scoped_id = format!("{header_relationship_id}\0{relationship_id}");
6584 let Some(image) = input.images.get(&scoped_id) else {
6585 diagnostics.push(Diagnostic {
6586 message: format!(
6587 "VML watermark image relationship {relationship_id} in header {header_relationship_id} was not resolved"
6588 ),
6589 });
6590 return Ok(None);
6591 };
6592 let data = image.data.clone();
6593 vec![PositionedElement::Image {
6594 rect: Rect {
6595 x: 0.0,
6596 y: 0.0,
6597 width,
6598 height,
6599 },
6600 content_type: image.content_type.clone(),
6601 media_id: media.id_for_relationship(&scoped_id),
6602 data,
6603 }]
6604 }
6605 };
6606 Ok(Some(GroupElement {
6607 transform,
6608 clip: None,
6609 opacity,
6610 effects: Vec::new(),
6611 children,
6612 }))
6613}
6614
6615fn vml_color(value: &str) -> Option<Color> {
6616 let normalized = value.trim().to_ascii_lowercase();
6617 let hex = match normalized.as_str() {
6618 "black" => "000000",
6619 "silver" => "c0c0c0",
6620 "gray" | "grey" => "808080",
6621 "white" => "ffffff",
6622 "maroon" => "800000",
6623 "red" => "ff0000",
6624 "purple" => "800080",
6625 "fuchsia" | "magenta" => "ff00ff",
6626 "green" => "008000",
6627 "lime" => "00ff00",
6628 "olive" => "808000",
6629 "yellow" => "ffff00",
6630 "navy" => "000080",
6631 "blue" => "0000ff",
6632 "teal" => "008080",
6633 "aqua" | "cyan" => "00ffff",
6634 _ => normalized.trim_start_matches('#'),
6635 };
6636 (hex.len() == 6 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
6637 .then(|| Color::from_hex(hex))
6638}
6639
6640fn resolve_font_family(
6644 rpr: &rdocx_oxml::properties::CT_RPr,
6645 theme: Option<&rdocx_oxml::theme::Theme>,
6646) -> Option<String> {
6647 if rpr.font_ascii.is_some() {
6649 return rpr.font_ascii.clone();
6650 }
6651
6652 if let (Some(theme_ref), Some(theme)) = (&rpr.font_ascii_theme, theme) {
6654 let font = match theme_ref.as_str() {
6655 "majorAscii" | "majorHAnsi" | "majorBidi" | "majorEastAsia" => {
6656 theme.major_font.as_deref()
6657 }
6658 "minorAscii" | "minorHAnsi" | "minorBidi" | "minorEastAsia" => {
6659 theme.minor_font.as_deref()
6660 }
6661 _ => None,
6662 };
6663 if let Some(f) = font {
6664 return Some(f.to_string());
6665 }
6666 }
6667
6668 None
6669}
6670
6671fn resolve_run_color(
6675 rpr: &rdocx_oxml::properties::CT_RPr,
6676 theme: Option<&rdocx_oxml::theme::Theme>,
6677) -> Color {
6678 if let Some(ref theme_name) = rpr.color_theme
6680 && let Some(theme) = theme
6681 && let Some(hex) = theme.colors.get(theme_name)
6682 {
6683 return Color::from_hex(hex);
6684 }
6685
6686 rpr.color
6688 .as_ref()
6689 .filter(|c| c.as_str() != "auto")
6690 .map(|c| Color::from_hex(c))
6691 .unwrap_or(Color::BLACK)
6692}
6693
6694#[derive(Clone, Copy, PartialEq, Eq)]
6695enum WordLanguageSlot {
6696 Direct,
6697 EastAsia,
6698 Bidi,
6699}
6700
6701fn word_language_slot(character: char) -> Option<WordLanguageSlot> {
6702 match character as u32 {
6703 0x0590..=0x08ff | 0xfb1d..=0xfdff | 0xfe70..=0xfeff => Some(WordLanguageSlot::Bidi),
6704 0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff => Some(WordLanguageSlot::EastAsia),
6705 0x0041..=0x024f | 0x0900..=0x097f | 0x0e00..=0x0e7f | 0x1e00..=0x1eff => {
6706 Some(WordLanguageSlot::Direct)
6707 }
6708 _ => None,
6709 }
6710}
6711
6712fn word_language_for_slot(style: &WordMultilingualStyle, slot: WordLanguageSlot) -> Option<String> {
6713 match slot {
6714 WordLanguageSlot::Direct => style.language.clone(),
6715 WordLanguageSlot::EastAsia => style
6716 .language_east_asia
6717 .clone()
6718 .or_else(|| style.language.clone()),
6719 WordLanguageSlot::Bidi => style
6720 .language_bidi
6721 .clone()
6722 .or_else(|| style.language.clone()),
6723 }
6724}
6725
6726fn word_text_direction(rtl: Option<bool>) -> TextDirection {
6727 match rtl {
6728 Some(true) => TextDirection::RightToLeft,
6729 Some(false) => TextDirection::LeftToRight,
6730 None => TextDirection::Auto,
6731 }
6732}
6733
6734fn word_language_ranges(text: &str) -> Vec<(usize, usize, WordLanguageSlot)> {
6735 let mut ranges = Vec::new();
6736 let mut start = 0usize;
6737 let mut slot = WordLanguageSlot::Direct;
6738 for (offset, character) in text.char_indices() {
6739 let Some(next_slot) = word_language_slot(character) else {
6740 continue;
6741 };
6742 if next_slot != slot && offset > start {
6743 ranges.push((start, offset, slot));
6744 start = offset;
6745 }
6746 slot = next_slot;
6747 }
6748 if start < text.len() {
6749 ranges.push((start, text.len(), slot));
6750 }
6751 ranges
6752}
6753
6754fn word_multilingual_segment_slice(
6755 segment: &TextSegment,
6756 byte_start: usize,
6757 byte_end: usize,
6758) -> Result<TextSegment> {
6759 let mut slice = segment.clone();
6760 slice.text = segment.text[byte_start..byte_end].to_owned();
6761 if let Some(source) = segment.source {
6762 let char_start =
6763 u32::try_from(segment.text[..byte_start].chars().count()).map_err(|_| {
6764 oxml_layout::LayoutError::Layout(
6765 "Word multilingual source offset exceeds the supported range".to_owned(),
6766 )
6767 })?;
6768 let char_len = u32::try_from(slice.text.chars().count()).map_err(|_| {
6769 oxml_layout::LayoutError::Layout(
6770 "Word multilingual source length exceeds the supported range".to_owned(),
6771 )
6772 })?;
6773 slice.source = Some(SourceSpan {
6774 node: source.node,
6775 char_start: source.char_start.checked_add(char_start).ok_or_else(|| {
6776 oxml_layout::LayoutError::Layout(
6777 "Word multilingual source offset overflowed".to_owned(),
6778 )
6779 })?,
6780 char_end: source
6781 .char_start
6782 .checked_add(char_start)
6783 .and_then(|start| start.checked_add(char_len))
6784 .ok_or_else(|| {
6785 oxml_layout::LayoutError::Layout(
6786 "Word multilingual source range overflowed".to_owned(),
6787 )
6788 })?,
6789 });
6790 }
6791 slice.glyph_ids.clear();
6792 slice.advances.clear();
6793 slice.width = 0.0;
6794 Ok(slice)
6795}
6796
6797fn needs_word_multilingual_layout(text: &str) -> bool {
6798 text.chars().any(|character| {
6799 matches!(
6800 character as u32,
6801 0x0590..=0x08ff
6802 | 0x0900..=0x097f
6803 | 0x0e00..=0x0e7f
6804 | 0x3000..=0x30ff
6805 | 0x3400..=0x9fff
6806 | 0xf900..=0xfaff
6807 | 0xfb1d..=0xfdff
6808 | 0xfe70..=0xfeff
6809 )
6810 })
6811}
6812
6813fn inferred_word_base_direction(items: &[InlineItem]) -> TextDirection {
6814 for character in items
6815 .iter()
6816 .filter_map(|item| match item {
6817 InlineItem::Text(segment)
6818 | InlineItem::HyphenatedText { segment, .. }
6819 | InlineItem::Marker(segment) => Some(segment.text.as_str()),
6820 _ => None,
6821 })
6822 .flat_map(str::chars)
6823 {
6824 if character == '\u{200e}' {
6825 return TextDirection::LeftToRight;
6826 }
6827 if character == '\u{200f}' {
6828 return TextDirection::RightToLeft;
6829 }
6830 if !character.is_alphabetic() {
6831 continue;
6832 }
6833 return if matches!(
6834 character as u32,
6835 0x0590..=0x08ff | 0xfb1d..=0xfdff | 0xfe70..=0xfeff
6836 ) {
6837 TextDirection::RightToLeft
6838 } else {
6839 TextDirection::LeftToRight
6840 };
6841 }
6842 TextDirection::LeftToRight
6843}
6844
6845fn multilingual_candidate(item: &InlineItem) -> Option<&TextSegment> {
6846 match item {
6847 InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. }
6848 if !segment.text.is_empty() && segment.field_kind.is_none() =>
6849 {
6850 Some(segment)
6851 }
6852 _ => None,
6853 }
6854}
6855
6856fn apply_word_multilingual_spacing(
6857 segment: oxml_layout::MultilingualTextSegment,
6858 spacing: f64,
6859 exact_word_baseline: bool,
6860) -> Result<oxml_layout::MultilingualTextSegment> {
6861 if spacing == 0.0 && !exact_word_baseline {
6862 return Ok(segment);
6863 }
6864 let mut base = segment.base().clone();
6865 let x_advances = segment
6866 .x_advances()
6867 .iter()
6868 .map(|advance| advance + spacing)
6869 .collect::<Vec<_>>();
6870 base.advances = x_advances.clone();
6871 base.width = x_advances.iter().sum();
6872 if exact_word_baseline {
6873 base.ascent = base.font_size * WORD_EXACT_LINE_BASELINE_EM;
6874 }
6875 oxml_layout::MultilingualTextSegment::new(
6876 base,
6877 segment.logical_index(),
6878 segment.language().map(str::to_owned),
6879 segment.script(),
6880 segment.direction(),
6881 segment.bidi_level(),
6882 x_advances,
6883 segment.y_advances().to_vec(),
6884 segment.x_offsets().to_vec(),
6885 segment.y_offsets().to_vec(),
6886 segment.clusters().to_vec(),
6887 segment.break_after(),
6888 )
6889}
6890
6891fn shape_word_multilingual_items(
6892 font_manager: &mut FontManager,
6893 legacy_items: Vec<InlineItem>,
6894 styles: &HashMap<usize, WordMultilingualStyle>,
6895 base_direction: TextDirection,
6896 no_wrap: bool,
6897 exact_word_baseline: bool,
6898) -> Result<Vec<InlineItem>> {
6899 let mut styled = Vec::new();
6900 for (index, item) in legacy_items.iter().enumerate() {
6901 let Some(segment) = multilingual_candidate(item) else {
6902 continue;
6903 };
6904 if let Some(style) = styles.get(&index) {
6905 for (byte_start, byte_end, slot) in word_language_ranges(&segment.text) {
6906 let mut slice = word_multilingual_segment_slice(segment, byte_start, byte_end)?;
6907 slice.direction = style.direction;
6908 styled.push((slice, word_language_for_slot(style, slot)));
6909 }
6910 } else {
6911 let language = match item {
6912 InlineItem::HyphenatedText { language, .. } => Some(language.clone()),
6913 _ => None,
6914 };
6915 styled.push((segment.clone(), language));
6916 }
6917 }
6918 let mut shaped = VecDeque::from(font_manager.shape_multilingual_paragraph(
6919 styled,
6920 base_direction,
6921 no_wrap,
6922 )?);
6923 let mut items = Vec::with_capacity(legacy_items.len());
6924 for (index, mut item) in legacy_items.into_iter().enumerate() {
6925 let Some(segment) = multilingual_candidate(&item) else {
6926 if exact_word_baseline {
6927 match &mut item {
6928 InlineItem::Text(segment)
6929 | InlineItem::Marker(segment)
6930 | InlineItem::HyphenatedText { segment, .. } => {
6931 segment.ascent = segment.font_size * WORD_EXACT_LINE_BASELINE_EM;
6932 }
6933 _ => {}
6934 }
6935 }
6936 items.push(item);
6937 continue;
6938 };
6939 let target_bytes = segment.text.len();
6940 let mut consumed_bytes = 0usize;
6941 while consumed_bytes < target_bytes {
6942 let span = shaped.pop_front().ok_or_else(|| {
6943 oxml_layout::LayoutError::Layout(
6944 "multilingual Word shaping lost a styled text run".to_owned(),
6945 )
6946 })?;
6947 consumed_bytes = consumed_bytes
6948 .checked_add(span.text().len())
6949 .filter(|consumed| *consumed <= target_bytes)
6950 .ok_or_else(|| {
6951 oxml_layout::LayoutError::Layout(
6952 "multilingual Word shaping crossed a styled text boundary".to_owned(),
6953 )
6954 })?;
6955 let spacing = styles.get(&index).map_or(0.0, |style| style.spacing);
6956 let span = apply_word_multilingual_spacing(span, spacing, exact_word_baseline)?;
6957 if let InlineItem::HyphenatedText { language, .. } = &item
6958 && !needs_word_multilingual_layout(span.text())
6959 {
6960 items.push(InlineItem::HyphenatedText {
6961 segment: span.base().clone(),
6962 language: language.clone(),
6963 });
6964 } else {
6965 items.push(InlineItem::MultilingualText(span));
6966 }
6967 }
6968 }
6969 if !shaped.is_empty() {
6970 return Err(oxml_layout::LayoutError::Layout(
6971 "multilingual Word shaping produced an unmatched text run".to_owned(),
6972 ));
6973 }
6974 Ok(items)
6975}
6976
6977fn highlight_to_color(h: ST_HighlightColor) -> Option<Color> {
6979 match h {
6980 ST_HighlightColor::None => None,
6981 ST_HighlightColor::Black => Some(Color {
6982 r: 0.0,
6983 g: 0.0,
6984 b: 0.0,
6985 a: 1.0,
6986 }),
6987 ST_HighlightColor::Blue => Some(Color {
6988 r: 0.0,
6989 g: 0.0,
6990 b: 1.0,
6991 a: 1.0,
6992 }),
6993 ST_HighlightColor::Cyan => Some(Color {
6994 r: 0.0,
6995 g: 1.0,
6996 b: 1.0,
6997 a: 1.0,
6998 }),
6999 ST_HighlightColor::DarkBlue => Some(Color {
7000 r: 0.0,
7001 g: 0.0,
7002 b: 0.545,
7003 a: 1.0,
7004 }),
7005 ST_HighlightColor::DarkCyan => Some(Color {
7006 r: 0.0,
7007 g: 0.545,
7008 b: 0.545,
7009 a: 1.0,
7010 }),
7011 ST_HighlightColor::DarkGray => Some(Color {
7012 r: 0.663,
7013 g: 0.663,
7014 b: 0.663,
7015 a: 1.0,
7016 }),
7017 ST_HighlightColor::DarkGreen => Some(Color {
7018 r: 0.0,
7019 g: 0.392,
7020 b: 0.0,
7021 a: 1.0,
7022 }),
7023 ST_HighlightColor::DarkMagenta => Some(Color {
7024 r: 0.545,
7025 g: 0.0,
7026 b: 0.545,
7027 a: 1.0,
7028 }),
7029 ST_HighlightColor::DarkRed => Some(Color {
7030 r: 0.545,
7031 g: 0.0,
7032 b: 0.0,
7033 a: 1.0,
7034 }),
7035 ST_HighlightColor::DarkYellow => Some(Color {
7036 r: 0.545,
7037 g: 0.545,
7038 b: 0.0,
7039 a: 1.0,
7040 }),
7041 ST_HighlightColor::Green => Some(Color {
7042 r: 0.0,
7043 g: 1.0,
7044 b: 0.0,
7045 a: 1.0,
7046 }),
7047 ST_HighlightColor::LightGray => Some(Color {
7048 r: 0.827,
7049 g: 0.827,
7050 b: 0.827,
7051 a: 1.0,
7052 }),
7053 ST_HighlightColor::Magenta => Some(Color {
7054 r: 1.0,
7055 g: 0.0,
7056 b: 1.0,
7057 a: 1.0,
7058 }),
7059 ST_HighlightColor::Red => Some(Color {
7060 r: 1.0,
7061 g: 0.0,
7062 b: 0.0,
7063 a: 1.0,
7064 }),
7065 ST_HighlightColor::White => Some(Color {
7066 r: 1.0,
7067 g: 1.0,
7068 b: 1.0,
7069 a: 1.0,
7070 }),
7071 ST_HighlightColor::Yellow => Some(Color {
7072 r: 1.0,
7073 g: 1.0,
7074 b: 0.0,
7075 a: 1.0,
7076 }),
7077 }
7078}
7079
7080#[cfg(test)]
7081mod tests {
7082 use super::*;
7083 use crate::input::ImageData;
7084 use oxml_layout::{MediaId, MultilingualGlyphRun, TextScript};
7085 use std::collections::HashMap;
7086
7087 const LEGACY_RESTART_CACHE_MAX_BYTES: usize = 8 * 1024 * 1024;
7088
7089 fn assert_restart_cache_within_aggregate(engine: &Engine) {
7090 let restart = engine
7091 .restart_cache
7092 .as_ref()
7093 .expect("restart state retained");
7094 let entries = engine
7095 .paragraph_cache
7096 .len()
7097 .checked_add(engine.table_cache.len())
7098 .and_then(|entries| entries.checked_add(engine.header_footer_cache.len()))
7099 .and_then(|entries| entries.checked_add(restart_cache_entries(restart)))
7100 .expect("retained cache entry accounting does not overflow");
7101 let bytes = engine
7102 .paragraph_cache_bytes
7103 .checked_add(engine.table_cache_bytes)
7104 .and_then(|bytes| bytes.checked_add(engine.header_footer_cache_bytes))
7105 .and_then(|bytes| bytes.checked_add(restart.bytes))
7106 .expect("retained cache byte accounting does not overflow");
7107 assert!(entries <= CACHE_MAX_ENTRIES, "{entries}");
7108 assert!(bytes <= CACHE_MAX_BYTES, "{bytes}");
7109 }
7110
7111 fn compatibility_elements(elements: &[PositionedElement]) -> Vec<&PositionedElement> {
7112 fn collect<'a>(
7113 elements: &'a [PositionedElement],
7114 flattened: &mut Vec<&'a PositionedElement>,
7115 ) {
7116 for element in elements {
7117 match element {
7118 PositionedElement::MarkedContent { children, .. } => {
7119 collect(children, flattened);
7120 }
7121 element => flattened.push(element),
7122 }
7123 }
7124 }
7125
7126 let mut flattened = Vec::new();
7127 collect(elements, &mut flattened);
7128 flattened
7129 }
7130
7131 fn compatibility_page_elements(page: &PageFrame) -> Vec<&PositionedElement> {
7132 compatibility_elements(&page.elements)
7133 }
7134
7135 fn multilingual_runs(layout: &LayoutResult) -> Vec<&MultilingualGlyphRun> {
7136 layout
7137 .pages
7138 .iter()
7139 .flat_map(|page| compatibility_page_elements(page))
7140 .filter_map(|element| match element {
7141 PositionedElement::MultilingualText(run) => Some(run),
7142 _ => None,
7143 })
7144 .collect()
7145 }
7146
7147 #[test]
7148 fn revision_views_project_wrapped_runs_in_document_order() {
7149 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7150 <w:r><w:t>A</w:t></w:r>
7151 <w:ins w:id="1" w:author="Ada"><w:r><w:t>I1</w:t></w:r><w:del w:id="2" w:author="Ben"><w:r><w:delText>D</w:delText></w:r></w:del><w:r><w:t>I2</w:t></w:r></w:ins>
7152 <w:del w:id="3" w:author="Cy"><w:r><w:delText>X</w:delText></w:r></w:del>
7153 <w:moveFrom w:id="4" w:author="Dee"><w:r><w:t>F</w:t></w:r></w:moveFrom>
7154 <w:moveTo w:id="5" w:author="Eve"><w:r><w:t>T</w:t></w:r></w:moveTo>
7155 <w:r><w:t>Z</w:t></w:r>
7156 </w:p></w:body></w:document>"#;
7157 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7158 let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7159 panic!("expected paragraph");
7160 };
7161
7162 let accepted = project_paragraph_runs(paragraph, RevisionView::Accepted)
7163 .iter()
7164 .map(|projected| projected.run.text())
7165 .collect::<Vec<_>>();
7166 assert_eq!(accepted, ["A", "I1", "I2", "T", "Z"]);
7167
7168 let tracked = project_paragraph_runs(paragraph, RevisionView::Tracked)
7169 .iter()
7170 .map(|projected| projected.run.text())
7171 .collect::<Vec<_>>();
7172 assert_eq!(tracked, ["A", "I1", "D", "I2", "X", "F", "T", "Z"]);
7173 }
7174
7175 #[test]
7176 fn nested_only_revision_wrappers_project_their_visible_runs() {
7177 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7178 <w:ins w:id="1" w:author="Ada"><w:moveTo w:id="2" w:author="Ben"><w:r><w:t>nested</w:t></w:r></w:moveTo></w:ins>
7179 </w:p></w:body></w:document>"#;
7180 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7181 let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7182 panic!("expected paragraph");
7183 };
7184 for view in [RevisionView::Accepted, RevisionView::Tracked] {
7185 assert_eq!(projected_paragraph_text(paragraph, view), "nested");
7186 }
7187 assert!(paragraph_has_visible_revision(paragraph));
7188 }
7189
7190 #[test]
7191 fn pageref_target_follows_an_earlier_revision_at_the_same_boundary() {
7192 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7193 <w:ins w:id="1" w:author="Ada"><w:r><w:t>before</w:t></w:r></w:ins>
7194 <w:bookmarkStart w:id="7" w:name="target"/>
7195 <w:fldSimple w:instr=" PAGEREF target "><w:r><w:t>1</w:t></w:r></w:fldSimple>
7196 <w:bookmarkEnd w:id="7"/>
7197 </w:p></w:body></w:document>"#;
7198 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7199 let mut input = make_input_with_text("");
7200 input.document = document;
7201 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
7202 panic!("expected paragraph");
7203 };
7204 let media = MediaRegistry::new(&input.images);
7205 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7206 let mut numbering = NumberingState::new();
7207 let mut diagnostics = Vec::new();
7208 let block = layout_paragraph(
7209 paragraph,
7210 468.0,
7211 &input.styles,
7212 &input,
7213 &media,
7214 &mut fonts,
7215 &mut numbering,
7216 &mut diagnostics,
7217 )
7218 .expect("paragraph lays out");
7219 let items = &block.reflow.expect("reflow items retained").items;
7220 let revision_index = items
7221 .iter()
7222 .position(|item| matches!(item, InlineItem::Text(text) if text.text == "before"))
7223 .expect("revision text");
7224 let target_index = items
7225 .iter()
7226 .position(|item| {
7227 matches!(item, InlineItem::Text(text) if matches!(text.field_kind, Some(FieldKind::Target(_))))
7228 })
7229 .expect("PAGEREF target");
7230 assert!(revision_index < target_index);
7231 }
7232
7233 #[test]
7234 fn derived_revision_text_uses_the_selected_projection() {
7235 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7236 <w:bookmarkStart w:id="7" w:name="target"/>
7237 <w:ins w:id="1" w:author="Ada"><w:r><w:t>new</w:t></w:r></w:ins>
7238 <w:del w:id="2" w:author="Ben"><w:r><w:delText>old</w:delText></w:r></w:del>
7239 <w:bookmarkEnd w:id="7"/>
7240 </w:p></w:body></w:document>"#;
7241 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7242 let mut input = make_input_with_text("");
7243 input.document = document;
7244
7245 assert_eq!(bookmark_text(&input, "target").as_deref(), Some("new"));
7246 input.revision_view = RevisionView::Tracked;
7247 assert_eq!(bookmark_text(&input, "target").as_deref(), Some("newold"));
7248 }
7249
7250 #[test]
7251 fn bookmark_after_a_terminal_hyperlink_revision_excludes_that_revision() {
7252 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p>
7253 <w:hyperlink r:id="rId1"><w:r><w:t>link</w:t></w:r><w:ins w:id="1" w:author="Ada"><w:r><w:t>before bookmark</w:t></w:r></w:ins></w:hyperlink>
7254 <w:bookmarkStart w:id="7" w:name="target"/><w:r><w:t>inside</w:t></w:r><w:bookmarkEnd w:id="7"/>
7255 </w:p></w:body></w:document>"#;
7256 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7257 let mut input = make_input_with_text("");
7258 input.document = document;
7259
7260 assert_eq!(bookmark_text(&input, "target").as_deref(), Some("inside"));
7261 }
7262
7263 #[test]
7264 fn revision_only_hyperlink_keeps_its_link_annotation() {
7265 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p>
7266 <w:hyperlink r:id="rId1"><w:ins w:id="1" w:author="Ada"><w:r><w:t>linked revision</w:t></w:r></w:ins></w:hyperlink>
7267 </w:p></w:body></w:document>"#;
7268 let mut document =
7269 rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7270 let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
7271 panic!("expected paragraph");
7272 };
7273 paragraph.hyperlinks[0].rel_id = Some("rId2".to_owned());
7274 let serialized = String::from_utf8(document.to_xml().expect("document serializes"))
7275 .expect("document XML is UTF-8");
7276 assert!(serialized.contains("r:id=\"rId2\""), "{serialized}");
7277 assert!(!serialized.contains("r:id=\"rId1\""), "{serialized}");
7278 let mut input = make_input_with_text("");
7279 input.document = document;
7280 input
7281 .hyperlink_urls
7282 .insert("rId2".to_owned(), "https://example.com".to_owned());
7283
7284 let output = Engine::new_deterministic()
7285 .expect("bundled fonts load")
7286 .layout(&input)
7287 .expect("revision hyperlink lays out");
7288 assert!(
7289 compatibility_page_elements(&output.pages[0])
7290 .into_iter()
7291 .any(|element| {
7292 matches!(element, PositionedElement::LinkAnnotation { url, .. }
7293 if url == "https://example.com")
7294 })
7295 );
7296 }
7297
7298 #[test]
7299 fn derived_revision_text_keeps_order_after_comment_run_removal() {
7300 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7301 <w:bookmarkStart w:id="7" w:name="target"/>
7302 <w:commentRangeStart w:id="5"/><w:r><w:commentReference w:id="5"/></w:r>
7303 <w:ins w:id="1" w:author="Ada"><w:r><w:t>inside</w:t></w:r></w:ins>
7304 <w:bookmarkEnd w:id="7"/>
7305 </w:p></w:body></w:document>"#;
7306 let mut document =
7307 rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7308 let BodyContent::Paragraph(paragraph) = &mut document.body.content[0] else {
7309 panic!("expected paragraph");
7310 };
7311 paragraph.remove_comment_anchors(&[5]);
7312 let mut input = make_input_with_text("");
7313 input.document = document;
7314
7315 assert_eq!(bookmark_text(&input, "target").as_deref(), Some("inside"));
7316 }
7317
7318 #[test]
7319 fn heading_text_uses_the_selected_revision_projection() {
7320 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7321 <w:ins w:id="1" w:author="Ada"><w:r><w:t>new</w:t></w:r></w:ins>
7322 <w:del w:id="2" w:author="Ben"><w:r><w:delText>old</w:delText></w:r></w:del>
7323 </w:p></w:body></w:document>"#;
7324 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7325 let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7326 panic!("expected paragraph");
7327 };
7328 assert_eq!(
7329 projected_paragraph_text(paragraph, RevisionView::Accepted),
7330 "new"
7331 );
7332 assert_eq!(
7333 projected_paragraph_text(paragraph, RevisionView::Tracked),
7334 "newold"
7335 );
7336 }
7337
7338 #[test]
7339 fn revised_floating_anchors_follow_the_selected_projection() {
7340 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
7341 xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
7342 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
7343 xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><w:body><w:p>
7344 <w:ins w:id="1" w:author="Ada"><w:r><w:drawing><wp:anchor behindDoc="0">
7345 <wp:positionH relativeFrom="margin"><wp:align>right</wp:align></wp:positionH>
7346 <wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>
7347 <wp:extent cx="914400" cy="457200"/><wp:wrapSquare wrapText="bothSides"/>
7348 <a:graphic><a:graphicData><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic>
7349 </wp:anchor></w:drawing></w:r></w:ins>
7350 </w:p></w:body></w:document>"#;
7351 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7352 let mut input = make_input_with_text("");
7353 input.document = document;
7354 assert!(document_has_wrapping_drawing(&input));
7355
7356 input.revision_view = RevisionView::Tracked;
7357 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
7358 panic!("expected paragraph");
7359 };
7360 let media = MediaRegistry::new(&input.images);
7361 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7362 let mut numbering = NumberingState::new();
7363 let mut diagnostics = Vec::new();
7364 let anchored = collect_anchored_drawings(
7365 paragraph,
7366 &input.styles,
7367 &input,
7368 &media,
7369 &mut fonts,
7370 &mut numbering,
7371 &mut diagnostics,
7372 )
7373 .expect("tracked anchor collection succeeds");
7374 assert_eq!(anchored.len(), 1);
7375 }
7376
7377 #[test]
7378 fn tracked_revision_decorations_override_only_underline_and_strike() {
7379 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>
7380 <w:ins w:id="1" w:author="Ada"><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans"/><w:b/><w:i/><w:u w:val="double"/><w:dstrike/><w:color w:val="AA0000"/><w:highlight w:val="yellow"/></w:rPr><w:t>inserted</w:t></w:r></w:ins>
7381 <w:del w:id="2" w:author="Ben"><w:r><w:rPr><w:u w:val="double"/><w:color w:val="0000AA"/></w:rPr><w:delText>deleted</w:delText></w:r></w:del>
7382 <w:ins w:id="3" w:author="Ada"><w:r><w:rPr><w:highlight w:val="yellow"/></w:rPr><w:footnoteReference w:id="11"/></w:r></w:ins>
7383 <w:del w:id="4" w:author="Ben"><w:r><w:endnoteReference w:id="12"/></w:r></w:del>
7384 </w:p></w:body></w:document>"#;
7385 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7386 let mut input = make_input_with_text("");
7387 input.document = document;
7388 input.revision_view = RevisionView::Tracked;
7389 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
7390 panic!("expected paragraph");
7391 };
7392 let media = MediaRegistry::new(&input.images);
7393 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7394 let mut numbering = NumberingState::new();
7395 let mut diagnostics = Vec::new();
7396 let block = layout_paragraph(
7397 paragraph,
7398 468.0,
7399 &input.styles,
7400 &input,
7401 &media,
7402 &mut fonts,
7403 &mut numbering,
7404 &mut diagnostics,
7405 )
7406 .expect("tracked paragraph lays out");
7407 let segments = block
7408 .lines
7409 .iter()
7410 .flat_map(|line| &line.items)
7411 .filter_map(|item| match item {
7412 oxml_layout::LineItem::Text(segment) => Some(segment),
7413 _ => None,
7414 })
7415 .collect::<Vec<_>>();
7416 let inserted = segments
7417 .iter()
7418 .find(|segment| segment.text == "inserted")
7419 .expect("inserted segment");
7420 assert_eq!(inserted.underline, Some(Underline::Single));
7421 assert!(!inserted.strike);
7422 assert!(inserted.dstrike);
7423 assert!(inserted.bold && inserted.italic);
7424 assert_eq!(inserted.color, Color::from_hex("AA0000"));
7425 assert_eq!(inserted.highlight, Some(Color::from_hex("FFFF00")));
7426
7427 let deleted = segments
7428 .iter()
7429 .find(|segment| segment.text == "deleted")
7430 .expect("deleted segment");
7431 assert_eq!(deleted.underline, Some(Underline::Double));
7432 assert!(deleted.strike);
7433 assert_eq!(deleted.color, Color::from_hex("0000AA"));
7434
7435 let inserted_note = segments
7436 .iter()
7437 .find(|segment| segment.text == "11")
7438 .expect("inserted note marker");
7439 assert_eq!(inserted_note.underline, Some(Underline::Single));
7440 assert_eq!(inserted_note.highlight, Some(Color::from_hex("FFFF00")));
7441 let deleted_note = segments
7442 .iter()
7443 .find(|segment| segment.text == "12")
7444 .expect("deleted note marker");
7445 assert!(deleted_note.strike);
7446
7447 let mut accepted_input = input.clone();
7448 accepted_input.revision_view = RevisionView::Accepted;
7449 let BodyContent::Paragraph(accepted_paragraph) = &accepted_input.document.body.content[0]
7450 else {
7451 panic!("expected paragraph");
7452 };
7453 let accepted_media = MediaRegistry::new(&accepted_input.images);
7454 let accepted_block = layout_paragraph(
7455 accepted_paragraph,
7456 468.0,
7457 &accepted_input.styles,
7458 &accepted_input,
7459 &accepted_media,
7460 &mut fonts,
7461 &mut numbering,
7462 &mut diagnostics,
7463 )
7464 .expect("accepted paragraph lays out");
7465 let accepted_note = accepted_block
7466 .lines
7467 .iter()
7468 .flat_map(|line| &line.items)
7469 .filter_map(|item| match item {
7470 oxml_layout::LineItem::Text(segment) if segment.text == "11" => Some(segment),
7471 _ => None,
7472 })
7473 .next()
7474 .expect("accepted note marker");
7475 assert_eq!(accepted_note.underline, None);
7476 assert!(!accepted_note.strike && !accepted_note.dstrike);
7477 assert_eq!(accepted_note.highlight, None);
7478 }
7479
7480 #[test]
7481 fn a_split_changed_paragraph_draws_one_margin_bar_on_each_page() {
7482 let changed = "changed ".repeat(3_000);
7483 let xml = format!(
7484 r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:ins w:id="1" w:author="Ada"><w:r><w:t xml:space="preserve">{changed}</w:t></w:r></w:ins></w:p></w:body></w:document>"#
7485 );
7486 let document =
7487 rdocx_oxml::CT_Document::from_xml(xml.as_bytes()).expect("revision document parses");
7488 let mut input = make_input_with_text("");
7489 input.document = document;
7490 let accepted = Engine::new_deterministic()
7491 .expect("bundled fonts load")
7492 .layout(&input)
7493 .expect("accepted document lays out");
7494 input.revision_view = RevisionView::Tracked;
7495 let output = Engine::new_deterministic()
7496 .expect("bundled fonts load")
7497 .layout(&input)
7498 .expect("tracked document lays out");
7499 let geometry = PageGeometry::default();
7500 assert!(output.pages.len() > 1);
7501 assert_eq!(accepted.pages.len(), output.pages.len());
7502 for (accepted_page, page) in accepted.pages.iter().zip(&output.pages) {
7503 let accepted_text = compatibility_page_elements(accepted_page)
7504 .into_iter()
7505 .filter_map(|element| match element {
7506 PositionedElement::Text(text) => Some(text),
7507 _ => None,
7508 })
7509 .collect::<Vec<_>>();
7510 let tracked_text = compatibility_page_elements(page)
7511 .into_iter()
7512 .filter_map(|element| match element {
7513 PositionedElement::Text(text) => Some(text),
7514 _ => None,
7515 })
7516 .collect::<Vec<_>>();
7517 assert_eq!(accepted_text, tracked_text);
7518 let bars = compatibility_page_elements(page)
7519 .into_iter()
7520 .filter_map(|element| match element {
7521 PositionedElement::Line {
7522 start,
7523 end,
7524 width,
7525 dash_pattern,
7526 ..
7527 } if (*width - 1.5).abs() < f64::EPSILON
7528 && dash_pattern.is_none()
7529 && start.x == end.x
7530 && (start.x < geometry.margin_left
7531 || start.x > geometry.page_width - geometry.margin_right) =>
7532 {
7533 Some((*start, *end))
7534 }
7535 _ => None,
7536 })
7537 .collect::<Vec<_>>();
7538 assert_eq!(bars.len(), 1, "page {}", page.page_number);
7539 let (start, end) = bars[0];
7540 assert!(start.x.is_finite() && start.y.is_finite() && end.y.is_finite());
7541 assert!(end.y > start.y);
7542 if page.page_number.is_multiple_of(2) {
7543 assert!(start.x < geometry.margin_left);
7544 } else {
7545 assert!(start.x > geometry.page_width - geometry.margin_right);
7546 }
7547 }
7548 }
7549
7550 fn page_change_bar_count(page: &PageFrame) -> usize {
7551 let geometry = PageGeometry::default();
7552 compatibility_page_elements(page)
7553 .into_iter()
7554 .filter(|element| {
7555 matches!(element, PositionedElement::Line { start, end, width, .. }
7556 if (*width - 1.5).abs() < f64::EPSILON
7557 && start.x == end.x
7558 && (start.x < geometry.margin_left
7559 || start.x > geometry.page_width - geometry.margin_right))
7560 })
7561 .count()
7562 }
7563
7564 #[test]
7565 fn tracked_header_paragraph_draws_a_change_bar() {
7566 use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
7567
7568 let mut input = make_input_with_text("body");
7569 input.revision_view = RevisionView::Tracked;
7570 input.document.body.sect_pr = Some(CT_SectPr::default_letter());
7571 input
7572 .document
7573 .body
7574 .sect_pr
7575 .as_mut()
7576 .expect("section properties")
7577 .header_refs
7578 .push(HdrFtrRef {
7579 hdr_ftr_type: HdrFtrType::Default,
7580 rel_id: "rIdHeader".to_owned(),
7581 });
7582 let header = CT_HdrFtr::from_xml(
7583 br#"<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:ins w:id="1" w:author="Ada"><w:r><w:t>changed header</w:t></w:r></w:ins></w:p></w:hdr>"#,
7584 )
7585 .expect("header parses");
7586 input.headers.insert("rIdHeader".to_owned(), header);
7587
7588 let output = Engine::new_deterministic()
7589 .expect("bundled fonts load")
7590 .layout(&input)
7591 .expect("tracked header lays out");
7592 assert_eq!(page_change_bar_count(&output.pages[0]), 1);
7593 }
7594
7595 #[test]
7596 fn tracked_note_paragraph_draws_a_change_bar() {
7597 let mut input = make_input_with_footnote(&["plain"]);
7598 input.revision_view = RevisionView::Tracked;
7599 let changed_note = rdocx_oxml::CT_Document::from_xml(
7600 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:ins w:id="1" w:author="Ada"><w:r><w:t>added note</w:t></w:r></w:ins><w:del w:id="2" w:author="Ben"><w:r><w:delText>removed note</w:delText></w:r></w:del></w:p></w:body></w:document>"#,
7601 )
7602 .expect("note paragraph parses");
7603 let BodyContent::Paragraph(paragraph) = &changed_note.body.content[0] else {
7604 panic!("expected paragraph");
7605 };
7606 input.footnotes.as_mut().expect("footnote stream").footnotes[0].paragraphs =
7607 vec![paragraph.clone()];
7608
7609 let output = Engine::new_deterministic()
7610 .expect("bundled fonts load")
7611 .layout(&input)
7612 .expect("tracked note lays out");
7613 assert_eq!(page_change_bar_count(&output.pages[0]), 1);
7614 let decoration_widths = compatibility_page_elements(&output.pages[0])
7615 .into_iter()
7616 .filter_map(|element| match element {
7617 PositionedElement::Line {
7618 start, end, width, ..
7619 } if start.y == end.y && (*width - 0.5).abs() > f64::EPSILON => Some(*width),
7620 _ => None,
7621 })
7622 .collect::<Vec<_>>();
7623 assert!(
7624 decoration_widths
7625 .iter()
7626 .any(|width| (*width - 11.0 / 18.0).abs() < 0.001),
7627 "tracked insertion underline missing: {decoration_widths:?}"
7628 );
7629 assert!(
7630 decoration_widths
7631 .iter()
7632 .any(|width| (*width - 11.0 / 24.0).abs() < 0.001),
7633 "tracked deletion strike missing: {decoration_widths:?}"
7634 );
7635 }
7636
7637 #[test]
7638 fn property_only_revisions_mark_the_tracked_paragraph() {
7639 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:pPrChange w:id="1" w:author="Ada"><w:pPr><w:jc w:val="right"/></w:pPr></w:pPrChange></w:pPr><w:r><w:t>current</w:t></w:r></w:p></w:body></w:document>"#;
7640 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7641 let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
7642 panic!("expected paragraph");
7643 };
7644 assert!(paragraph_has_visible_revision(paragraph));
7645 }
7646
7647 #[test]
7648 fn empty_revision_wrappers_do_not_mark_the_tracked_paragraph() {
7649 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:ins w:id="1" w:author="Ada"/></w:p><w:p><w:ins w:id="2" w:author="Ben"><w:r><w:t/></w:r></w:ins></w:p></w:body></w:document>"#;
7650 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision document parses");
7651 for content in &document.body.content {
7652 let BodyContent::Paragraph(paragraph) = content else {
7653 panic!("expected paragraph");
7654 };
7655 assert!(!paragraph_has_visible_revision(paragraph));
7656 }
7657 }
7658
7659 fn make_input_with_text(text: &str) -> LayoutInput {
7660 let mut doc = rdocx_oxml::document::CT_Document::new();
7661 let mut p = CT_P::new();
7662 p.add_run(text);
7663 doc.body.add_paragraph(p);
7664
7665 LayoutInput {
7666 revision_view: crate::input::RevisionView::Accepted,
7667 automatic_hyphenation: false,
7668 document: doc,
7669 styles: CT_Styles::new_default(),
7670 numbering: None,
7671 headers: HashMap::new(),
7672 footers: HashMap::new(),
7673 images: HashMap::new(),
7674 charts: HashMap::new(),
7675 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
7676 chart_color_map: oxml_drawing::color::ColorMap::default(),
7677 core_properties: None,
7678 hyperlink_urls: HashMap::new(),
7679 footnotes: None,
7680 endnotes: None,
7681 theme: None,
7682 fonts: Vec::new(),
7683 }
7684 }
7685
7686 fn five_large_caller_fonts() -> Vec<oxml_layout::FontFile> {
7687 const TOTAL_BYTES: usize = 22 * 1024 * 1024;
7688 let bundled = oxml_layout::bundled_fonts::bundled_font_data();
7689 [0, 4, 8, 12, 16]
7690 .into_iter()
7691 .enumerate()
7692 .map(|(index, bundled_index)| {
7693 let (family, source) = bundled[bundled_index];
7694 let mut data = source.to_vec();
7695 let target = TOTAL_BYTES / 5 + usize::from(index < TOTAL_BYTES % 5);
7696 data.resize(
7697 target,
7698 u8::try_from(index).expect("five font indices fit in u8"),
7699 );
7700 oxml_layout::FontFile {
7701 family: family.to_owned(),
7702 data,
7703 }
7704 })
7705 .collect()
7706 }
7707
7708 #[test]
7709 fn warm_layout_does_not_repeat_retained_context_font_byte_equality() {
7710 let mut input = make_input_with_text("warm caller-font comparison");
7711 input.fonts = five_large_caller_fonts();
7712 let aliases = (0..40)
7713 .map(|index| {
7714 (
7715 format!("Editor Family {index}"),
7716 input.fonts[index % input.fonts.len()].family.clone(),
7717 )
7718 })
7719 .collect::<Vec<_>>();
7720 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
7721 engine.set_caller_font_aliases(&aliases);
7722 engine.layout(&input).expect("prime caller-font layout");
7723
7724 reset_retained_context_font_bytes_compared();
7725 engine.layout(&input).expect("warm caller-font layout");
7726
7727 assert_eq!(retained_context_font_bytes_compared(), 0);
7728 }
7729
7730 #[test]
7731 fn same_length_changed_font_bytes_still_invalidate_reusable_work() {
7732 let mut input = make_input_with_text("changed caller-font bytes");
7733 input.fonts = five_large_caller_fonts();
7734 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
7735 engine.layout(&input).expect("prime caller-font layout");
7736 assert_eq!(engine.paragraph_cache_counts(), (0, 1));
7737
7738 let last = input.fonts[0]
7739 .data
7740 .last_mut()
7741 .expect("generated font has padding");
7742 *last ^= 1;
7743 let warm = engine.layout(&input).expect("changed-font layout");
7744 let fresh = Engine::new_deterministic()
7745 .expect("bundled fonts load")
7746 .layout(&input)
7747 .expect("fresh changed-font layout");
7748
7749 assert_eq!(engine.paragraph_cache_counts(), (0, 2));
7750 assert_layout_results_equal(&warm, &fresh);
7751 }
7752
7753 #[test]
7754 fn checked_transfer_keeps_exact_ordered_caller_font_bytes() {
7755 let mut input = make_input_with_text("checked caller-font transfer");
7756 input.fonts = five_large_caller_fonts();
7757 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
7758 engine.layout(&input).expect("prime caller-font layout");
7759 let mut source = Some(engine);
7760
7761 let last = input.fonts[0]
7762 .data
7763 .last_mut()
7764 .expect("generated font has padding");
7765 *last ^= 1;
7766
7767 reset_retained_context_font_bytes_compared();
7768 assert!(Engine::take_if_compatible(&mut source, &input).is_none());
7769 assert!(source.is_some(), "rejected transfer preserves its source");
7770 assert_eq!(retained_context_font_bytes_compared(), 22 * 1024 * 1024);
7771 }
7772
7773 fn hyphenation_input(enabled: bool, language: Option<&str>, suppressed: bool) -> LayoutInput {
7774 let mut input = make_input_with_text("representation");
7775 input.automatic_hyphenation = enabled;
7776 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7777 panic!("expected paragraph")
7778 };
7779 paragraph.properties = Some(CT_PPr {
7780 ind_right: Some(rdocx_oxml::units::Twips(8_500)),
7781 suppress_auto_hyphens: suppressed.then_some(true),
7782 ..Default::default()
7783 });
7784 paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
7785 language: language.map(str::to_owned),
7786 ..Default::default()
7787 });
7788 input
7789 }
7790
7791 #[test]
7792 fn document_enablement_language_and_paragraph_suppression_gate_hyphenation() {
7793 let enabled = output_text(&deterministic_layout(&hyphenation_input(
7794 true,
7795 Some("en-US"),
7796 false,
7797 )))
7798 .concat();
7799 assert_eq!(enabled, "repre-sentation");
7800
7801 for input in [
7802 hyphenation_input(false, Some("en-US"), false),
7803 hyphenation_input(true, None, false),
7804 hyphenation_input(true, Some("it-IT"), false),
7805 hyphenation_input(true, Some("en-US"), true),
7806 ] {
7807 let text = output_text(&deterministic_layout(&input)).concat();
7808 assert_eq!(text, "representation");
7809 }
7810 }
7811
7812 #[test]
7813 fn rtl_first_rich_paragraph_keeps_hyphenatable_english_in_visual_order() {
7814 let mut input = make_input_with_text("");
7815 input.automatic_hyphenation = true;
7816 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7817 panic!("expected paragraph")
7818 };
7819 let mut arabic = CT_R::new("العربية");
7820 arabic.properties = Some(rdocx_oxml::properties::CT_RPr {
7821 language: Some("ar-SA".to_owned()),
7822 language_bidi: Some("ar-SA".to_owned()),
7823 ..Default::default()
7824 });
7825 let mut english = CT_R::new(" representation");
7826 english.properties = Some(rdocx_oxml::properties::CT_RPr {
7827 language: Some("en-US".to_owned()),
7828 ..Default::default()
7829 });
7830 paragraph.runs = vec![arabic, english];
7831
7832 let output = crate::layout_document_deterministic_with_provenance(&input)
7833 .expect("hybrid bidi layout with sources")
7834 .layout;
7835 let arabic_x = multilingual_runs(&output)
7836 .into_iter()
7837 .find(|run| run.logical_text == "العربية")
7838 .expect("Arabic run uses rich shaping")
7839 .origin
7840 .x;
7841 let english_x = output
7842 .pages
7843 .iter()
7844 .flat_map(|page| compatibility_page_elements(page))
7845 .find_map(|element| match element {
7846 PositionedElement::Text(run) if run.text.contains("representation") => {
7847 Some(run.origin.x)
7848 }
7849 _ => None,
7850 })
7851 .expect("hyphenatable English run stays in the line");
7852 assert!(
7853 english_x < arabic_x,
7854 "RTL paragraph paints English left of Arabic: English {english_x}, Arabic {arabic_x}"
7855 );
7856 let extraction_order = output
7857 .pages
7858 .iter()
7859 .flat_map(|page| compatibility_page_elements(page))
7860 .filter_map(|element| match element {
7861 PositionedElement::Text(run) if run.text.contains("representation") => {
7862 Some(run.text.as_str())
7863 }
7864 PositionedElement::MultilingualText(run)
7865 if run.logical_text.contains("العربية") =>
7866 {
7867 Some(run.logical_text.as_str())
7868 }
7869 _ => None,
7870 })
7871 .collect::<Vec<_>>();
7872 assert_eq!(extraction_order, ["العربية", "representation"]);
7873 }
7874
7875 #[test]
7876 fn explicit_rtl_hyphenatable_latin_spans_keep_resolved_even_level_order() {
7877 let mut input = make_input_with_text("ABC 123");
7878 input.automatic_hyphenation = true;
7879 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7880 panic!("expected paragraph")
7881 };
7882 paragraph.properties = Some(CT_PPr {
7883 bidi: Some(false),
7884 ..Default::default()
7885 });
7886 paragraph.runs[0].properties = Some(CT_RPr {
7887 rtl: Some(true),
7888 language: Some("en-US".to_owned()),
7889 ..Default::default()
7890 });
7891
7892 let output = deterministic_layout(&input);
7893 let positions = output
7894 .pages
7895 .iter()
7896 .flat_map(|page| compatibility_page_elements(page))
7897 .filter_map(|element| match element {
7898 PositionedElement::Text(run) if run.text.contains("ABC") => {
7899 Some(("ABC", run.origin.x))
7900 }
7901 PositionedElement::Text(run) if run.text.contains("123") => {
7902 Some(("123", run.origin.x))
7903 }
7904 _ => None,
7905 })
7906 .collect::<Vec<_>>();
7907 assert_eq!(positions.len(), 2, "{positions:?}");
7908 let abc_x = positions
7909 .iter()
7910 .find_map(|(text, x)| (*text == "ABC").then_some(*x))
7911 .unwrap();
7912 let digits_x = positions
7913 .iter()
7914 .find_map(|(text, x)| (*text == "123").then_some(*x))
7915 .unwrap();
7916 assert!(
7917 abc_x < digits_x,
7918 "resolved even-level spans stay LTR: {positions:?}"
7919 );
7920 }
7921
7922 #[test]
7923 fn right_to_left_paragraph_resolves_start_alignment_and_indents_from_the_right() {
7924 let mut input = make_input_with_text("123 العربية");
7925 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7926 panic!("expected paragraph")
7927 };
7928 paragraph.properties = Some(CT_PPr {
7929 bidi: Some(true),
7930 jc: Some(rdocx_oxml::shared::ST_Jc::Start),
7931 ind_start: Some(rdocx_oxml::units::Twips(720)),
7932 ind_end: Some(rdocx_oxml::units::Twips(360)),
7933 num_id: Some(1),
7934 num_ilvl: Some(0),
7935 ..Default::default()
7936 });
7937 let mut level = rdocx_oxml::numbering::CT_Lvl::new(0);
7938 level.num_fmt = Some(rdocx_oxml::numbering::ST_NumberFormat::Bullet);
7939 level.suffix = Some(rdocx_oxml::numbering::ST_LvlSuffix::Nothing);
7940 level.lvl_text = Some("•".to_owned());
7941 let mut abstract_num = rdocx_oxml::numbering::CT_AbstractNum::new(1);
7942 abstract_num.levels.push(level);
7943 input.numbering = Some(rdocx_oxml::numbering::CT_Numbering {
7944 abstract_nums: vec![abstract_num],
7945 nums: vec![rdocx_oxml::numbering::CT_Num {
7946 num_id: 1,
7947 abstract_num_id: 1,
7948 extra_xml: Vec::new(),
7949 extra_attributes: Vec::new(),
7950 }],
7951 root_attributes: Vec::new(),
7952 extra_xml: Vec::new(),
7953 });
7954 let paragraph = paragraph.clone();
7955
7956 let media = MediaRegistry::new(&input.images);
7957 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
7958 let mut numbering = NumberingState::new();
7959 let mut diagnostics = Vec::new();
7960 let block = layout_paragraph(
7961 ¶graph,
7962 468.0,
7963 &input.styles,
7964 &input,
7965 &media,
7966 &mut fonts,
7967 &mut numbering,
7968 &mut diagnostics,
7969 )
7970 .expect("RTL paragraph lays out");
7971
7972 assert_eq!(block.indent_left, 18.0);
7973 assert_eq!(block.indent_right, 36.0);
7974 assert_eq!(block.jc, Some(oxml_layout::Align::End));
7975 assert!(matches!(
7976 block.lines[0].items.last(),
7977 Some(LineItem::Marker(marker)) if marker.text == "•"
7978 ));
7979 assert_eq!(
7980 block.lines[0]
7981 .items
7982 .iter()
7983 .filter_map(|item| match item {
7984 LineItem::Text(text) => Some(text.text.as_str()),
7985 LineItem::MultilingualText(text) => Some(text.text()),
7986 LineItem::Marker(marker) => Some(marker.text.as_str()),
7987 _ => None,
7988 })
7989 .collect::<String>(),
7990 "العربية 123•"
7991 );
7992 }
7993
7994 #[test]
7995 fn run_level_direction_override_shapes_the_exact_source_span() {
7996 let mut input = make_input_with_text("");
7997 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
7998 panic!("expected paragraph")
7999 };
8000 paragraph.properties = Some(CT_PPr {
8001 bidi: Some(false),
8002 ..Default::default()
8003 });
8004 let mut leading = CT_R::new("left ");
8005 leading.properties = Some(CT_RPr {
8006 language: Some("en-US".to_owned()),
8007 ..Default::default()
8008 });
8009 let mut overridden = CT_R::new("123");
8010 overridden.properties = Some(CT_RPr {
8011 rtl: Some(true),
8012 language_bidi: Some("ar-SA".to_owned()),
8013 ..Default::default()
8014 });
8015 let mut trailing = CT_R::new(" right");
8016 trailing.properties = Some(CT_RPr {
8017 language: Some("en-US".to_owned()),
8018 ..Default::default()
8019 });
8020 paragraph.runs = vec![leading, overridden, trailing];
8021
8022 let output = crate::layout_document_deterministic_with_provenance(&input)
8023 .expect("directional layout with sources");
8024 let overridden = multilingual_runs(&output.layout)
8025 .into_iter()
8026 .find(|run| run.logical_text == "123")
8027 .expect("run override enters rich shaping");
8028 assert_eq!(overridden.direction, TextDirection::LeftToRight);
8029 assert_eq!(overridden.bidi_level, 2);
8030 assert_eq!(overridden.source.expect("source span").char_start, 5);
8031 assert_eq!(overridden.source.expect("source span").char_end, 8);
8032 }
8033
8034 #[test]
8035 fn computed_field_retains_its_stored_run_direction() {
8036 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:bidi w:val="0"/></w:pPr><w:fldSimple w:instr=" PAGE "><w:r><w:rPr><w:rtl/><w:lang w:val="en-US" w:bidi="ar-SA"/></w:rPr><w:t>99</w:t></w:r></w:fldSimple></w:p></w:body></w:document>"#;
8037 let mut input = make_input_with_text("");
8038 input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
8039 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8040 panic!("expected paragraph")
8041 };
8042 let media = MediaRegistry::new(&input.images);
8043 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8044 let mut numbering = NumberingState::new();
8045 let mut diagnostics = Vec::new();
8046 let block = layout_paragraph(
8047 paragraph,
8048 468.0,
8049 &input.styles,
8050 &input,
8051 &media,
8052 &mut fonts,
8053 &mut numbering,
8054 &mut diagnostics,
8055 )
8056 .expect("directional field lays out");
8057 let field = block
8058 .lines
8059 .iter()
8060 .flat_map(|line| &line.items)
8061 .find_map(|item| match item {
8062 LineItem::Text(segment) if segment.field_kind == Some(FieldKind::Page) => {
8063 Some(segment)
8064 }
8065 _ => None,
8066 })
8067 .expect("computed field remains substitutable");
8068 assert_eq!(field.text, "99");
8069 assert_eq!(field.direction, TextDirection::RightToLeft);
8070 }
8071
8072 #[test]
8073 fn field_only_directional_paragraph_keeps_bidi_through_drawing_reflow() {
8074 use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
8075 use rdocx_oxml::text::Field;
8076
8077 let mut input =
8078 make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
8079 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8080 panic!("expected paragraph")
8081 };
8082 paragraph.properties = Some(CT_PPr {
8083 bidi: Some(true),
8084 ..Default::default()
8085 });
8086 let drawing = paragraph.runs.pop().expect("wrapping drawing run");
8087 let mut page = CT_R::new("");
8088 page.properties = Some(CT_RPr {
8089 rtl: Some(true),
8090 ..Default::default()
8091 });
8092 page.content = vec![RunContent::Field(Field::new("PAGE", "אבג"))];
8093 let mut pages = CT_R::new("");
8094 pages.properties = Some(CT_RPr {
8095 rtl: Some(false),
8096 ..Default::default()
8097 });
8098 pages.content = vec![RunContent::Field(Field::new("NUMPAGES", "ABC"))];
8099 paragraph.runs = vec![page, pages, drawing];
8100
8101 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8102 panic!("expected paragraph")
8103 };
8104 let media = MediaRegistry::new(&input.images);
8105 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8106 let mut numbering = NumberingState::new();
8107 let mut diagnostics = Vec::new();
8108 let (block, direction) = layout_paragraph_with_source_and_direction(
8109 paragraph,
8110 468.0,
8111 &input.styles,
8112 &input,
8113 &media,
8114 &mut fonts,
8115 &mut numbering,
8116 &mut diagnostics,
8117 None,
8118 )
8119 .expect("field-only paragraph lays out");
8120 assert_eq!(direction, TextDirection::RightToLeft);
8121 assert_eq!(
8122 block.reflow.as_ref().expect("reflow state").items.len(),
8123 2,
8124 "private direction state must not masquerade as a public inline item"
8125 );
8126
8127 let output = deterministic_layout(&input);
8128 let fields = output.pages[0]
8129 .elements
8130 .iter()
8131 .filter_map(|element| match element {
8132 PositionedElement::Text(run) => run.field_kind.map(|kind| (kind, run.origin.x)),
8133 PositionedElement::MarkedContent { children, .. } => {
8134 children.iter().find_map(|child| match child {
8135 PositionedElement::Text(run) => {
8136 run.field_kind.map(|kind| (kind, run.origin.x))
8137 }
8138 _ => None,
8139 })
8140 }
8141 _ => None,
8142 })
8143 .collect::<Vec<_>>();
8144 assert_eq!(
8145 fields.iter().map(|(kind, _)| *kind).collect::<Vec<_>>(),
8146 vec![FieldKind::Page, FieldKind::NumPages],
8147 "logical extraction keeps the stored field sequence"
8148 );
8149 }
8150
8151 #[test]
8152 fn word_positioned_runs_keep_logical_order_with_visual_origins() {
8153 let mut input = make_input_with_text("");
8154 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8155 panic!("expected paragraph")
8156 };
8157 paragraph.properties = Some(CT_PPr {
8158 bidi: Some(true),
8159 ..Default::default()
8160 });
8161 let mut arabic = CT_R::new("العربية ");
8162 arabic.properties = Some(CT_RPr {
8163 rtl: Some(true),
8164 language_bidi: Some("ar-SA".to_owned()),
8165 ..Default::default()
8166 });
8167 let mut latin = CT_R::new("ABC");
8168 latin.properties = Some(CT_RPr {
8169 rtl: Some(false),
8170 language: Some("en-US".to_owned()),
8171 ..Default::default()
8172 });
8173 paragraph.runs = vec![arabic, latin];
8174
8175 let output = deterministic_layout(&input);
8176 let runs = multilingual_runs(&output);
8177 assert_eq!(
8178 runs.iter()
8179 .map(|run| run.logical_text.as_str())
8180 .collect::<String>(),
8181 "العربية ABC"
8182 );
8183 assert!(
8184 runs.windows(2)
8185 .all(|pair| pair[0].logical_index < pair[1].logical_index)
8186 );
8187 let arabic_x = runs
8188 .iter()
8189 .find(|run| run.logical_text.contains("العربية"))
8190 .unwrap()
8191 .origin
8192 .x;
8193 let latin_x = runs
8194 .iter()
8195 .find(|run| run.logical_text == "ABC")
8196 .unwrap()
8197 .origin
8198 .x;
8199 assert!(latin_x < arabic_x, "visual origins remain RTL");
8200 }
8201
8202 #[test]
8203 fn absent_word_bidi_infers_one_rtl_base_for_reordering_alignment_and_indents() {
8204 let mut input = make_input_with_text("");
8205 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
8206 panic!("expected paragraph")
8207 };
8208 paragraph.properties = Some(CT_PPr {
8209 jc: Some(rdocx_oxml::shared::ST_Jc::Start),
8210 ind_start: Some(rdocx_oxml::units::Twips(720)),
8211 ind_end: Some(rdocx_oxml::units::Twips(360)),
8212 ..Default::default()
8213 });
8214 let mut arabic = CT_R::new("العربية ");
8215 arabic.properties = Some(CT_RPr {
8216 language_bidi: Some("ar-SA".to_owned()),
8217 ..Default::default()
8218 });
8219 let mut latin = CT_R::new("ABC");
8220 latin.properties = Some(CT_RPr {
8221 language: Some("en-US".to_owned()),
8222 ..Default::default()
8223 });
8224 paragraph.runs = vec![arabic, latin];
8225 let paragraph = paragraph.clone();
8226
8227 let media = MediaRegistry::new(&input.images);
8228 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8229 let mut numbering = NumberingState::new();
8230 let mut diagnostics = Vec::new();
8231 let block = layout_paragraph(
8232 ¶graph,
8233 468.0,
8234 &input.styles,
8235 &input,
8236 &media,
8237 &mut fonts,
8238 &mut numbering,
8239 &mut diagnostics,
8240 )
8241 .expect("default-direction paragraph lays out");
8242 assert_eq!(block.indent_left, 18.0);
8243 assert_eq!(block.indent_right, 36.0);
8244 assert_eq!(block.jc, Some(oxml_layout::Align::End));
8245
8246 let output = deterministic_layout(&input);
8247 let runs = multilingual_runs(&output);
8248 let arabic_x = runs
8249 .iter()
8250 .find(|run| run.logical_text.contains("العربية"))
8251 .unwrap()
8252 .origin
8253 .x;
8254 let latin_x = runs
8255 .iter()
8256 .find(|run| run.logical_text == "ABC")
8257 .unwrap()
8258 .origin
8259 .x;
8260 assert!(
8261 latin_x < arabic_x,
8262 "absent w:bidi uses one inferred RTL base"
8263 );
8264 }
8265
8266 #[test]
8267 fn changed_document_hyphenation_state_invalidates_reusable_paragraph_work() {
8268 let mut input = hyphenation_input(false, Some("en-US"), false);
8269 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8270 assert_eq!(
8271 output_text(&engine.layout(&input).expect("disabled layout")).concat(),
8272 "representation"
8273 );
8274
8275 input.automatic_hyphenation = true;
8276 let warm = engine.layout(&input).expect("enabled warm layout");
8277 let fresh = Engine::new_deterministic()
8278 .expect("bundled fonts load")
8279 .layout(&input)
8280 .expect("enabled fresh layout");
8281 assert_layout_results_equal(&warm, &fresh);
8282 assert!(output_text(&warm).concat().contains('-'));
8283 }
8284
8285 #[test]
8286 fn inherited_run_language_hyphenates_but_generated_fields_do_not() {
8287 let mut inherited = hyphenation_input(true, None, false);
8288 inherited
8289 .styles
8290 .doc_defaults
8291 .as_mut()
8292 .unwrap()
8293 .rpr
8294 .as_mut()
8295 .unwrap()
8296 .language = Some("en-GB".to_owned());
8297 assert!(
8298 output_text(&deterministic_layout(&inherited))
8299 .concat()
8300 .contains('-')
8301 );
8302
8303 let mut field = hyphenation_input(true, Some("en-US"), false);
8304 let BodyContent::Paragraph(paragraph) = &mut field.document.body.content[0] else {
8305 panic!("expected paragraph")
8306 };
8307 paragraph.runs[0].content = vec![RunContent::Field(Field::new("DATE", "representation"))];
8308 assert_eq!(
8309 output_text(&deterministic_layout(&field)).concat(),
8310 "representation"
8311 );
8312 }
8313
8314 #[test]
8315 fn mixed_languages_and_table_paragraphs_keep_hyphenation_run_local() {
8316 use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
8317 use rdocx_oxml::text::CT_R;
8318
8319 let mut paragraph = CT_P::new();
8320 paragraph.properties = Some(CT_PPr {
8321 ind_right: Some(rdocx_oxml::units::Twips(8_500)),
8322 ..Default::default()
8323 });
8324 for (text, language) in [("representation ", "en-US"), ("rappresentazione", "it-IT")] {
8325 let mut run = CT_R::new(text);
8326 run.properties = Some(rdocx_oxml::properties::CT_RPr {
8327 language: Some(language.to_owned()),
8328 ..Default::default()
8329 });
8330 paragraph.runs.push(run);
8331 }
8332
8333 let mut cell = CT_Tc::new();
8334 cell.content = vec![CellContent::Paragraph(paragraph)];
8335 let mut row = CT_Row::new();
8336 row.cells.push(cell);
8337 let mut table = CT_Tbl::new();
8338 table.rows.push(row);
8339 let mut input = make_input_with_text("");
8340 input.automatic_hyphenation = true;
8341 input.document.body.content = vec![BodyContent::Table(table)];
8342
8343 let text = output_text(&deterministic_layout(&input));
8344 assert!(text.iter().any(|item| item == "-"), "{text:?}");
8345 assert!(
8346 text.iter().any(|item| item == "rappresentazione"),
8347 "{text:?}"
8348 );
8349 }
8350
8351 #[test]
8352 fn oversized_caller_aliases_have_one_bounded_reusable_identity() {
8353 fn retained_bytes(aliases: &[(String, String)]) -> usize {
8354 aliases
8355 .iter()
8356 .map(|(requested, target)| requested.len() + target.len())
8357 .sum()
8358 }
8359
8360 fn assert_compatible_after_bound(
8361 input: &LayoutInput,
8362 first: &[(String, String)],
8363 second: &[(String, String)],
8364 ) {
8365 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8366 engine.set_caller_font_aliases(first);
8367 assert!(engine.caller_font_aliases.len() <= CALLER_ALIAS_MAX_ENTRIES);
8368 assert!(retained_bytes(&engine.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES);
8369 engine.layout(input).expect("prime reusable engine");
8370 let context = engine
8371 .paragraph_cache_context
8372 .as_ref()
8373 .expect("layout retains its context");
8374 assert_eq!(context.caller_font_aliases, engine.caller_font_aliases);
8375 assert!(context.caller_font_aliases.len() <= CALLER_ALIAS_MAX_ENTRIES);
8376 assert!(
8377 retained_bytes(&context.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES
8378 );
8379
8380 let mut source = Some(engine);
8381 assert!(
8382 Engine::take_if_compatible_with_caller_aliases(&mut source, input, second)
8383 .is_some(),
8384 "aliases discarded by the bounds must not change reusable identity"
8385 );
8386 assert!(source.is_none());
8387 }
8388
8389 let retained_prefix = (0..CALLER_ALIAS_MAX_ENTRIES)
8390 .map(|index| (format!("Document Serif {index}"), "Caladea".to_owned()))
8391 .collect::<Vec<_>>();
8392 let mut entry_limited_a = retained_prefix.clone();
8393 entry_limited_a.push(("discarded entry a".to_owned(), "Caladea".to_owned()));
8394 let mut entry_limited_b = retained_prefix;
8395 entry_limited_b.push(("discarded entry b".to_owned(), "Carlito".to_owned()));
8396 let input = make_input_with_text("bounded caller aliases");
8397 let mut boundary_engine = Engine::new_deterministic().expect("bundled fonts load");
8398 boundary_engine.set_caller_font_aliases(&entry_limited_a);
8399 assert_eq!(
8400 boundary_engine.caller_font_aliases.as_slice(),
8401 &entry_limited_a[..CALLER_ALIAS_MAX_ENTRIES]
8402 );
8403 assert_compatible_after_bound(&input, &entry_limited_a, &entry_limited_b);
8404
8405 let retained_large = ("x".repeat(32_760), String::new());
8406 let byte_limited_a = vec![
8407 retained_large.clone(),
8408 ("discarded bytes a".to_owned(), "Caladea".to_owned()),
8409 ];
8410 let byte_limited_b = vec![
8411 retained_large,
8412 ("discarded bytes b".to_owned(), "Carlito".to_owned()),
8413 ];
8414 boundary_engine.set_caller_font_aliases(&byte_limited_a);
8415 assert_eq!(
8416 boundary_engine.caller_font_aliases.as_slice(),
8417 &byte_limited_a[..1]
8418 );
8419 assert_compatible_after_bound(&input, &byte_limited_a, &byte_limited_b);
8420
8421 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8422 engine.set_caller_font_aliases(&[("x".repeat(70_000), "Caladea".to_owned())]);
8423 assert!(retained_bytes(&engine.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES);
8424 assert!(engine.caller_font_aliases.is_empty());
8425 let context = ReusableEngineContext::for_input(&input, &engine.caller_font_aliases);
8426 assert!(retained_bytes(&context.caller_font_aliases) <= CALLER_ALIAS_MAX_RETAINED_BYTES);
8427 }
8428
8429 fn header_footer_part(text: &str) -> rdocx_oxml::header_footer::CT_HdrFtr {
8430 let mut part = rdocx_oxml::header_footer::CT_HdrFtr::new();
8431 let mut paragraph = CT_P::new();
8432 paragraph.add_run(text);
8433 part.paragraphs.push(paragraph);
8434 part
8435 }
8436
8437 fn image_watermark_header(text: &str, width_pt: f64) -> rdocx_oxml::header_footer::CT_HdrFtr {
8438 rdocx_oxml::header_footer::CT_HdrFtr::from_xml(
8439 format!(
8440 r#"<w:hdr xmlns:w="{}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:p><w:r><w:pict><v:shape style="width:{width_pt}pt;height:36pt"><v:fill opacity=".5"/><v:imagedata r:id="rIdWatermark"/></v:shape></w:pict><w:t>{text}</w:t></w:r></w:p></w:hdr>"#,
8441 rdocx_oxml::namespace::W_NS
8442 )
8443 .as_bytes(),
8444 )
8445 .expect("watermark header parses")
8446 }
8447
8448 fn cacheable_header_footer_input(body: &str) -> LayoutInput {
8449 use rdocx_oxml::header_footer::HdrFtrRef;
8450
8451 let mut input = make_input_with_text(body);
8452 let mut section = CT_SectPr::default_letter();
8453 section.title_pg = Some(true);
8454 for (variant, suffix) in [
8455 (HdrFtrType::Default, "default"),
8456 (HdrFtrType::First, "first"),
8457 (HdrFtrType::Even, "even"),
8458 ] {
8459 let header_id = format!("rId-{suffix}-header");
8460 let footer_id = format!("rId-{suffix}-footer");
8461 section.header_refs.push(HdrFtrRef {
8462 hdr_ftr_type: variant,
8463 rel_id: header_id.clone(),
8464 });
8465 section.footer_refs.push(HdrFtrRef {
8466 hdr_ftr_type: variant,
8467 rel_id: footer_id.clone(),
8468 });
8469 let header = if variant == HdrFtrType::Default {
8470 image_watermark_header(&format!("{suffix} header"), 72.0)
8471 } else {
8472 header_footer_part(&format!("{suffix} header"))
8473 };
8474 input.headers.insert(header_id, header);
8475 input
8476 .footers
8477 .insert(footer_id, header_footer_part(&format!("{suffix} footer")));
8478 }
8479 input.images.insert(
8480 "rId-default-header\0rIdWatermark".to_owned(),
8481 ImageData {
8482 data: vec![1, 2, 3, 4],
8483 content_type: "image/png".to_owned(),
8484 },
8485 );
8486 input.document.body.sect_pr = Some(section);
8487 input
8488 }
8489
8490 fn header_footer_page_text(page: &PageFrame) -> String {
8491 compatibility_page_elements(page)
8492 .into_iter()
8493 .filter_map(|element| match element {
8494 PositionedElement::Text(text) => Some(text.text.as_str()),
8495 _ => None,
8496 })
8497 .collect()
8498 }
8499
8500 fn assert_header_footer_context_miss(
8501 base: &LayoutInput,
8502 name: &str,
8503 mutate: impl FnOnce(&mut LayoutInput),
8504 ) {
8505 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8506 engine.layout(base).expect("prime exact identity");
8507 let mut changed = base.clone();
8508 mutate(&mut changed);
8509 engine
8510 .layout(&changed)
8511 .unwrap_or_else(|error| panic!("{name}: {error}"));
8512 assert_eq!(engine.header_footer_cache_counts(), (0, 12), "{name}");
8513 }
8514
8515 #[test]
8516 fn safe_header_footer_variants_reuse_exactly() {
8517 let mut input = cacheable_header_footer_input(&"body ".repeat(4_000));
8518 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
8519 let (cold, cold_sources) = engine
8520 .layout_with_provenance(&input)
8521 .expect("cold header/footer layout");
8522 assert!(cold.pages.len() > 2);
8523 assert!(header_footer_page_text(&cold.pages[0]).contains("first header"));
8524 assert!(header_footer_page_text(&cold.pages[0]).contains("first footer"));
8525 assert!(header_footer_page_text(&cold.pages[1]).contains("even header"));
8526 assert!(header_footer_page_text(&cold.pages[1]).contains("even footer"));
8527 assert!(header_footer_page_text(&cold.pages[2]).contains("default header"));
8528 assert!(header_footer_page_text(&cold.pages[2]).contains("default footer"));
8529 assert_eq!(engine.header_footer_cache_counts(), (0, 6));
8530 assert_eq!(engine.header_footer_cache.len(), 6);
8531 assert!(
8532 engine
8533 .header_footer_cache
8534 .iter()
8535 .all(|entry| !entry.font_trace.is_empty())
8536 );
8537
8538 for (index, entry) in engine.header_footer_cache.iter_mut().enumerate() {
8539 entry.diagnostics = vec![Diagnostic {
8540 message: format!("cached header/footer diagnostic {index}"),
8541 }];
8542 }
8543 input.document.body.content.insert(
8544 0,
8545 BodyContent::Paragraph({
8546 let mut paragraph = CT_P::new();
8547 paragraph.add_run("inserted body source");
8548 paragraph
8549 }),
8550 );
8551 let (warm, warm_sources) = engine
8552 .layout_with_provenance(&input)
8553 .expect("warm header/footer layout");
8554 let (fresh, fresh_sources) = Engine::new_deterministic()
8555 .expect("bundled fonts load")
8556 .layout_with_provenance(&input)
8557 .expect("fresh comparison layout");
8558 assert_eq!(format!("{:?}", warm.pages), format!("{:?}", fresh.pages));
8559 assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", fresh.fonts));
8560 assert_eq!(
8561 format!("{:?}", warm.outlines),
8562 format!("{:?}", fresh.outlines)
8563 );
8564 assert_eq!(warm_sources, fresh_sources);
8565 assert_ne!(cold_sources, warm_sources);
8566 assert_eq!(engine.header_footer_cache_counts(), (6, 6));
8567 assert_eq!(warm.diagnostics.len(), 6);
8568 assert!(warm.diagnostics.iter().all(|diagnostic| {
8569 diagnostic
8570 .message
8571 .starts_with("cached header/footer diagnostic")
8572 }));
8573 let header_source = warm
8574 .pages
8575 .iter()
8576 .flat_map(|page| compatibility_page_elements(page))
8577 .filter_map(|element| match element {
8578 PositionedElement::Text(text) if text.text.contains("header") => text.source,
8579 _ => None,
8580 })
8581 .next()
8582 .expect("cached header text has provenance");
8583 assert!(matches!(
8584 warm_sources[header_source.node.get() as usize - 1].story,
8585 WordStory::Header { .. }
8586 ));
8587
8588 let mut inherited_input = cacheable_header_footer_input(&"second section ".repeat(2_000));
8592 let inherited_section = inherited_input
8593 .document
8594 .body
8595 .sect_pr
8596 .clone()
8597 .expect("final section");
8598 let mut first_section_end = CT_P::new();
8599 first_section_end.add_run("authored section with shared variants");
8600 first_section_end.properties.get_or_insert_default().sect_pr = Some(inherited_section);
8601 inherited_input
8602 .document
8603 .body
8604 .content
8605 .insert(0, BodyContent::Paragraph(first_section_end));
8606 let mut inherited_engine = Engine::new_deterministic().expect("bundled fonts load");
8607 inherited_engine
8608 .layout_with_provenance(&inherited_input)
8609 .expect("cold inherited layout");
8610 let (inherited_warm, inherited_sources) = inherited_engine
8611 .layout_with_provenance(&inherited_input)
8612 .expect("warm inherited layout");
8613 let (inherited_fresh, fresh_inherited_sources) = Engine::new_deterministic()
8614 .expect("bundled fonts load")
8615 .layout_with_provenance(&inherited_input)
8616 .expect("fresh inherited layout");
8617 assert_eq!(inherited_engine.header_footer_cache_counts(), (12, 12));
8618 assert_eq!(
8619 format!("{:?}", inherited_warm.pages),
8620 format!("{:?}", inherited_fresh.pages)
8621 );
8622 assert_eq!(inherited_sources, fresh_inherited_sources);
8623 }
8624
8625 #[test]
8626 fn header_footer_media_geometry_and_context_changes_miss() {
8627 use rdocx_oxml::footnotes::CT_Footnotes;
8628 use rdocx_oxml::numbering::CT_Numbering;
8629 use rdocx_oxml::theme::Theme;
8630 use rdocx_oxml::units::Twips;
8631
8632 let base = cacheable_header_footer_input("body");
8633 assert_header_footer_context_miss(&base, "header text", |input| {
8634 input.headers.insert(
8635 "rId-first-header".to_owned(),
8636 header_footer_part("changed first header"),
8637 );
8638 });
8639 assert_header_footer_context_miss(&base, "media bytes", |input| {
8640 input
8641 .images
8642 .get_mut("rId-default-header\0rIdWatermark")
8643 .expect("watermark image")
8644 .data
8645 .push(5);
8646 });
8647 assert_header_footer_context_miss(&base, "watermark", |input| {
8648 input.headers.insert(
8649 "rId-default-header".to_owned(),
8650 image_watermark_header("default header", 73.0),
8651 );
8652 });
8653 assert_header_footer_context_miss(&base, "same-width page height", |input| {
8654 input
8655 .document
8656 .body
8657 .sect_pr
8658 .as_mut()
8659 .expect("section")
8660 .page_height = Some(Twips(15_841));
8661 });
8662 assert_header_footer_context_miss(&base, "styles", |input| {
8663 input.styles = CT_Styles::new();
8664 });
8665 assert_header_footer_context_miss(&base, "numbering", |input| {
8666 input.numbering = Some(CT_Numbering::new());
8667 });
8668 assert_header_footer_context_miss(&base, "notes", |input| {
8669 input.footnotes = Some(CT_Footnotes::new());
8670 });
8671 assert_header_footer_context_miss(&base, "theme", |input| {
8672 input.theme = Some(Theme::default());
8673 });
8674 assert_header_footer_context_miss(&base, "revision", |input| {
8675 input.revision_view = RevisionView::Tracked;
8676 });
8677 assert_header_footer_context_miss(&base, "fonts", |input| {
8678 let (family, data) = oxml_layout::bundled_fonts::bundled_font_data()[0];
8679 input.fonts.push(oxml_layout::FontFile {
8680 family: family.to_owned(),
8681 data: data.to_vec(),
8682 });
8683 });
8684
8685 let mut source_mode = Engine::new_deterministic().expect("bundled fonts load");
8686 source_mode.layout(&base).expect("prime unsourced cache");
8687 source_mode
8688 .layout_with_provenance(&base)
8689 .expect("sourced layout misses unsourced entries");
8690 assert_eq!(source_mode.header_footer_cache_counts(), (0, 12));
8691
8692 let mut unsafe_input = base.clone();
8693 unsafe_input
8694 .headers
8695 .get_mut("rId-first-header")
8696 .expect("first header")
8697 .paragraphs[0]
8698 .properties
8699 .get_or_insert_default()
8700 .num_id = Some(1);
8701 let mut unsafe_engine = Engine::new_deterministic().expect("bundled fonts load");
8702 unsafe_engine
8703 .layout(&unsafe_input)
8704 .expect("unsafe part lays out");
8705 assert_eq!(unsafe_engine.header_footer_cache_counts(), (0, 5));
8706
8707 let mut opaque_input = base.clone();
8708 let opaque_run = &mut opaque_input
8709 .headers
8710 .get_mut("rId-first-header")
8711 .expect("first header")
8712 .paragraphs[0]
8713 .runs[0];
8714 opaque_run
8715 .extra_xml
8716 .push(br#"<w:object xmlns:w="urn:unrepresented"/>"#.to_vec());
8717 opaque_run.extra_xml_positions.push(0);
8718 let mut opaque_engine = Engine::new_deterministic().expect("bundled fonts load");
8719 opaque_engine
8720 .layout(&opaque_input)
8721 .expect("opaque producer XML lays out without reuse");
8722 assert_eq!(opaque_engine.header_footer_cache_counts(), (0, 5));
8723
8724 let foreign_wrapper = rdocx_oxml::header_footer::CT_HdrFtr::from_xml(
8725 format!(
8726 r#"<w:hdr xmlns:w="{}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:producer"><w:p><w:r><x:pict><w:pict><v:shape style="width:72pt;height:36pt"><v:textpath string="DRAFT"/></v:shape></w:pict></x:pict></w:r></w:p></w:hdr>"#,
8727 rdocx_oxml::namespace::W_NS
8728 )
8729 .as_bytes(),
8730 )
8731 .expect("foreign pict wrapper parses");
8732 assert_eq!(foreign_wrapper.watermarks().len(), 1);
8733 assert!(!header_footer_part_is_cache_safe(
8734 &foreign_wrapper,
8735 &base.styles
8736 ));
8737 let rebound_word_prefix = rdocx_oxml::header_footer::CT_HdrFtr::from_xml(
8738 format!(
8739 r#"<q:hdr xmlns:q="{}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:producer"><q:p><q:r><w:pict><q:pict><v:shape style="width:72pt;height:36pt"><v:textpath string="DRAFT"/></v:shape></q:pict></w:pict></q:r></q:p></q:hdr>"#,
8740 rdocx_oxml::namespace::W_NS
8741 )
8742 .as_bytes(),
8743 )
8744 .expect("rebound conventional prefix parses");
8745 assert_eq!(rebound_word_prefix.watermarks().len(), 1);
8746 assert!(!header_footer_part_is_cache_safe(
8747 &rebound_word_prefix,
8748 &base.styles
8749 ));
8750 }
8751
8752 #[test]
8753 fn header_footer_cache_publishes_transactionally_and_stays_bounded() {
8754 use rdocx_oxml::header_footer::HdrFtrRef;
8755
8756 let (valid_family, valid_bytes) = oxml_layout::bundled_fonts::bundled_font_data()[0];
8757 let (invalid_family, invalid_source) = oxml_layout::bundled_fonts::bundled_font_data()[4];
8758 let mut invalid_bytes = invalid_source.to_vec();
8759 let table_count = u16::from_be_bytes([invalid_bytes[4], invalid_bytes[5]]) as usize;
8760 let head_offset = (0..table_count)
8761 .find_map(|table| {
8762 let record = 12 + table * 16;
8763 (&invalid_bytes[record..record + 4] == b"head").then(|| {
8764 u32::from_be_bytes(
8765 invalid_bytes[record + 8..record + 12]
8766 .try_into()
8767 .expect("head offset"),
8768 ) as usize
8769 })
8770 })
8771 .expect("font has head table");
8772 invalid_bytes[head_offset + 18..head_offset + 20].copy_from_slice(&0u16.to_be_bytes());
8773
8774 let mut failing_input = make_input_with_text("section-ending prefix");
8775 let mut section = CT_SectPr::default_letter();
8776 section.header_refs.push(HdrFtrRef {
8777 hdr_ftr_type: HdrFtrType::Default,
8778 rel_id: "rIdHeader".to_owned(),
8779 });
8780 let BodyContent::Paragraph(prefix) = &mut failing_input.document.body.content[0] else {
8781 panic!("prefix paragraph");
8782 };
8783 prefix.properties.get_or_insert_default().sect_pr = Some(section);
8784 prefix.runs[0].properties.get_or_insert_default().font_ascii =
8785 Some(valid_family.to_owned());
8786 failing_input.headers.insert(
8787 "rIdHeader".to_owned(),
8788 header_footer_part("staged header before late failure"),
8789 );
8790 let mut later = CT_P::new();
8791 later
8792 .add_run("late font failure")
8793 .properties
8794 .get_or_insert_default()
8795 .font_ascii = Some(invalid_family.to_owned());
8796 failing_input.document.body.add_paragraph(later);
8797 failing_input.fonts.push(oxml_layout::FontFile {
8798 family: invalid_family.to_owned(),
8799 data: invalid_bytes,
8800 });
8801 let mut failing = Engine::with_font_manager(FontManager::new_with_fonts(vec![(
8802 valid_family.to_owned(),
8803 valid_bytes.to_vec(),
8804 )]));
8805 assert!(failing.layout(&failing_input).is_err());
8806 assert!(failing.header_footer_cache.is_empty());
8807 assert_eq!(failing.header_footer_cache_bytes, 0);
8808 assert_eq!(failing.header_footer_cache_counts(), (0, 1));
8809
8810 let mut bounded_input = make_input_with_text("bounded body");
8811 let mut bounded_section = CT_SectPr::default_letter();
8812 for index in 0..(HEADER_FOOTER_CACHE_MAX_ENTRIES * 2) {
8813 let relationship_id = format!("rIdHeader{index:03}");
8814 bounded_section.header_refs.push(HdrFtrRef {
8815 hdr_ftr_type: HdrFtrType::Default,
8816 rel_id: relationship_id.clone(),
8817 });
8818 bounded_input.headers.insert(
8819 relationship_id,
8820 header_footer_part(&format!("bounded header {index:03}")),
8821 );
8822 }
8823 bounded_input.document.body.sect_pr = Some(bounded_section);
8824 let mut bounded = Engine::new_deterministic().expect("bundled fonts load");
8825 bounded
8826 .layout(&bounded_input)
8827 .expect("bounded pending layout succeeds");
8828 assert_eq!(
8829 bounded.header_footer_cache.len(),
8830 HEADER_FOOTER_CACHE_MAX_ENTRIES
8831 );
8832 assert!(bounded.header_footer_cache_bytes <= HEADER_FOOTER_CACHE_MAX_BYTES);
8833 assert!(
8834 bounded.pending_header_footer_cache_peak_entries <= HEADER_FOOTER_CACHE_MAX_ENTRIES
8835 );
8836 assert!(bounded.pending_header_footer_cache_peak_bytes <= HEADER_FOOTER_CACHE_MAX_BYTES);
8837 assert_eq!(
8838 bounded.header_footer_cache_bytes,
8839 bounded
8840 .header_footer_cache
8841 .iter()
8842 .map(|entry| entry.bytes)
8843 .sum::<usize>()
8844 );
8845 assert!(
8846 bounded.paragraph_cache.len()
8847 + bounded.table_cache.len()
8848 + bounded.header_footer_cache.len()
8849 + bounded
8850 .restart_cache
8851 .as_ref()
8852 .map_or(0, |cache| cache.checkpoints.len())
8853 <= CACHE_MAX_ENTRIES
8854 );
8855 assert!(
8856 bounded.paragraph_cache_bytes
8857 + bounded.table_cache_bytes
8858 + bounded.header_footer_cache_bytes
8859 + bounded
8860 .restart_cache
8861 .as_ref()
8862 .map_or(0, |cache| cache.bytes)
8863 <= CACHE_MAX_BYTES
8864 );
8865
8866 let one = cacheable_header_footer_input("oversized entry body");
8867 let mut oversized = Engine::new_deterministic().expect("bundled fonts load");
8868 oversized.layout(&one).expect("prime oversized template");
8869 let mut entry = oversized
8870 .header_footer_cache
8871 .pop_front()
8872 .expect("header/footer template retained");
8873 let mut oversized_key = oversized
8874 .header_footer_cache
8875 .pop_front()
8876 .expect("second header/footer template retained");
8877 oversized.header_footer_cache.clear();
8878 oversized.header_footer_cache_bytes = 0;
8879 let mut reserved_namespace = String::with_capacity(HEADER_FOOTER_CACHE_MAX_BYTES + 1);
8880 reserved_namespace.push('x');
8881 oversized_key
8882 .key
8883 .part
8884 .extra_namespaces
8885 .push((reserved_namespace, "urn:test".to_owned()));
8886 oversized_key.bytes = header_footer_cache_entry_bytes(
8887 &oversized_key.key,
8888 &oversized_key.content,
8889 &oversized_key.diagnostics,
8890 &oversized_key.font_trace,
8891 );
8892 assert!(oversized_key.bytes > HEADER_FOOTER_CACHE_MAX_BYTES);
8893 oversized.publish_header_footer_cache_entry(oversized_key);
8894 assert!(oversized.header_footer_cache.is_empty());
8895 assert_eq!(oversized.header_footer_cache_bytes, 0);
8896
8897 let text = entry.content.blocks[0]
8898 .lines
8899 .iter_mut()
8900 .flat_map(|line| &mut line.items)
8901 .find_map(|item| match item {
8902 LineItem::Text(text) => Some(text),
8903 _ => None,
8904 })
8905 .expect("template has text");
8906 text.advances = vec![0.0; HEADER_FOOTER_CACHE_MAX_BYTES / 8 + 1];
8907 entry.bytes = header_footer_cache_entry_bytes(
8908 &entry.key,
8909 &entry.content,
8910 &entry.diagnostics,
8911 &entry.font_trace,
8912 );
8913 assert!(entry.bytes > HEADER_FOOTER_CACHE_MAX_BYTES);
8914 oversized.publish_header_footer_cache_entry(entry);
8915 assert!(oversized.header_footer_cache.is_empty());
8916 assert_eq!(oversized.header_footer_cache_bytes, 0);
8917 }
8918
8919 #[test]
8920 fn word_projection_leaves_break_segmentation_to_shared_layout() {
8921 let input = make_input_with_text("financial planning");
8922 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
8923 panic!("expected paragraph");
8924 };
8925 let media = MediaRegistry::new(&input.images);
8926 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
8927 let mut numbering = NumberingState::new();
8928 let mut diagnostics = Vec::new();
8929 let block = layout_paragraph_with_source(
8930 paragraph,
8931 468.0,
8932 &input.styles,
8933 &input,
8934 &media,
8935 &mut fonts,
8936 &mut numbering,
8937 &mut diagnostics,
8938 SourceNodeId::new(1),
8939 )
8940 .expect("paragraph lays out");
8941 let text_items = block
8942 .reflow
8943 .expect("line-breaking inputs retained")
8944 .items
8945 .into_iter()
8946 .filter_map(|item| match item {
8947 InlineItem::Text(segment) => Some(segment),
8948 _ => None,
8949 })
8950 .collect::<Vec<_>>();
8951
8952 assert_eq!(text_items.len(), 1);
8953 assert_eq!(text_items[0].text, "financial planning");
8954 assert_eq!(text_items[0].source.expect("source span").char_start, 0);
8955 assert_eq!(text_items[0].source.expect("source span").char_end, 18);
8956 }
8957
8958 #[test]
8959 fn mixed_script_fallback_uses_each_covering_font_without_boxes() {
8960 let input = make_input_with_text("Latin العربية देवनागरी ภาษาไทย 你好世界");
8961 let result = crate::layout_document_deterministic_with_provenance(&input)
8962 .expect("deterministic multilingual layout");
8963 let runs = multilingual_runs(&result.layout);
8964
8965 assert!(
8966 !runs.is_empty(),
8967 "Word layout still emits only legacy glyph runs"
8968 );
8969 for script in [
8970 TextScript::Latin,
8971 TextScript::Arabic,
8972 TextScript::Devanagari,
8973 TextScript::Thai,
8974 TextScript::Han,
8975 ] {
8976 assert!(
8977 runs.iter().any(|run| run.script == script),
8978 "missing {script:?} span"
8979 );
8980 }
8981 assert!(
8982 runs.iter().all(|run| !run.glyph_ids.contains(&0)),
8983 "deterministic fallback emitted a .notdef glyph: {:?}",
8984 runs.iter()
8985 .map(|run| (&run.logical_text, run.script, &run.glyph_ids))
8986 .collect::<Vec<_>>()
8987 );
8988 }
8989
8990 #[test]
8991 fn complex_shaping_preserves_clusters_offsets_and_logical_source_spans() {
8992 let text = "سلام क्षि ภาษาไทย 你好世界";
8993 let input = make_input_with_text(text);
8994 let result = crate::layout_document_deterministic_with_provenance(&input)
8995 .expect("deterministic multilingual layout");
8996 let mut runs = multilingual_runs(&result.layout);
8997
8998 assert!(!runs.is_empty(), "Word did not consume rich shaped spans");
8999 runs.sort_by_key(|run| run.logical_index);
9000 assert_eq!(
9001 runs.iter()
9002 .map(|run| run.logical_text.as_str())
9003 .collect::<String>(),
9004 text
9005 );
9006 assert!(runs.iter().all(|run| run.is_valid()));
9007 assert!(runs.iter().all(|run| run.source.is_some()));
9008 assert!(runs.iter().any(|run| {
9009 run.script == TextScript::Devanagari
9010 && run
9011 .clusters
9012 .iter()
9013 .any(|cluster| cluster.char_end - cluster.char_start > 1)
9014 }));
9015 }
9016
9017 #[test]
9018 fn rich_mixed_script_paragraph_retains_conditional_hyphenation() {
9019 let mut input = hyphenation_input(true, Some("en-US"), false);
9020 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9021 panic!("expected paragraph")
9022 };
9023 let mut arabic = CT_R::new(" العربية");
9024 arabic.properties = Some(rdocx_oxml::properties::CT_RPr {
9025 language: Some("en-US".to_owned()),
9026 language_bidi: Some("ar-SA".to_owned()),
9027 ..Default::default()
9028 });
9029 paragraph.runs.push(arabic);
9030
9031 let output = deterministic_layout(&input);
9032 let text = output_text(&output);
9033 assert!(text.iter().any(|item| item == "-"), "{text:?}");
9034 assert!(
9035 multilingual_runs(&output)
9036 .iter()
9037 .any(|run| run.script == TextScript::Arabic),
9038 "the Arabic run must still use rich shaping"
9039 );
9040 }
9041
9042 #[test]
9043 fn one_mixed_text_node_uses_each_effective_word_language_slot() {
9044 let text = "Latin العربية 你好";
9045 let mut input = make_input_with_text(text);
9046 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9047 panic!("expected paragraph")
9048 };
9049 paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
9050 language: Some("en-US".to_owned()),
9051 language_east_asia: Some("zh-CN".to_owned()),
9052 language_bidi: Some("ar-SA".to_owned()),
9053 ..Default::default()
9054 });
9055 let result = crate::layout_document_deterministic_with_provenance(&input)
9056 .expect("deterministic multilingual layout");
9057 let runs = multilingual_runs(&result.layout);
9058
9059 for (script, language) in [
9060 (TextScript::Latin, "en-US"),
9061 (TextScript::Arabic, "ar-SA"),
9062 (TextScript::Han, "zh-CN"),
9063 ] {
9064 let run = runs
9065 .iter()
9066 .find(|run| run.script == script && run.language.as_deref() == Some(language))
9067 .unwrap_or_else(|| {
9068 panic!(
9069 "missing {script:?} with {language}: {:?}",
9070 runs.iter()
9071 .map(|run| (
9072 run.script,
9073 run.language.as_deref(),
9074 run.logical_text.as_str()
9075 ))
9076 .collect::<Vec<_>>()
9077 )
9078 });
9079 let source = run.source.unwrap_or_else(|| {
9080 panic!(
9081 "mixed-language {script:?} span {:?} retains source: {:?}",
9082 run.logical_text,
9083 runs.iter()
9084 .map(|run| (&run.logical_text, run.script, run.source))
9085 .collect::<Vec<_>>()
9086 )
9087 });
9088 let source_text = text
9089 .chars()
9090 .skip(source.char_start as usize)
9091 .take((source.char_end - source.char_start) as usize)
9092 .collect::<String>();
9093 assert_eq!(source_text, run.logical_text);
9094 }
9095 }
9096
9097 #[test]
9098 fn rich_stored_field_retains_resolved_language_and_character_spacing() {
9099 for (xml, expected) in [
9100 (
9101 r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:fldSimple w:instr="DATE"><w:r><w:rPr><w:spacing w:val="40"/><w:lang w:val="en-US"/></w:rPr><w:t>stored</w:t></w:r></w:fldSimple><w:r><w:rPr><w:lang w:bidi="ar-SA"/></w:rPr><w:t> العربية</w:t></w:r></w:p></w:body></w:document>"#,
9102 "stored",
9103 ),
9104 (
9105 r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:bookmarkStart w:id="1" w:name="target"/><w:r><w:t>resolved</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p><w:p><w:fldSimple w:instr="REF target"><w:r><w:rPr><w:spacing w:val="40"/><w:lang w:val="en-US"/></w:rPr><w:t>cached</w:t></w:r></w:fldSimple><w:r><w:rPr><w:lang w:bidi="ar-SA"/></w:rPr><w:t> العربية</w:t></w:r></w:p></w:body></w:document>"#,
9106 "resolved",
9107 ),
9108 ] {
9109 let mut input = make_input_with_text("");
9110 input.document =
9111 rdocx_oxml::CT_Document::from_xml(xml.as_bytes()).expect("stored field XML parses");
9112 let output = deterministic_layout(&input);
9113 let run = multilingual_runs(&output)
9114 .into_iter()
9115 .find(|run| {
9116 run.logical_text == expected && run.language.as_deref() == Some("en-US")
9117 })
9118 .expect("stored or resolved field is rich-shaped with its language");
9119
9120 let family = output
9121 .fonts
9122 .iter()
9123 .find(|font| font.id == run.font_id)
9124 .expect("field font is present")
9125 .family
9126 .clone();
9127 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
9128 let font_id = fonts
9129 .resolve_font(Some(&family), run.bold, run.italic)
9130 .expect("field font resolves");
9131 let unspaced = fonts
9132 .shape_text(font_id, &run.logical_text, run.font_size)
9133 .expect("field text reshapes");
9134 assert_eq!(run.x_advances.len(), unspaced.advances.len());
9135 assert!(
9136 run.x_advances
9137 .iter()
9138 .zip(unspaced.advances)
9139 .all(|(actual, unspaced)| (*actual - unspaced - 2.0).abs() < 0.001),
9140 "stored or resolved field spacing was not retained"
9141 );
9142 }
9143 }
9144
9145 #[test]
9146 fn exact_word_lines_place_every_complex_script_on_the_word_em_baseline() {
9147 for (family, language_attributes, text) in [
9148 (
9149 "Noto Sans Arabic",
9150 r#"w:val="ar-SA" w:bidi="ar-SA""#,
9151 "العربية مرحبا بالعالم",
9152 ),
9153 (
9154 "Noto Sans Devanagari",
9155 r#"w:val="hi-IN""#,
9156 "देवनागरी नमस्ते दुनिया",
9157 ),
9158 ("Noto Sans Thai", r#"w:val="th-TH""#, "ภาษาไทยยินดีต้อนรับ"),
9159 (
9160 "Noto Sans SC",
9161 r#"w:val="zh-CN" w:eastAsia="zh-CN""#,
9162 "〈中〉、你好世界",
9163 ),
9164 ] {
9165 let xml = format!(
9166 r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:spacing w:after="0" w:line="480" w:lineRule="exact"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="{family}" w:hAnsi="{family}" w:eastAsia="{family}" w:cs="{family}"/><w:sz w:val="48"/><w:szCs w:val="48"/><w:lang {language_attributes}/></w:rPr><w:t>{text}</w:t></w:r></w:p><w:sectPr><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>"#
9167 );
9168 let mut input = make_input_with_text("");
9169 input.document = rdocx_oxml::CT_Document::from_xml(xml.as_bytes())
9170 .expect("complex-script metric fixture parses");
9171 let result = crate::layout_document_deterministic_with_provenance(&input)
9172 .expect("complex-script metric fixture lays out");
9173 let runs = multilingual_runs(&result.layout);
9174 let first = runs
9175 .iter()
9176 .min_by_key(|run| run.logical_index)
9177 .expect("fixture emits rich text");
9178 assert!(
9179 (first.origin.y - 91.2).abs() < 0.001,
9180 "{family} baseline was {}, expected 91.2",
9181 first.origin.y
9182 );
9183 }
9184 }
9185
9186 #[test]
9187 fn latin_shaping_and_hash_outputs_remain_byte_identical() {
9188 let text = "financial العربية";
9189 let input = make_input_with_text(text);
9190 let result = crate::layout_document_deterministic_with_provenance(&input)
9191 .expect("deterministic Latin layout");
9192 let mut runs = multilingual_runs(&result.layout);
9193
9194 assert!(
9195 !runs.is_empty(),
9196 "Word has not migrated to the shared rich path"
9197 );
9198 runs.sort_by_key(|run| run.logical_index);
9199 assert_eq!(
9200 runs.iter()
9201 .map(|run| run.logical_text.as_str())
9202 .collect::<String>(),
9203 text
9204 );
9205 let latin = runs
9206 .iter()
9207 .find(|run| run.script == TextScript::Latin && run.logical_text == "financial")
9208 .expect("mixed-script paragraph retains its Latin span");
9209 let projection = latin.legacy_projection();
9210 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
9211 let family = result
9212 .layout
9213 .fonts
9214 .iter()
9215 .find(|font| font.id == latin.font_id)
9216 .expect("Latin font is in the result")
9217 .family
9218 .clone();
9219 let font_id = fonts
9220 .resolve_font(Some(&family), latin.bold, latin.italic)
9221 .expect("bundled Latin font resolves");
9222 let independently_shaped = fonts
9223 .shape_text(font_id, &latin.logical_text, latin.font_size)
9224 .expect("Latin span shapes independently");
9225 assert_eq!(projection.glyph_ids, independently_shaped.glyph_ids);
9226 assert_eq!(projection.advances, independently_shaped.advances);
9227 }
9228
9229 #[test]
9230 fn break_opportunities_emit_every_scalar_and_glyph_once() {
9231 let text = "financial planning ttf-parser double spaces e\u{301}lan allocated \u{754c} "
9232 .repeat(12);
9233 let input = make_input_with_text(&text);
9234 let result = crate::layout_document_deterministic_with_provenance(&input)
9235 .expect("deterministic layout");
9236 let runs = multilingual_runs(&result.layout);
9237
9238 assert_eq!(
9239 runs.iter()
9240 .map(|run| run.logical_text.as_str())
9241 .collect::<String>(),
9242 text
9243 );
9244 let mut expected_start = 0;
9245 for run in runs {
9246 let source = run.source.expect("filtered sourced run");
9247 assert_eq!(source.char_start, expected_start);
9248 assert_eq!(
9249 source.char_end - source.char_start,
9250 run.logical_text.chars().count() as u32
9251 );
9252 expected_start = source.char_end;
9253 assert!(run.is_valid(), "{}", run.logical_text);
9254 }
9255 assert_eq!(expected_start, text.chars().count() as u32);
9256 }
9257
9258 #[test]
9259 fn reported_words_do_not_duplicate_boundary_glyphs() {
9260 for text in [
9261 "ttf-parser follows",
9262 "double spaces follow",
9263 "financial planning",
9264 "allocated space",
9265 ] {
9266 let input = make_input_with_text(text);
9267 let result = crate::layout_document_deterministic_with_provenance(&input)
9268 .expect("deterministic layout");
9269 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
9270 for run in result.layout.pages.iter().flat_map(|page| {
9271 compatibility_page_elements(page)
9272 .into_iter()
9273 .filter_map(|element| match element {
9274 PositionedElement::Text(run) if run.source.is_some() => Some(run),
9275 _ => None,
9276 })
9277 }) {
9278 let family = result
9279 .layout
9280 .fonts
9281 .iter()
9282 .find(|font| font.id == run.font_id)
9283 .expect("run font is in result")
9284 .family
9285 .clone();
9286 let font_id = fonts
9287 .resolve_font(Some(&family), run.bold, run.italic)
9288 .expect("bundled run font resolves");
9289 let independently_shaped = fonts
9290 .shape_text(font_id, &run.text, run.font_size)
9291 .expect("emitted chunk reshapes");
9292 assert_eq!(run.glyph_ids, independently_shaped.glyph_ids, "{text}");
9293 assert_eq!(run.advances, independently_shaped.advances, "{text}");
9294 }
9295 }
9296 }
9297
9298 #[test]
9299 fn warm_relayout_matches_cold_and_rebuilds_only_changed_safe_paragraphs() {
9300 let mut input = make_input_with_text("first cache-safe paragraph");
9301 for text in ["second cache-safe paragraph", "third cache-safe paragraph"] {
9302 let mut paragraph = CT_P::new();
9303 paragraph.add_run(text);
9304 input.document.body.add_paragraph(paragraph);
9305 }
9306
9307 let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9308 let cold = warm_engine
9309 .layout_with_provenance(&input)
9310 .expect("cold layout succeeds");
9311 let after_cold = warm_engine.paragraph_cache_counts();
9312
9313 let BodyContent::Paragraph(changed) = &mut input.document.body.content[1] else {
9314 panic!("second body item is a paragraph");
9315 };
9316 changed.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
9317 "changed cache-safe paragraph",
9318 ))];
9319
9320 let warm = warm_engine
9321 .layout_with_provenance(&input)
9322 .expect("warm relayout succeeds");
9323 let after_warm = warm_engine.paragraph_cache_counts();
9324 let cold_after_edit = Engine::new_deterministic()
9325 .expect("bundled fonts load")
9326 .layout_with_provenance(&input)
9327 .expect("independent cold relayout succeeds");
9328
9329 assert_eq!(format!("{:?}", warm.0), format!("{:?}", cold_after_edit.0));
9330 assert_eq!(warm.1, cold_after_edit.1);
9331 assert_eq!(after_cold, (0, 3));
9332 assert_eq!(after_warm, (2, 4));
9333 assert_ne!(output_text(&cold.0), output_text(&warm.0));
9334 }
9335
9336 #[test]
9337 fn paragraph_and_table_fingerprint_collisions_require_typed_equality() {
9338 let first = make_input_with_text("first collision candidate");
9339 let second = make_input_with_text("second collision candidate");
9340 let BodyContent::Paragraph(second_paragraph) = &second.document.body.content[0] else {
9341 panic!("body item is a paragraph");
9342 };
9343 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9344 engine.layout(&first).expect("first layout succeeds");
9345
9346 let forced_fingerprint = paragraph_fingerprint(second_paragraph);
9347 let retained = engine
9348 .paragraph_cache
9349 .front_mut()
9350 .expect("first paragraph is retained");
9351 assert_ne!(retained.fingerprint, forced_fingerprint);
9352 retained.fingerprint = forced_fingerprint;
9353
9354 let output = engine.layout(&second).expect("collision layout succeeds");
9355 assert_eq!(output_text(&output).concat(), "second collision candidate");
9356 assert_eq!(engine.paragraph_cache_counts(), (0, 2));
9357
9358 let mut first = make_input_with_text("before first table");
9359 first.document.body.add_table(safe_table("first table"));
9360 let mut second = make_input_with_text("before first table");
9361 second.document.body.add_table(safe_table("second table"));
9362 let BodyContent::Table(second_table) = &second.document.body.content[1] else {
9363 panic!("body item is a table");
9364 };
9365 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9366 engine.layout(&first).expect("first table layout succeeds");
9367 let forced_fingerprint = table_fingerprint(second_table);
9368 let retained = engine
9369 .table_cache
9370 .front_mut()
9371 .expect("first table is retained");
9372 assert_ne!(retained.fingerprint, forced_fingerprint);
9373 retained.fingerprint = forced_fingerprint;
9374
9375 let output = engine
9376 .layout(&second)
9377 .expect("table collision layout succeeds");
9378 assert!(output_text(&output).concat().contains("second table"));
9379 assert_eq!(engine.table_cache_counts(), (0, 2));
9380 }
9381
9382 #[test]
9383 fn body_only_layout_and_transfer_do_not_rebuild_owned_context() {
9384 let input = restart_input();
9385 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9386 engine.layout(&input).expect("cold layout succeeds");
9387 assert_eq!(engine.owned_context_build_count(), 1);
9388
9389 let mut edited = input.clone();
9390 set_body_paragraph_text(&mut edited, 70, "body-only edit");
9391 engine.layout(&edited).expect("warm layout succeeds");
9392 assert_eq!(engine.owned_context_build_count(), 1);
9393
9394 let mut source = Some(engine);
9395 let transferred = Engine::take_if_compatible(&mut source, &edited)
9396 .expect("body-only restore accepts the retained engine");
9397 assert_eq!(transferred.owned_context_build_count(), 1);
9398 assert!(source.is_none());
9399 }
9400
9401 #[test]
9402 fn editor_scale_paragraph_cache_avoids_warm_thrash() {
9403 let mut input = make_input_with_text("editor paragraph 000");
9404 for index in 1..700 {
9405 let mut paragraph = CT_P::new();
9406 paragraph.add_run(&format!("editor paragraph {index:03}"));
9407 input.document.body.add_paragraph(paragraph);
9408 }
9409 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9410 engine
9411 .layout_with_provenance(&input)
9412 .expect("editor cold layout succeeds");
9413 assert_eq!(engine.paragraph_cache.len(), 700);
9414 assert_eq!(engine.paragraph_cache_counts(), (0, 700));
9415
9416 set_body_paragraph_text(&mut input, 350, "editor paragraph 350 changed");
9417 let warm = engine
9418 .layout_with_provenance(&input)
9419 .expect("editor warm layout succeeds");
9420 let cold = Engine::new_deterministic()
9421 .expect("bundled fonts load")
9422 .layout_with_provenance(&input)
9423 .expect("editor cold comparison succeeds");
9424
9425 assert_layout_results_equal(&warm.0, &cold.0);
9426 assert_eq!(warm.1, cold.1);
9427 assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9428 assert_eq!(engine.paragraph_cache.len(), 701);
9429 assert_eq!(
9430 engine
9431 .paragraph_cache
9432 .front()
9433 .expect("insertion order has a front")
9434 .key
9435 .paragraph
9436 .text(),
9437 "editor paragraph 000"
9438 );
9439 let rebuilt = engine
9440 .last_rebuilt_page_range
9441 .clone()
9442 .expect("edited layout reports a rebuilt range");
9443 assert!(
9444 rebuilt.end.saturating_sub(rebuilt.start) <= 2,
9445 "{rebuilt:?}"
9446 );
9447 }
9448
9449 fn note_reference_cache_input(stream: NoteStream, include_second_note: bool) -> LayoutInput {
9450 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
9451
9452 let mut input = make_input_with_text("note cache paragraph 000");
9453 for index in 1..700 {
9454 let mut paragraph = CT_P::new();
9455 paragraph.add_run(&format!("note cache paragraph {index:03}"));
9456 input.document.body.add_paragraph(paragraph);
9457 }
9458 let BodyContent::Paragraph(reference) = &mut input.document.body.content[20] else {
9459 panic!("note reference belongs to a paragraph");
9460 };
9461 let mut marker = CT_R::new("");
9462 marker.content = vec![match stream {
9463 NoteStream::Footnote => RunContent::FootnoteRef { id: 1 },
9464 NoteStream::Endnote => RunContent::EndnoteRef { id: 1 },
9465 }];
9466 reference.runs.push(marker);
9467
9468 let note = |id, text: &str| {
9469 let mut paragraph = CT_P::new();
9470 paragraph.add_run(text);
9471 CT_Footnote {
9472 id,
9473 note_type: NoteType::Normal,
9474 paragraphs: vec![paragraph],
9475 }
9476 };
9477 let mut notes = vec![note(1, "first note text")];
9478 if include_second_note {
9479 notes.push(note(2, "second note text"));
9480 }
9481 let part = Some(CT_Footnotes { footnotes: notes });
9482 match stream {
9483 NoteStream::Footnote => input.footnotes = part,
9484 NoteStream::Endnote => input.endnotes = part,
9485 }
9486 input
9487 }
9488
9489 fn assert_note_reference_does_not_poison_later_hits(stream: NoteStream) {
9490 let mut input = note_reference_cache_input(stream, false);
9491 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9492 engine.layout(&input).expect("cold note layout succeeds");
9493 assert_eq!(engine.paragraph_cache_counts(), (0, 700));
9494
9495 set_body_paragraph_text(&mut input, 350, "note cache paragraph 350 changed");
9496 let warm = engine.layout(&input).expect("warm note layout succeeds");
9497 let fresh = Engine::new_deterministic()
9498 .expect("bundled fonts load")
9499 .layout(&input)
9500 .expect("fresh note layout succeeds");
9501
9502 assert_layout_results_equal(&warm, &fresh);
9503 assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9504 }
9505
9506 #[test]
9507 fn note_reference_does_not_poison_later_paragraph_cache_hits() {
9508 assert_note_reference_does_not_poison_later_hits(NoteStream::Footnote);
9509 }
9510
9511 #[test]
9512 fn endnote_reference_does_not_poison_later_paragraph_cache_hits() {
9513 assert_note_reference_does_not_poison_later_hits(NoteStream::Endnote);
9514 }
9515
9516 #[test]
9517 fn changed_note_reference_or_note_part_invalidates_required_cache_entry() {
9518 let mut input = note_reference_cache_input(NoteStream::Footnote, true);
9519 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9520 engine.layout(&input).expect("cold note layout succeeds");
9521 assert_eq!(engine.paragraph_cache_counts(), (0, 700));
9522
9523 let BodyContent::Paragraph(reference) = &mut input.document.body.content[20] else {
9524 panic!("note reference belongs to a paragraph");
9525 };
9526 reference.runs.last_mut().expect("marker run").content =
9527 vec![RunContent::FootnoteRef { id: 2 }];
9528 let warm_reference = engine
9529 .layout(&input)
9530 .expect("changed reference layout succeeds");
9531 let fresh_reference = Engine::new_deterministic()
9532 .expect("bundled fonts load")
9533 .layout(&input)
9534 .expect("fresh changed reference layout succeeds");
9535 assert_layout_results_equal(&warm_reference, &fresh_reference);
9536 assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9537
9538 input.footnotes.as_mut().expect("footnotes exist").footnotes[1].paragraphs[0].runs[0]
9539 .content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
9540 "second note text changed",
9541 ))];
9542 let warm_note = engine.layout(&input).expect("changed note layout succeeds");
9543 let fresh_note = Engine::new_deterministic()
9544 .expect("bundled fonts load")
9545 .layout(&input)
9546 .expect("fresh changed note layout succeeds");
9547 assert_layout_results_equal(&warm_note, &fresh_note);
9548 assert_eq!(engine.paragraph_cache_counts(), (699, 1_401));
9549 }
9550
9551 #[test]
9552 fn note_reference_warm_layout_equals_fresh_layout() {
9553 let mut input = related_story_restart_input(700);
9554 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9555 engine
9556 .layout(&input)
9557 .expect("cold related-story layout succeeds");
9558 set_body_paragraph_text(&mut input, 350, "related note paragraph changed");
9559
9560 let warm = engine
9561 .layout(&input)
9562 .expect("warm related-story layout succeeds");
9563 let fresh = Engine::new_deterministic()
9564 .expect("bundled fonts load")
9565 .layout(&input)
9566 .expect("fresh related-story layout succeeds");
9567 assert_layout_results_equal(&warm, &fresh);
9568 assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9569 }
9570
9571 fn mixed_editor_input() -> LayoutInput {
9572 let mut input = make_input_with_text("");
9573 input.document.body.content.clear();
9574 for index in 0..700 {
9575 let mut paragraph = CT_P::new();
9576 paragraph.add_run(&format!("편집 paragraph {index:03} stable line"));
9577 input.document.body.add_paragraph(paragraph);
9578 if index % 50 == 49 {
9579 input
9580 .document
9581 .body
9582 .add_table(safe_table(&format!("table {:02}", index / 50)));
9583 }
9584 }
9585 input
9586 }
9587
9588 fn mixed_editor_paragraph_mut(input: &mut LayoutInput, target: usize) -> &mut CT_P {
9589 input
9590 .document
9591 .body
9592 .content
9593 .iter_mut()
9594 .filter_map(|content| match content {
9595 BodyContent::Paragraph(paragraph) => Some(paragraph),
9596 BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
9597 None
9598 }
9599 })
9600 .nth(target)
9601 .expect("mixed editor paragraph exists")
9602 }
9603
9604 #[test]
9605 fn mixed_editor_relayout_reuses_every_safe_unchanged_block_and_page() {
9606 let mut input = mixed_editor_input();
9607 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9608 let initial = engine.layout(&input).expect("mixed cold layout");
9609 assert_eq!(engine.paragraph_cache_counts(), (0, 700));
9610 assert_eq!(engine.table_cache_counts(), (0, 14));
9611 assert_eq!(engine.hot_path_work_counts(), (0, 0));
9612
9613 mixed_editor_paragraph_mut(&mut input, 350).runs[0].content = vec![RunContent::Text(
9614 rdocx_oxml::text::CT_Text::new("편집 paragraph 350 changed line"),
9615 )];
9616 let warm = engine.layout(&input).expect("mixed warm layout");
9617 let fresh = Engine::new_deterministic()
9618 .expect("bundled fonts load")
9619 .layout(&input)
9620 .expect("mixed fresh layout");
9621 assert_layout_results_equal(&warm, &fresh);
9622 assert_eq!(engine.paragraph_cache_counts(), (699, 701));
9623 assert_eq!(engine.table_cache_counts(), (14, 14));
9624 assert_eq!(engine.hot_path_work_counts(), (0, 0));
9625 assert_eq!(engine.owned_context_build_count(), 1);
9626 let restart = engine.restart_cache.as_ref().unwrap_or_else(|| {
9627 panic!(
9628 "mixed restart retained for {} pages, candidate {} bytes",
9629 initial.pages.len(),
9630 engine.last_restart_candidate_bytes
9631 )
9632 });
9633 assert_restart_cache_within_aggregate(&engine);
9634 assert!(restart.checkpoints.len() <= RESTART_CACHE_MAX_ENTRIES);
9635 let rebuilt = engine
9636 .last_rebuilt_page_range
9637 .clone()
9638 .expect("mixed rebuilt range recorded");
9639 assert!(
9640 warm.pages
9641 .iter()
9642 .zip(&initial.pages)
9643 .take(rebuilt.start)
9644 .all(|(current, previous)| Arc::ptr_eq(current, previous))
9645 );
9646 assert!(
9647 warm.pages
9648 .iter()
9649 .zip(&initial.pages)
9650 .skip(rebuilt.end)
9651 .all(|(current, previous)| Arc::ptr_eq(current, previous))
9652 );
9653 }
9654
9655 #[test]
9656 fn mixed_editor_table_mutation_rebuilds_only_the_changed_table() {
9657 let mut input = mixed_editor_input();
9658 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9659 engine.layout(&input).expect("mixed cold layout");
9660 let BodyContent::Table(changed) = input
9661 .document
9662 .body
9663 .content
9664 .iter_mut()
9665 .filter(|content| matches!(content, BodyContent::Table(_)))
9666 .nth(7)
9667 .expect("eighth mixed table")
9668 else {
9669 panic!("mixed body item is a table");
9670 };
9671 changed.rows[0].cells[0].paragraphs_mut()[0].runs[0].content = vec![RunContent::Text(
9672 rdocx_oxml::text::CT_Text::new("changed table 07"),
9673 )];
9674
9675 let warm = engine.layout(&input).expect("mixed warm table layout");
9676 let fresh = Engine::new_deterministic()
9677 .expect("bundled fonts load")
9678 .layout(&input)
9679 .expect("mixed fresh table layout");
9680 assert_layout_results_equal(&warm, &fresh);
9681 assert_eq!(engine.paragraph_cache_counts(), (700, 700));
9682 assert_eq!(engine.table_cache_counts(), (13, 15));
9683 assert_eq!(engine.hot_path_work_counts(), (0, 0));
9684 }
9685
9686 #[test]
9687 fn unsafe_prefix_still_disables_later_paragraph_hits() {
9688 let mut field = CT_P::new();
9689 let mut field_run = CT_R::new("");
9690 field_run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
9691 field.runs.push(field_run);
9692
9693 let mut numbered = CT_P::new();
9694 numbered.add_run("numbered prefix");
9695 numbered.properties.get_or_insert_default().num_id = Some(1);
9696
9697 let mut drawing = CT_P::new();
9698 let mut drawing_run = CT_R::new("");
9699 drawing_run.content = vec![RunContent::Drawing(rdocx_oxml::drawing::CT_Drawing {
9700 inline: None,
9701 anchor: None,
9702 })];
9703 drawing.runs.push(drawing_run);
9704
9705 let mut raw = CT_P::new();
9706 raw.extra_xml.push((
9707 0,
9708 br#"<w:unknown xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>"#
9709 .to_vec(),
9710 ));
9711
9712 for (name, unsafe_paragraph) in [
9713 ("field", field),
9714 ("numbering", numbered),
9715 ("drawing", drawing),
9716 ("raw child", raw),
9717 ] {
9718 let mut input = make_input_with_text("safe cached suffix");
9719 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9720 engine.layout(&input).expect("prime safe suffix");
9721 input
9722 .document
9723 .body
9724 .content
9725 .insert(0, BodyContent::Paragraph(unsafe_paragraph));
9726
9727 let warm = engine.layout(&input).expect("warm unsafe-prefix layout");
9728 let cold = Engine::new_deterministic()
9729 .expect("bundled fonts load")
9730 .layout(&input)
9731 .expect("cold unsafe-prefix layout");
9732 assert_layout_results_equal(&warm, &cold);
9733 assert_eq!(engine.paragraph_cache_counts(), (0, 2), "{name}");
9734 }
9735 }
9736
9737 #[test]
9738 fn scaled_paragraph_cache_warm_equals_cold() {
9739 let mut input = make_input_with_text("warm-cold paragraph 000");
9740 for index in 1..700 {
9741 let mut paragraph = CT_P::new();
9742 paragraph.add_run(&format!("warm-cold paragraph {index:03}"));
9743 input.document.body.add_paragraph(paragraph);
9744 }
9745 let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9746 warm_engine
9747 .layout_with_provenance(&input)
9748 .expect("prime warm state");
9749 set_body_paragraph_text(&mut input, 349, "warm-cold paragraph 349 changed");
9750
9751 let warm = warm_engine
9752 .layout_with_provenance(&input)
9753 .expect("warm edited layout");
9754 let cold = Engine::new_deterministic()
9755 .expect("bundled fonts load")
9756 .layout_with_provenance(&input)
9757 .expect("cold edited layout");
9758 assert_layout_results_equal(&warm.0, &cold.0);
9759 assert_eq!(warm.1, cold.1);
9760 assert_eq!(format!("{:?}", warm.0), format!("{:?}", cold.0));
9761 }
9762
9763 #[test]
9764 fn warm_relayout_rebinds_font_tables_and_ids_to_the_current_result() {
9765 let mut input = make_input_with_text("font identity changes");
9766 {
9767 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9768 panic!("body paragraph");
9769 };
9770 paragraph.runs[0]
9771 .properties
9772 .get_or_insert_default()
9773 .font_ascii = Some("Carlito".to_owned());
9774 }
9775
9776 let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9777 warm_engine.layout(&input).expect("prime warm font state");
9778 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
9779 panic!("body paragraph");
9780 };
9781 paragraph.runs[0]
9782 .properties
9783 .get_or_insert_default()
9784 .font_ascii = Some("Caladea".to_owned());
9785
9786 let warm = warm_engine.layout(&input).expect("warm relayout succeeds");
9787 let cold = Engine::new_deterministic()
9788 .expect("bundled fonts load")
9789 .layout(&input)
9790 .expect("cold relayout succeeds");
9791 assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9792 assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", cold.fonts));
9793 }
9794
9795 #[test]
9796 fn warm_relayout_canonicalizes_the_same_fonts_in_new_resolution_order() {
9797 let mut input = make_input_with_text("first family");
9798 let BodyContent::Paragraph(first) = &mut input.document.body.content[0] else {
9799 panic!("body paragraph");
9800 };
9801 first.runs[0].properties.get_or_insert_default().font_ascii = Some("Carlito".to_owned());
9802 let mut second = CT_P::new();
9803 second
9804 .add_run("second family")
9805 .properties
9806 .get_or_insert_default()
9807 .font_ascii = Some("Caladea".to_owned());
9808 input.document.body.add_paragraph(second);
9809
9810 let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9811 warm_engine.layout(&input).expect("prime original order");
9812 input.document.body.content.swap(0, 1);
9813
9814 let warm = warm_engine.layout(&input).expect("warm reordered layout");
9815 let cold = Engine::new_deterministic()
9816 .expect("bundled fonts load")
9817 .layout(&input)
9818 .expect("cold reordered layout");
9819 assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9820 assert_eq!(format!("{:?}", warm.fonts), format!("{:?}", cold.fonts));
9821 }
9822
9823 #[test]
9824 fn shared_layout_context_changes_cannot_serve_stale_blocks() {
9825 let mut input = make_input_with_text("context-sensitive cache identity");
9826 let mut warm_engine = Engine::new_deterministic().expect("bundled fonts load");
9827 warm_engine.layout(&input).expect("prime context cache");
9828
9829 let normal = input
9830 .styles
9831 .styles
9832 .iter_mut()
9833 .find(|style| style.is_default)
9834 .expect("default style");
9835 normal.rpr.get_or_insert_default().font_ascii = Some("Caladea".to_owned());
9836 let warm = warm_engine.layout(&input).expect("warm style mutation");
9837 let cold = Engine::new_deterministic()
9838 .expect("bundled fonts load")
9839 .layout(&input)
9840 .expect("cold style mutation");
9841 assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9842
9843 input.numbering = Some(rdocx_oxml::numbering::CT_Numbering::new());
9844 let warm = warm_engine.layout(&input).expect("warm numbering mutation");
9845 let cold = Engine::new_deterministic()
9846 .expect("bundled fonts load")
9847 .layout(&input)
9848 .expect("cold numbering mutation");
9849 assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9850
9851 input.theme = Some(rdocx_oxml::theme::Theme::default());
9852 let warm = warm_engine.layout(&input).expect("warm theme mutation");
9853 let cold = Engine::new_deterministic()
9854 .expect("bundled fonts load")
9855 .layout(&input)
9856 .expect("cold theme mutation");
9857 assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9858
9859 input
9860 .hyperlink_urls
9861 .insert("rIdLink".to_owned(), "https://example.com".to_owned());
9862 input.images.insert(
9863 "rIdImage".to_owned(),
9864 crate::input::ImageData {
9865 data: vec![1, 2, 3],
9866 content_type: "image/png".to_owned(),
9867 },
9868 );
9869 let warm = warm_engine
9870 .layout(&input)
9871 .expect("warm relationship and image mutation");
9872 let cold = Engine::new_deterministic()
9873 .expect("bundled fonts load")
9874 .layout(&input)
9875 .expect("cold relationship and image mutation");
9876 assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9877
9878 input.fonts.push(oxml_layout::FontFile {
9879 family: "Embedded".to_owned(),
9880 data: oxml_layout::bundled_fonts::bundled_font_data()[0]
9881 .1
9882 .to_vec(),
9883 });
9884 let warm = warm_engine.layout(&input).expect("warm font mutation");
9885 let cold = Engine::new_deterministic()
9886 .expect("bundled fonts load")
9887 .layout(&input)
9888 .expect("cold font mutation");
9889 assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
9890
9891 let contextual = rdocx_oxml::CT_Document::from_xml(
9892 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:hyperlink r:id="rIdLink"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p><w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:body></w:document>"#,
9893 )
9894 .expect("contextual paragraphs parse");
9895 for content in &contextual.body.content {
9896 let BodyContent::Paragraph(paragraph) = content else {
9897 continue;
9898 };
9899 assert!(!paragraph_is_cache_safe(paragraph, &input.styles));
9900 }
9901 }
9902
9903 #[test]
9904 fn compatible_engine_take_reuses_normal_layout_work() {
9905 let mut source_input = make_input_with_text("unchanged paragraph");
9906 let mut changed = CT_P::new();
9907 changed.add_run("old second paragraph");
9908 source_input.document.body.add_paragraph(changed);
9909
9910 let mut source_engine = Engine::new_deterministic().expect("bundled fonts load");
9911 source_engine
9912 .layout(&source_input)
9913 .expect("prime reusable engine");
9914 assert_eq!(source_engine.paragraph_cache_counts(), (0, 2));
9915
9916 let mut receiver_input = source_input.clone();
9917 let BodyContent::Paragraph(second) = &mut receiver_input.document.body.content[1] else {
9918 panic!("second body paragraph");
9919 };
9920 second.runs[0].content[0] =
9921 RunContent::Text(rdocx_oxml::text::CT_Text::new("new second paragraph"));
9922
9923 let mut source = Some(source_engine);
9924 let mut transferred = Engine::take_if_compatible(&mut source, &receiver_input)
9925 .expect("matching context transfers");
9926 assert!(source.is_none());
9927 transferred
9928 .layout(&receiver_input)
9929 .expect("transferred layout succeeds");
9930 assert_eq!(transferred.paragraph_cache_counts(), (1, 3));
9931 }
9932
9933 #[test]
9934 fn incompatible_or_failed_engine_take_preserves_the_source() {
9935 fn assert_rejected(label: &str, mut receiver: LayoutInput) {
9936 let source_input = make_input_with_text("retained paragraph");
9937 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
9938 engine.layout(&source_input).expect("prime reusable engine");
9939 let mut source = Some(engine);
9940 assert!(
9941 Engine::take_if_compatible(&mut source, &receiver).is_none(),
9942 "{label} must reject transfer"
9943 );
9944 assert!(source.is_some());
9945 receiver.document.body.content.clear();
9946 }
9947
9948 let base = make_input_with_text("retained paragraph");
9949 let mut changed = base.clone();
9950 changed.revision_view = RevisionView::Tracked;
9951 assert_rejected("revision view", changed);
9952
9953 let wrapping = make_wrapping_document(
9954 WrapType::Square,
9955 Some(rdocx_oxml::drawing::AnchorAlignH::Left),
9956 100.0,
9957 40.0,
9958 5.0,
9959 );
9960 let mut changed = base.clone();
9961 changed.document = wrapping.document;
9962 assert_rejected("document wrapping state", changed);
9963
9964 let mut changed = base.clone();
9965 changed.styles = CT_Styles::new();
9966 assert_rejected("styles", changed);
9967
9968 let mut changed = base.clone();
9969 changed.numbering = Some(rdocx_oxml::numbering::CT_Numbering::new());
9970 assert_rejected("numbering", changed);
9971
9972 let mut changed = base.clone();
9973 changed.headers.insert(
9974 "rIdHeader".to_owned(),
9975 rdocx_oxml::header_footer::CT_HdrFtr::new(),
9976 );
9977 assert_rejected("headers", changed);
9978
9979 let mut changed = base.clone();
9980 changed.footers.insert(
9981 "rIdFooter".to_owned(),
9982 rdocx_oxml::header_footer::CT_HdrFtr::new(),
9983 );
9984 assert_rejected("footers", changed);
9985
9986 let mut changed = base.clone();
9987 changed.images.insert(
9988 "rIdImage".to_owned(),
9989 crate::input::ImageData {
9990 data: vec![1, 2, 3],
9991 content_type: "image/png".to_owned(),
9992 },
9993 );
9994 assert_rejected("images", changed);
9995
9996 let mut changed = base.clone();
9997 changed
9998 .charts
9999 .insert("rIdChart".to_owned(), Err("missing chart".to_owned()));
10000 assert_rejected("charts", changed);
10001
10002 let mut changed = base.clone();
10003 changed.chart_theme.name = Some("Changed".to_owned());
10004 assert_rejected("chart theme", changed);
10005
10006 let mut changed = base.clone();
10007 changed.core_properties = Some(rdocx_oxml::core_properties::CoreProperties {
10008 title: Some("Changed".to_owned()),
10009 ..Default::default()
10010 });
10011 assert_rejected("core properties", changed);
10012
10013 let mut changed = base.clone();
10014 changed
10015 .hyperlink_urls
10016 .insert("rIdLink".to_owned(), "https://example.com".to_owned());
10017 assert_rejected("hyperlinks", changed);
10018
10019 let mut changed = base.clone();
10020 changed.footnotes = Some(rdocx_oxml::footnotes::CT_Footnotes::new());
10021 assert_rejected("footnotes", changed);
10022
10023 let mut changed = base.clone();
10024 changed.endnotes = Some(rdocx_oxml::footnotes::CT_Footnotes::new());
10025 assert_rejected("endnotes", changed);
10026
10027 let mut changed = base.clone();
10028 changed.theme = Some(rdocx_oxml::theme::Theme::default());
10029 assert_rejected("theme", changed);
10030
10031 let mut changed = base.clone();
10032 changed.fonts.push(oxml_layout::FontFile {
10033 family: "Changed".to_owned(),
10034 data: vec![1, 2, 3],
10035 });
10036 assert_rejected("fonts", changed);
10037
10038 let mut changed = base;
10039 changed
10040 .document
10041 .body
10042 .sect_pr
10043 .get_or_insert_with(CT_SectPr::default_letter)
10044 .page_width = Some(rdocx_oxml::units::Twips(10_000));
10045 assert_rejected("sections", changed);
10046 }
10047
10048 #[test]
10049 fn alternate_content_drawings_bypass_paragraph_reuse() {
10050 let document = rdocx_oxml::CT_Document::from_xml(
10051 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><w:body><w:p><w:r><w:t>ordinary text</w:t></w:r><w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing><wp:anchor behindDoc="0"><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><a:graphic><a:graphicData><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice></mc:AlternateContent></w:r></w:p></w:body></w:document>"#,
10052 )
10053 .expect("AlternateContent drawing parses");
10054 let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
10055 panic!("body paragraph");
10056 };
10057 assert!(!paragraph.runs[1].alt_drawings.is_empty());
10058 assert!(!paragraph_is_cache_safe(
10059 paragraph,
10060 &CT_Styles::new_default()
10061 ));
10062 }
10063
10064 #[test]
10065 fn warm_provenance_rebinds_to_current_word_source_nodes() {
10066 let mut input = make_input_with_text("first paragraph");
10067 for text in ["second paragraph", "third paragraph"] {
10068 let mut paragraph = CT_P::new();
10069 paragraph.add_run(text);
10070 input.document.body.add_paragraph(paragraph);
10071 }
10072 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10073 engine
10074 .layout_with_provenance(&input)
10075 .expect("prime paragraph cache");
10076
10077 let moved = input.document.body.content.remove(2);
10078 input.document.body.content.insert(0, moved);
10079 let mut inserted = CT_P::new();
10080 inserted.add_run("new paragraph");
10081 input
10082 .document
10083 .body
10084 .content
10085 .insert(1, BodyContent::Paragraph(inserted));
10086 let (layout, sources) = engine
10087 .layout_with_provenance(&input)
10088 .expect("warm provenance layout");
10089
10090 for page in &layout.pages {
10091 oxml_layout::walk(&page.elements, &mut |element, _| {
10092 let PositionedElement::Text(run) = element else {
10093 return;
10094 };
10095 let Some(span) = run.source else {
10096 return;
10097 };
10098 let path = &sources[span.node.get() as usize - 1];
10099 assert_eq!(path.story, WordStory::Document);
10100 let BodyContent::Paragraph(paragraph) =
10101 &input.document.body.content[path.children[0]]
10102 else {
10103 panic!("source path resolves to a body paragraph");
10104 };
10105 let text = paragraph.text();
10106 let resolved = text
10107 .chars()
10108 .skip(span.char_start as usize)
10109 .take((span.char_end - span.char_start) as usize)
10110 .collect::<String>();
10111 assert_eq!(resolved, run.text);
10112 });
10113 }
10114 assert_eq!(engine.paragraph_cache_counts(), (3, 4));
10115 }
10116
10117 #[test]
10118 fn cached_heading_keeps_result_local_provenance() {
10119 fn heading_path(layout: &LayoutResult, sources: &[WordSourcePath]) -> Vec<usize> {
10120 layout
10121 .pages
10122 .iter()
10123 .flat_map(|page| compatibility_page_elements(page))
10124 .find_map(|element| match element {
10125 PositionedElement::Text(run) if run.text.contains("cached") => run
10126 .source
10127 .map(|source| sources[source.node.get() as usize - 1].children.clone()),
10128 _ => None,
10129 })
10130 .expect("cached heading keeps a source path")
10131 }
10132
10133 let mut input = make_input_with_text("ordinary first paragraph");
10134 let mut heading = CT_P::new();
10135 heading.properties = Some(CT_PPr {
10136 style_id: Some("Heading1".to_owned()),
10137 ..CT_PPr::default()
10138 });
10139 heading.add_run("cached heading");
10140 input.document.body.add_paragraph(heading);
10141
10142 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10143 let cold = engine
10144 .layout_with_provenance(&input)
10145 .expect("cold heading layout");
10146 assert_eq!(heading_path(&cold.0, &cold.1), vec![1]);
10147
10148 let mut inserted = CT_P::new();
10149 inserted.add_run("inserted before heading");
10150 input
10151 .document
10152 .body
10153 .content
10154 .insert(0, BodyContent::Paragraph(inserted));
10155 let warm = engine
10156 .layout_with_provenance(&input)
10157 .expect("warm heading layout");
10158 let fresh = Engine::new_deterministic()
10159 .expect("bundled fonts load")
10160 .layout_with_provenance(&input)
10161 .expect("fresh heading layout");
10162
10163 assert_layout_results_equal(&warm.0, &fresh.0);
10164 assert_eq!(warm.1, fresh.1);
10165 assert_eq!(heading_path(&warm.0, &warm.1), vec![2]);
10166 assert_eq!(engine.paragraph_cache_counts(), (2, 3));
10167 }
10168
10169 #[test]
10170 fn complex_heading_rebinds_rich_line_and_reflow_sources() {
10171 let mut input = make_input_with_text("العنوان المخزن");
10172 if let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] {
10173 paragraph.properties = Some(CT_PPr {
10174 style_id: Some("Heading1".to_owned()),
10175 ..CT_PPr::default()
10176 });
10177 } else {
10178 panic!("expected paragraph")
10179 }
10180 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
10181 unreachable!("paragraph checked above")
10182 };
10183 let media = MediaRegistry::new(&input.images);
10184 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
10185 let mut numbering = NumberingState::new();
10186 let mut diagnostics = Vec::new();
10187 let mut block = layout_paragraph_with_source(
10188 paragraph,
10189 468.0,
10190 &input.styles,
10191 &input,
10192 &media,
10193 &mut fonts,
10194 &mut numbering,
10195 &mut diagnostics,
10196 Some(CACHE_SOURCE_NODE),
10197 )
10198 .expect("complex heading lays out");
10199
10200 let rebound = SourceNodeId::new(2).expect("source ID");
10201 rebind_paragraph_source(&mut block, Some(rebound)).expect("source rebinding succeeds");
10202 let line_sources = block
10203 .lines
10204 .iter()
10205 .flat_map(|line| &line.items)
10206 .filter_map(|item| match item {
10207 LineItem::MultilingualText(segment) => Some(segment.base().source),
10208 _ => None,
10209 })
10210 .collect::<Vec<_>>();
10211 assert!(!line_sources.is_empty());
10212 assert!(
10213 line_sources
10214 .iter()
10215 .all(|source| source.is_some_and(|source| source.node == rebound))
10216 );
10217 assert!(block
10218 .reflow
10219 .as_ref()
10220 .expect("heading retains reflow inputs")
10221 .items
10222 .iter()
10223 .all(|item| {
10224 !matches!(item, InlineItem::MultilingualText(segment) if !segment.base().source.is_some_and(|source| source.node == rebound))
10225 }));
10226 }
10227
10228 #[test]
10229 fn cached_header_rebinds_hyphenated_reflow_sources() {
10230 let input = hyphenation_input(true, Some("en-US"), false);
10231 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
10232 panic!("expected paragraph")
10233 };
10234 let media = MediaRegistry::new(&input.images);
10235 let mut fonts = FontManager::new_deterministic().expect("bundled fonts load");
10236 let mut numbering = NumberingState::new();
10237 let mut diagnostics = Vec::new();
10238 let mut block = layout_paragraph_with_source(
10239 paragraph,
10240 468.0,
10241 &input.styles,
10242 &input,
10243 &media,
10244 &mut fonts,
10245 &mut numbering,
10246 &mut diagnostics,
10247 Some(CACHE_SOURCE_NODE),
10248 )
10249 .expect("hyphenated header paragraph lays out");
10250 assert!(
10251 block
10252 .reflow
10253 .as_ref()
10254 .expect("header retains reflow inputs")
10255 .items
10256 .iter()
10257 .any(|item| matches!(item, InlineItem::HyphenatedText { .. }))
10258 );
10259
10260 let rebound = SourceNodeId::new(74).expect("header source ID");
10261 rebind_paragraph_source(&mut block, Some(rebound)).expect("source rebinding succeeds");
10262 assert!(
10263 block
10264 .reflow
10265 .as_ref()
10266 .expect("header retains reflow inputs")
10267 .items
10268 .iter()
10269 .all(|item| {
10270 !matches!(
10271 item,
10272 InlineItem::HyphenatedText { segment, .. }
10273 if !segment.source.is_some_and(|source| source.node == rebound)
10274 )
10275 })
10276 );
10277 }
10278
10279 #[test]
10280 fn overflowed_table_font_trace_keeps_result_local_provenance() {
10281 let mut input = make_input_with_text("before overflow table");
10282 let mut table = safe_table("");
10283 let paragraph = &mut table.rows[0].cells[0].paragraphs_mut()[0];
10284 paragraph.runs.clear();
10285 for _ in 0..4_100 {
10286 paragraph.runs.push(CT_R::new("x"));
10287 }
10288 input.document.body.add_table(table);
10289
10290 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10291 let (layout, sources) = engine
10292 .layout_with_provenance(&input)
10293 .expect("overflowed table trace still lays out");
10294
10295 assert!(engine.table_cache.is_empty());
10296 let source = layout
10297 .pages
10298 .iter()
10299 .flat_map(|page| compatibility_page_elements(page))
10300 .find_map(|element| match element {
10301 PositionedElement::Text(run) if run.text.contains('x') => run.source,
10302 _ => None,
10303 })
10304 .expect("table glyph keeps provenance after trace overflow");
10305 assert_eq!(
10306 sources[source.node.get() as usize - 1].children,
10307 vec![1, 0, 0, 0]
10308 );
10309 }
10310
10311 #[test]
10312 fn restart_body_accounting_charges_cache_safe_property_payloads() {
10313 let mut paragraph = CT_P::new();
10314 paragraph.properties = Some(CT_PPr {
10315 style_id: Some("p".repeat(LEGACY_RESTART_CACHE_MAX_BYTES + 1)),
10316 ..CT_PPr::default()
10317 });
10318 let paragraph_entry = RestartBodyEntry::for_content(
10319 &BodyContent::Paragraph(paragraph),
10320 RevisionView::Accepted,
10321 )
10322 .expect("paragraph has restart identity");
10323 assert!(paragraph_entry.bytes() > LEGACY_RESTART_CACHE_MAX_BYTES);
10324
10325 let mut table = safe_table("property accounting");
10326 table.properties = Some(rdocx_oxml::table::CT_TblPr {
10327 style_id: Some("t".repeat(4_096)),
10328 ..rdocx_oxml::table::CT_TblPr::default()
10329 });
10330 table.rows[0].properties = Some(rdocx_oxml::table::CT_TrPr {
10331 height: Some(rdocx_oxml::units::Twips(1)),
10332 height_rule: Some("r".repeat(4_096)),
10333 ..rdocx_oxml::table::CT_TrPr::default()
10334 });
10335 table.rows[0].cells[0].properties = Some(rdocx_oxml::table::CT_TcPr {
10336 text_direction: Some("c".repeat(4_096)),
10337 ..rdocx_oxml::table::CT_TcPr::default()
10338 });
10339 let table_entry =
10340 RestartBodyEntry::for_content(&BodyContent::Table(table), RevisionView::Accepted)
10341 .expect("table has restart identity");
10342 assert!(table_entry.bytes() >= 3 * 4_096);
10343 }
10344
10345 #[test]
10346 fn restart_body_identity_is_exact_for_all_run_language_state() {
10347 let mut paragraph = CT_P::new();
10348 let mut run = CT_R::new("representation");
10349 run.properties = Some(CT_RPr {
10350 language: Some("en-US".to_owned()),
10351 language_east_asia: Some("ja-JP".to_owned()),
10352 language_bidi: Some("ar-SA".to_owned()),
10353 language_extra_attributes: vec![("data".to_owned(), "one".to_owned())],
10354 ..CT_RPr::default()
10355 });
10356 paragraph.runs.push(run);
10357 let retained = RestartBodyEntry::for_content(
10358 &BodyContent::Paragraph(paragraph.clone()),
10359 RevisionView::Accepted,
10360 )
10361 .expect("paragraph has restart identity");
10362
10363 let mut changed = Vec::new();
10364 for field in 0..4 {
10365 let mut candidate = paragraph.clone();
10366 let properties = candidate.runs[0]
10367 .properties
10368 .as_mut()
10369 .expect("run properties exist");
10370 match field {
10371 0 => properties.language = Some("en-GB".to_owned()),
10372 1 => properties.language_east_asia = Some("zh-CN".to_owned()),
10373 2 => properties.language_bidi = Some("he-IL".to_owned()),
10374 3 => properties.language_extra_attributes[0].1 = "two".to_owned(),
10375 _ => unreachable!(),
10376 }
10377 changed.push(candidate);
10378 }
10379
10380 for candidate in changed {
10381 assert_eq!(
10382 paragraph_fingerprint(¶graph),
10383 paragraph_fingerprint(&candidate)
10384 );
10385 assert!(!retained.matches(&BodyContent::Paragraph(candidate)));
10386 }
10387 }
10388
10389 #[test]
10390 fn shared_cached_blocks_keep_result_local_semantics_exact() {
10391 fn has_semantic_mark(elements: &[PositionedElement]) -> bool {
10392 elements.iter().any(|element| match element {
10393 PositionedElement::MarkedContent {
10394 structure: Some(_), ..
10395 } => true,
10396 PositionedElement::MarkedContent { children, .. } => has_semantic_mark(children),
10397 PositionedElement::Group(group) => has_semantic_mark(&group.children),
10398 _ => false,
10399 })
10400 }
10401
10402 let mut input = make_input_with_text("before shared table");
10403 input
10404 .document
10405 .body
10406 .add_table(safe_nested_table("outer cell", "nested cell"));
10407 let mut after = CT_P::new();
10408 after.add_run("after shared table");
10409 input.document.body.add_paragraph(after);
10410
10411 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10412 engine
10413 .layout_with_provenance(&input)
10414 .expect("prime shared cache payloads");
10415 assert_eq!(engine.last_shared_block_counts, (2, 1));
10416
10417 let mut inserted = CT_P::new();
10418 inserted.add_run("inserted before shared payloads");
10419 input
10420 .document
10421 .body
10422 .content
10423 .insert(0, BodyContent::Paragraph(inserted));
10424 let warm = engine
10425 .layout_with_provenance(&input)
10426 .expect("warm shared layout");
10427 let fresh = Engine::new_deterministic()
10428 .expect("bundled fonts load")
10429 .layout_with_provenance(&input)
10430 .expect("fresh shared comparison");
10431
10432 assert_layout_results_equal(&warm.0, &fresh.0);
10433 assert_eq!(warm.0.structure, fresh.0.structure);
10434 assert_eq!(warm.1, fresh.1);
10435 assert_eq!(format!("{:?}", warm.0), format!("{:?}", fresh.0));
10436 assert_eq!(engine.last_shared_block_counts, (3, 1));
10437 assert_eq!(engine.paragraph_cache_counts(), (2, 3));
10438 assert_eq!(engine.table_cache_counts(), (1, 1));
10439
10440 let sourced_runs = warm
10441 .0
10442 .pages
10443 .iter()
10444 .flat_map(|page| compatibility_page_elements(page))
10445 .filter_map(|element| match element {
10446 PositionedElement::Text(run) => Some((run.text.as_str(), run.source)),
10447 _ => None,
10448 })
10449 .collect::<Vec<_>>();
10450 for (text, expected_path) in [
10451 ("outer ", vec![2, 0, 0, 0]),
10452 ("nested ", vec![2, 0, 0, 1, 0, 0, 0]),
10453 ] {
10454 let source = sourced_runs
10455 .iter()
10456 .find_map(|(run_text, source)| (*run_text == text).then_some(*source).flatten())
10457 .unwrap_or_else(|| panic!("{text:?} keeps result-local provenance"));
10458 assert_eq!(
10459 warm.1[source.node.get() as usize - 1].children,
10460 expected_path
10461 );
10462 }
10463 assert!(
10464 warm.0
10465 .pages
10466 .iter()
10467 .any(|page| has_semantic_mark(&page.elements))
10468 );
10469 }
10470
10471 fn safe_table(text: &str) -> CT_Tbl {
10472 let mut table = CT_Tbl::new();
10473 let mut row = CT_Row::new();
10474 let mut cell = CT_Tc::new();
10475 cell.paragraphs_mut()[0].add_run(text);
10476 row.cells.push(cell);
10477 table.rows.push(row);
10478 table
10479 }
10480
10481 fn safe_nested_table(outer_text: &str, nested_text: &str) -> CT_Tbl {
10482 let mut table = safe_table(outer_text);
10483 table.rows[0].cells[0]
10484 .content
10485 .push(CellContent::Table(safe_table(nested_text)));
10486 table
10487 }
10488
10489 fn rtl_multi_leader_paragraph(parts: [&str; 4]) -> CT_P {
10490 use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
10491 use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
10492 use rdocx_oxml::units::Twips;
10493
10494 let mut paragraph = CT_P::new();
10495 paragraph.properties = Some(CT_PPr {
10496 bidi: Some(true),
10497 tabs: Some(CT_Tabs {
10498 tabs: vec![
10499 CT_TabStop {
10500 val: ST_TabJc::Left,
10501 pos: Twips(1_200),
10502 leader: None,
10503 source_occurrence: None,
10504 },
10505 CT_TabStop {
10506 val: ST_TabJc::Left,
10507 pos: Twips(2_400),
10508 leader: Some(ST_TabLeader::Dot),
10509 source_occurrence: None,
10510 },
10511 CT_TabStop {
10512 val: ST_TabJc::Left,
10513 pos: Twips(3_600),
10514 leader: Some(ST_TabLeader::Hyphen),
10515 source_occurrence: None,
10516 },
10517 ],
10518 }),
10519 ..Default::default()
10520 });
10521 let mut run = CT_R::new("");
10522 run.content = vec![
10523 RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[0])),
10524 RunContent::Tab,
10525 RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[1])),
10526 RunContent::Tab,
10527 RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[2])),
10528 RunContent::Tab,
10529 RunContent::Text(rdocx_oxml::text::CT_Text::new(parts[3])),
10530 ];
10531 paragraph.runs = vec![run];
10532 paragraph
10533 }
10534
10535 #[test]
10536 fn cached_table_and_header_keep_resolved_rtl_for_logical_extraction() {
10537 let mut table = CT_Tbl::new();
10538 let mut row = CT_Row::new();
10539 let mut cell = CT_Tc::new();
10540 cell.content = vec![CellContent::Paragraph(rtl_multi_leader_paragraph([
10541 "TA", "TB", "TC", "TD",
10542 ]))];
10543 row.cells.push(cell);
10544 table.rows.push(row);
10545 let mut table_input = make_input_with_text("body");
10546 let media = MediaRegistry::new(&table_input.images);
10547 let mut direction_engine = Engine::new_deterministic().expect("bundled fonts load");
10548 let mut numbering = NumberingState::new();
10549 let mut diagnostics = Vec::new();
10550 let shared = direction_engine
10551 .layout_body_table(
10552 &table,
10553 468.0,
10554 &table_input.styles,
10555 &table_input,
10556 &media,
10557 &mut numbering,
10558 &mut diagnostics,
10559 None,
10560 &WordStory::Document,
10561 &[0],
10562 )
10563 .expect("real table container lays out");
10564 let SharedLayoutBlock::Table { semantics, .. } = shared else {
10565 panic!("cache-safe table uses the shared table container")
10566 };
10567 let CellBlockSemantics::Paragraph(table_paragraph) = &semantics.rows[0].cells[0].blocks[0]
10568 else {
10569 panic!("table paragraph semantics")
10570 };
10571 assert_eq!(
10572 table_paragraph.reflow_direction,
10573 TextDirection::RightToLeft,
10574 "the production table container retains its resolved paragraph base"
10575 );
10576 table_input
10577 .document
10578 .body
10579 .content
10580 .insert(0, BodyContent::Table(table));
10581 let mut table_engine = Engine::new_deterministic().expect("bundled fonts load");
10582 table_engine
10583 .layout_with_provenance(&table_input)
10584 .expect("cold table layout");
10585 let (table_warm, table_sources) = table_engine
10586 .layout_with_provenance(&table_input)
10587 .expect("warm table layout");
10588 assert_eq!(table_engine.table_cache_counts().1, 2);
10589
10590 let mut header_input = cacheable_header_footer_input(&"body line ".repeat(4_000));
10591 for header in header_input.headers.values_mut() {
10592 header.paragraphs = vec![rtl_multi_leader_paragraph(["HA", "HB", "HC", "HD"])];
10593 }
10594 let section = header_input
10595 .document
10596 .body
10597 .sect_pr
10598 .as_ref()
10599 .expect("header section")
10600 .clone();
10601 let media = MediaRegistry::new(&header_input.images);
10602 let mut direction_header_engine = Engine::new_deterministic().expect("bundled fonts load");
10603 let mut numbering = NumberingState::new();
10604 let mut diagnostics = Vec::new();
10605 let (_, semantics) = layout_header_footer(
10606 &mut direction_header_engine,
10607 §ion,
10608 &header_input,
10609 &header_input.styles,
10610 &media,
10611 &mut numbering,
10612 &mut diagnostics,
10613 None,
10614 )
10615 .expect("real header cache container lays out")
10616 .expect("header content exists");
10617 assert_eq!(
10618 semantics.first_header_directions,
10619 [TextDirection::RightToLeft],
10620 "the production header cache container retains its resolved paragraph base"
10621 );
10622 let mut header_engine = Engine::new_deterministic().expect("bundled fonts load");
10623 header_engine
10624 .layout_with_provenance(&header_input)
10625 .expect("cold header layout");
10626 let (header_warm, header_sources) = header_engine
10627 .layout_with_provenance(&header_input)
10628 .expect("warm header layout");
10629 let header_counts = header_engine.header_footer_cache_counts();
10630 assert!(
10631 header_counts.0 > 0,
10632 "header cache is traversed: {header_counts:?}"
10633 );
10634
10635 let assert_line = |warm: &LayoutResult,
10636 sources: &[WordSourcePath],
10637 story: &WordStory,
10638 children: &[usize],
10639 parts: [&str; 4]| {
10640 let prefix = parts[0];
10641 let mut located = None;
10642 for (page_index, page) in warm.pages.iter().enumerate() {
10643 for element in compatibility_page_elements(page) {
10644 let (text, source, y) = match element {
10645 PositionedElement::Text(run) => {
10646 (run.text.as_str(), run.source, run.origin.y)
10647 }
10648 PositionedElement::MultilingualText(run) => {
10649 (run.logical_text.as_str(), run.source, run.origin.y)
10650 }
10651 _ => continue,
10652 };
10653 if text == prefix {
10654 located = source.map(|source| (page_index, source.node, y));
10655 break;
10656 }
10657 }
10658 if located.is_some() {
10659 break;
10660 }
10661 }
10662 let (page_index, node, line_y) = located.unwrap_or_else(|| {
10663 let text = warm
10664 .pages
10665 .iter()
10666 .flat_map(|page| compatibility_page_elements(page))
10667 .filter_map(|element| match element {
10668 PositionedElement::Text(run) => Some(run.text.clone()),
10669 PositionedElement::MultilingualText(run) => Some(run.logical_text.clone()),
10670 _ => None,
10671 })
10672 .collect::<Vec<_>>();
10673 panic!("missing {prefix}: {text:?}")
10674 });
10675 let path = &sources[node.get() as usize - 1];
10676 assert_eq!(&path.story, story);
10677 assert_eq!(path.children, children);
10678 let runs = compatibility_page_elements(&warm.pages[page_index])
10679 .into_iter()
10680 .filter_map(|element| match element {
10681 PositionedElement::Text(run) if (run.origin.y - line_y).abs() < 0.01 => {
10682 Some((run.text.clone(), run.origin.x))
10683 }
10684 PositionedElement::MultilingualText(run)
10685 if (run.origin.y - line_y).abs() < 0.01 =>
10686 {
10687 Some((run.logical_text.clone(), run.origin.x))
10688 }
10689 _ => None,
10690 })
10691 .filter(|(text, _)| {
10692 parts.contains(&text.as_str())
10693 || (!text.is_empty()
10694 && text.chars().all(|character| matches!(character, '.' | '-')))
10695 })
10696 .collect::<Vec<_>>();
10697 let text = runs
10698 .iter()
10699 .map(|(text, _)| text.as_str())
10700 .collect::<String>();
10701 let positions = parts.map(|part| {
10702 text.find(part)
10703 .unwrap_or_else(|| panic!("missing {part} in {text:?}"))
10704 });
10705 assert!(
10706 positions.windows(2).all(|pair| pair[0] < pair[1]),
10707 "logical extraction for {story:?}: {runs:?}"
10708 );
10709 let dots = text.find('.').expect("dot leader");
10710 let dashes = text.find('-').expect("hyphen leader");
10711 assert!(
10712 positions[1] < dots
10713 && dots < positions[2]
10714 && positions[2] < dashes
10715 && dashes < positions[3],
10716 "leaders keep their logical tabs for {story:?}: {runs:?}"
10717 );
10718 assert!(
10719 runs.iter().find(|run| run.0 == parts[0]).unwrap().1
10720 > runs.iter().find(|run| run.0 == parts[3]).unwrap().1,
10721 "RTL visual origins survive for {story:?}: {runs:?}"
10722 );
10723 };
10724
10725 assert_line(
10726 &table_warm,
10727 &table_sources,
10728 &WordStory::Document,
10729 &[0, 0, 0, 0],
10730 ["TA", "TB", "TC", "TD"],
10731 );
10732 assert_line(
10733 &header_warm,
10734 &header_sources,
10735 &WordStory::Header {
10736 relationship_id: "rId-first-header".to_owned(),
10737 },
10738 &[0],
10739 ["HA", "HB", "HC", "HD"],
10740 );
10741 }
10742
10743 fn assert_layout_results_equal(left: &LayoutResult, right: &LayoutResult) {
10744 assert_eq!(left.pages.len(), right.pages.len());
10745 for (left, right) in left.pages.iter().zip(&right.pages) {
10746 assert_eq!(left.page_number, right.page_number);
10747 assert_eq!(left.width, right.width);
10748 assert_eq!(left.height, right.height);
10749 assert_eq!(left.elements, right.elements);
10750 assert_eq!(left.background, right.background);
10751 }
10752 assert_eq!(left.fonts.len(), right.fonts.len());
10753 for (left, right) in left.fonts.iter().zip(&right.fonts) {
10754 assert_eq!(left.id, right.id);
10755 assert_eq!(left.family, right.family);
10756 assert_eq!(left.data, right.data);
10757 assert_eq!(left.face_index, right.face_index);
10758 assert_eq!(left.bold, right.bold);
10759 assert_eq!(left.italic, right.italic);
10760 }
10761 match (&left.metadata, &right.metadata) {
10762 (Some(left), Some(right)) => {
10763 assert_eq!(left.title, right.title);
10764 assert_eq!(left.author, right.author);
10765 assert_eq!(left.subject, right.subject);
10766 assert_eq!(left.keywords, right.keywords);
10767 assert_eq!(left.creator, right.creator);
10768 }
10769 (None, None) => {}
10770 _ => panic!("layout metadata presence differs"),
10771 }
10772 assert_eq!(left.diagnostics, right.diagnostics);
10773 assert_eq!(left.outlines.len(), right.outlines.len());
10774 for (left, right) in left.outlines.iter().zip(&right.outlines) {
10775 assert_eq!(left.title, right.title);
10776 assert_eq!(left.level, right.level);
10777 assert_eq!(left.page_index, right.page_index);
10778 assert_eq!(left.y_position, right.y_position);
10779 }
10780 assert_eq!(left.structure, right.structure);
10781 assert_eq!(format!("{left:#?}"), format!("{right:#?}"));
10782 }
10783
10784 #[test]
10785 fn earlier_note_insertion_invalidates_later_cached_markers() {
10786 let mut input = make_input_with_text("safe prefix");
10787 let mut later = CT_P::new();
10788 later.add_run("safe suffix");
10789 input.document.body.add_paragraph(later);
10790 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10791 let original = engine.layout(&input).expect("initial layout");
10792
10793 let mut note_paragraph = CT_P::new();
10794 let mut note_run = CT_R::new("");
10795 note_run.content = vec![RunContent::FootnoteRef { id: 7 }];
10796 note_paragraph.runs.push(note_run);
10797 input
10798 .document
10799 .body
10800 .content
10801 .insert(1, BodyContent::Paragraph(note_paragraph));
10802 let warm_insert = engine.layout(&input).expect("warm insertion layout");
10803 let cold_insert = Engine::new_deterministic()
10804 .expect("bundled fonts load")
10805 .layout(&input)
10806 .expect("cold insertion layout");
10807 assert_layout_results_equal(&warm_insert, &cold_insert);
10808 assert_eq!(engine.paragraph_cache_counts(), (2, 3));
10809
10810 input.document.body.content.remove(1);
10811 let warm_delete = engine.layout(&input).expect("warm deletion layout");
10812 let cold_delete = Engine::new_deterministic()
10813 .expect("bundled fonts load")
10814 .layout(&input)
10815 .expect("cold deletion layout");
10816 assert_layout_results_equal(&warm_delete, &cold_delete);
10817 assert_layout_results_equal(&original, &warm_delete);
10818 assert_eq!(engine.paragraph_cache_counts(), (4, 3));
10819 }
10820
10821 #[test]
10822 fn dense_form_caches_are_transactional_bounded_and_exact() {
10823 let mut input = make_input_with_text("before table");
10824 input
10825 .document
10826 .body
10827 .content
10828 .push(BodyContent::Table(safe_nested_table(
10829 "cached outer cell",
10830 "cached nested cell",
10831 )));
10832 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
10833 let cold = engine.layout(&input).expect("cold table layout");
10834 let warm = engine.layout(&input).expect("warm table layout");
10835 assert_layout_results_equal(&cold, &warm);
10836 assert_eq!(engine.table_cache_counts(), (1, 1));
10837
10838 let mut provenance_input = input.clone();
10839 let mut provenance_engine = Engine::new_deterministic().expect("bundled fonts load");
10840 provenance_engine
10841 .layout_with_provenance(&provenance_input)
10842 .expect("prime sourced table cache");
10843 let mut inserted = CT_P::new();
10844 inserted.add_run("inserted before table");
10845 provenance_input
10846 .document
10847 .body
10848 .content
10849 .insert(1, BodyContent::Paragraph(inserted));
10850 let (provenance_layout, sources) = provenance_engine
10851 .layout_with_provenance(&provenance_input)
10852 .expect("warm sourced table layout");
10853 let sourced_runs = provenance_layout
10854 .pages
10855 .iter()
10856 .flat_map(|page| compatibility_page_elements(page))
10857 .filter_map(|element| match element {
10858 PositionedElement::Text(run) => Some((run.text.clone(), run.source)),
10859 _ => None,
10860 })
10861 .collect::<Vec<_>>();
10862 let cached_outer_cell = sourced_runs
10863 .iter()
10864 .find_map(|(text, source)| (text == "outer ").then_some(*source).flatten())
10865 .unwrap_or_else(|| panic!("cached outer cell keeps provenance: {sourced_runs:?}"));
10866 assert_eq!(
10867 sources[cached_outer_cell.node.get() as usize - 1].children,
10868 [2, 0, 0, 0]
10869 );
10870 let cached_nested_cell = sourced_runs
10871 .iter()
10872 .find_map(|(text, source)| (text == "nested ").then_some(*source).flatten())
10873 .unwrap_or_else(|| panic!("cached nested cell keeps provenance: {sourced_runs:?}"));
10874 assert_eq!(
10875 sources[cached_nested_cell.node.get() as usize - 1].children,
10876 [2, 0, 0, 1, 0, 0, 0]
10877 );
10878 assert_eq!(provenance_engine.table_cache_counts(), (1, 1));
10879
10880 let mut bounded = make_input_with_text("bounded prefix");
10881 for index in 0..(TABLE_CACHE_MAX_ENTRIES + 8) {
10882 bounded
10883 .document
10884 .body
10885 .content
10886 .push(BodyContent::Table(safe_table(&format!("table {index}"))));
10887 }
10888 let mut bounded_engine = Engine::new_deterministic().expect("bundled fonts load");
10889 bounded_engine
10890 .layout(&bounded)
10891 .expect("bounded table layout");
10892 assert!(bounded_engine.table_cache.len() <= TABLE_CACHE_MAX_ENTRIES);
10893 assert!(bounded_engine.table_cache_bytes <= TABLE_CACHE_MAX_BYTES);
10894 assert!(bounded_engine.pending_table_cache_peak_entries <= TABLE_CACHE_MAX_ENTRIES);
10895 assert!(bounded_engine.pending_table_cache_peak_bytes <= TABLE_CACHE_MAX_BYTES);
10896
10897 let mut retained_border_block = engine
10898 .table_cache
10899 .back()
10900 .expect("safe table retained")
10901 .block
10902 .as_ref()
10903 .clone();
10904 let mut color = String::with_capacity(TABLE_CACHE_MAX_BYTES + 1);
10905 color.push_str("00");
10906 let mut edge =
10907 rdocx_oxml::borders::CT_BorderEdge::new(rdocx_oxml::shared::ST_Border::Single);
10908 edge.color = Some(color);
10909 let table::CellBlock::Table(nested_block) =
10910 &mut retained_border_block.rows[0].cells[0].blocks[1]
10911 else {
10912 panic!("cached form retains the nested table block");
10913 };
10914 nested_block.borders = Some(rdocx_oxml::table::CT_TblBorders {
10915 top: Some(edge),
10916 ..Default::default()
10917 });
10918 assert!(table_block_retained_bytes(&retained_border_block) > TABLE_CACHE_MAX_BYTES);
10919
10920 let mut unsafe_table = safe_table("numbered cell");
10921 unsafe_table.rows[0].cells[0].paragraphs_mut()[0]
10922 .properties
10923 .get_or_insert_default()
10924 .num_id = Some(1);
10925 assert!(!table_is_cache_safe(&unsafe_table, &input.styles));
10926
10927 let mut preserved_table = safe_table("preserved properties");
10928 preserved_table
10929 .properties
10930 .get_or_insert_default()
10931 .revision_xml
10932 .push(br#"<w:unknown/>"#.to_vec());
10933 assert!(!table_is_cache_safe(&preserved_table, &input.styles));
10934
10935 let mut preserved_cell = safe_table("preserved cell properties");
10936 preserved_cell.rows[0].cells[0]
10937 .properties
10938 .get_or_insert_default()
10939 .extra_xml
10940 .push((0, br#"<w:unknown/>"#.to_vec()));
10941 assert!(!table_is_cache_safe(&preserved_cell, &input.styles));
10942 }
10943
10944 fn restart_input() -> LayoutInput {
10945 let mut input = make_input_with_text("paragraph 000 stable line");
10946 for index in 1..140 {
10947 let mut paragraph = CT_P::new();
10948 paragraph.add_run(&format!("paragraph {index:03} stable line"));
10949 input.document.body.add_paragraph(paragraph);
10950 }
10951 input
10952 }
10953
10954 fn ordinary_prose_restart_input(paragraph_count: usize) -> LayoutInput {
10955 let mut input = make_input_with_text("");
10956 input.document.body.content.clear();
10957 for index in 0..paragraph_count {
10958 let mut paragraph = CT_P::new();
10959 if index == 10 {
10960 paragraph.properties = Some(CT_PPr {
10961 style_id: Some("Heading1".to_owned()),
10962 ..CT_PPr::default()
10963 });
10964 } else if index == 11 {
10965 paragraph.properties.get_or_insert_default().keep_next = Some(true);
10966 } else if index == 13 {
10967 paragraph.properties.get_or_insert_default().keep_lines = Some(true);
10968 }
10969 let text = if index == 20 {
10970 "ordinary multiline paragraph wraps without splitting across pages ".repeat(8)
10971 } else {
10972 format!("ordinary prose paragraph {index:03} stable line")
10973 };
10974 paragraph.add_run(&text);
10975 input.document.body.add_paragraph(paragraph);
10976 }
10977 input
10978 }
10979
10980 fn page_spanning_prose_paragraph(index: usize) -> CT_P {
10981 let mut paragraph = CT_P::new();
10982 paragraph.add_run(&format!(
10983 "Paragraph {index}: the quick brown fox jumps over the lazy dog, pack my box \
10984 with five dozen liquor jugs, and a mixed sentence that keeps going. \
10985 Sphinx of black quartz, judge my vow across line breaks and pages. \
10986 Waltz, bad nymph, for quick jigs vex. Glib jocks quiz nymph to vex dwarf. \
10987 Bright vixens jump, dozy fowl quack."
10988 ));
10989 paragraph
10990 }
10991
10992 fn page_spanning_prose_restart_input(paragraph_count: usize) -> LayoutInput {
10993 let mut input = make_input_with_text("");
10994 input.document.body.content = (0..paragraph_count)
10995 .map(|index| BodyContent::Paragraph(page_spanning_prose_paragraph(index)))
10996 .collect();
10997 input.core_properties = Some(rdocx_oxml::core_properties::CoreProperties {
10998 title: Some("Issue 67 page-spanning prose".to_owned()),
10999 creator: Some("rdocx-layout regression".to_owned()),
11000 subject: Some("restart pagination".to_owned()),
11001 keywords: Some("page-spanning,provenance".to_owned()),
11002 ..Default::default()
11003 });
11004 input
11005 }
11006
11007 fn change_page_spanning_paragraph(input: &mut LayoutInput, index: usize, revision: usize) {
11008 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[index] else {
11009 panic!("page-spanning body entry is a paragraph");
11010 };
11011 let RunContent::Text(text) = &mut paragraph.runs[0].content[0] else {
11012 panic!("page-spanning paragraph begins with text");
11013 };
11014 text.text.push_str(&format!(" edit{revision}"));
11015 }
11016
11017 #[test]
11018 fn page_spanning_prose_publishes_complete_boundary_restart_records() {
11019 let input = page_spanning_prose_restart_input(175);
11020 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11021 let result = engine.layout(&input).expect("page-spanning prose layout");
11022
11023 assert_eq!(result.pages.len(), 16, "Issue 67 source page count");
11024 assert_eq!(engine.paragraph_cache.len(), 175);
11025 assert!(
11026 engine
11027 .paragraph_cache
11028 .iter()
11029 .all(|entry| entry.block.lines.len() == 4),
11030 "every Issue 67 source paragraph must wrap to exactly four lines"
11031 );
11032 assert_eq!(
11033 engine.page_layout_invocation_count(),
11034 result.pages.len(),
11035 "the completed recorded pass must be the published pass"
11036 );
11037 let retained = engine
11038 .restart_cache
11039 .as_ref()
11040 .expect("page-spanning prose must retain restart state");
11041 let boundaries = retained
11042 .checkpoints
11043 .iter()
11044 .map(|checkpoint| (checkpoint.next_block_index, checkpoint.page_count))
11045 .collect::<Vec<_>>();
11046 assert_eq!(
11047 boundaries,
11048 [
11049 (0, 0),
11050 (23, 2),
11051 (46, 4),
11052 (69, 6),
11053 (92, 8),
11054 (115, 10),
11055 (138, 12),
11056 (161, 14),
11057 ],
11058 "the first page ends inside paragraph 11, so its first eligible complete boundary is block 23 after page 2"
11059 );
11060 }
11061
11062 #[test]
11063 fn page_spanning_prose_restarts_warm_edits_exactly() {
11064 let mut input = page_spanning_prose_restart_input(175);
11065 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11066 engine
11067 .layout_with_provenance(&input)
11068 .expect("prime sourced page-spanning prose");
11069
11070 for revision in 0..10 {
11071 let index = 80 + revision;
11072 change_page_spanning_paragraph(&mut input, index, revision);
11073 let before = engine.paragraph_cache_counts();
11074 let (warm, warm_sources) = engine
11075 .layout_with_provenance(&input)
11076 .expect("warm sourced middle edit");
11077 let after = engine.paragraph_cache_counts();
11078 assert_eq!(after.0 - before.0, 174, "warm hits for edit {revision}");
11079 assert_eq!(after.1 - before.1, 1, "warm build for edit {revision}");
11080 assert!(
11081 engine.page_layout_invocation_count() <= 2,
11082 "edit {revision} repaginated {} pages",
11083 engine.page_layout_invocation_count()
11084 );
11085 let rebuilt = engine
11086 .last_rebuilt_page_range
11087 .clone()
11088 .expect("warm edit reports its rebuilt range");
11089 assert!(
11090 rebuilt.end.saturating_sub(rebuilt.start) <= 2,
11091 "edit {revision}: {rebuilt:?}"
11092 );
11093 let (fresh, fresh_sources) = Engine::new_deterministic()
11094 .expect("bundled fonts load")
11095 .layout_with_provenance(&input)
11096 .expect("fresh sourced middle edit");
11097 assert_layout_results_equal(&warm, &fresh);
11098 assert_eq!(warm_sources, fresh_sources);
11099 }
11100 }
11101
11102 #[test]
11103 fn page_spanning_prose_edit_matrix_matches_fresh_layout() {
11104 let original_input = page_spanning_prose_restart_input(175);
11105 let mut input = original_input.clone();
11106 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11107 let original = engine.layout(&input).expect("prime page-spanning prose");
11108
11109 change_page_spanning_paragraph(&mut input, 170, 1);
11110 let warm_edit = engine.layout(&input).expect("warm late edit");
11111 let fresh_edit = Engine::new_deterministic()
11112 .expect("bundled fonts load")
11113 .layout(&input)
11114 .expect("fresh late edit");
11115 assert_layout_results_equal(&warm_edit, &fresh_edit);
11116 assert!(engine.page_layout_invocation_count() <= 2);
11117
11118 input.document.body.content.insert(
11119 160,
11120 BodyContent::Paragraph(page_spanning_prose_paragraph(999)),
11121 );
11122 let warm_insert = engine.layout(&input).expect("warm insertion");
11123 let fresh_insert = Engine::new_deterministic()
11124 .expect("bundled fonts load")
11125 .layout(&input)
11126 .expect("fresh insertion");
11127 assert_layout_results_equal(&warm_insert, &fresh_insert);
11128
11129 input.document.body.content.remove(160);
11130 let warm_delete = engine.layout(&input).expect("warm deletion");
11131 let fresh_delete = Engine::new_deterministic()
11132 .expect("bundled fonts load")
11133 .layout(&input)
11134 .expect("fresh deletion");
11135 assert_layout_results_equal(&warm_delete, &fresh_delete);
11136
11137 input = original_input;
11138 let warm_undo = engine.layout(&input).expect("warm undo");
11139 let fresh_undo = Engine::new_deterministic()
11140 .expect("bundled fonts load")
11141 .layout(&input)
11142 .expect("fresh undo");
11143 assert_layout_results_equal(&warm_undo, &fresh_undo);
11144 assert_layout_results_equal(&warm_undo, &original);
11145 }
11146
11147 #[test]
11148 fn page_spanning_note_and_page_footer_restart_only_at_clean_boundaries() {
11149 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
11150 use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
11151
11152 let mut input = page_spanning_prose_restart_input(175);
11153 let BodyContent::Paragraph(split) = &mut input.document.body.content[11] else {
11154 panic!("split body entry is a paragraph");
11155 };
11156 let mut marker = CT_R::new("");
11157 marker.content = vec![RunContent::FootnoteRef { id: 1 }];
11158 split.runs.push(marker);
11159 let mut note = CT_P::new();
11160 note.add_run("page-spanning footnote");
11161 input.footnotes = Some(CT_Footnotes {
11162 footnotes: vec![CT_Footnote {
11163 id: 1,
11164 note_type: NoteType::Normal,
11165 paragraphs: vec![note],
11166 }],
11167 });
11168 let section = input
11169 .document
11170 .body
11171 .sect_pr
11172 .get_or_insert_with(CT_SectPr::default_letter);
11173 section.footer_refs.push(HdrFtrRef {
11174 hdr_ftr_type: HdrFtrType::Default,
11175 rel_id: "rIdPageFooter".to_owned(),
11176 });
11177 let mut footer = CT_HdrFtr::new();
11178 let mut footer_paragraph = CT_P::new();
11179 let mut page = CT_R::new("");
11180 page.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
11181 footer_paragraph.runs.push(page);
11182 footer.paragraphs.push(footer_paragraph);
11183 input.footers.insert("rIdPageFooter".to_owned(), footer);
11184
11185 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11186 let initial = engine.layout(&input).expect("prime note and footer layout");
11187 let retained = engine
11188 .restart_cache
11189 .as_ref()
11190 .expect("clean complete boundaries retain restart state");
11191 assert!(page_text(&initial.pages[0]).contains("Paragraph 11:"));
11192 assert!(
11193 page_text(&initial.pages[1]).starts_with("Waltz"),
11194 "page 2 must begin with paragraph 11's split continuation"
11195 );
11196 let first_complete = retained
11197 .checkpoints
11198 .iter()
11199 .find(|checkpoint| checkpoint.page_count > 0)
11200 .expect("a clean boundary follows the split paragraph");
11201 assert!(
11202 first_complete.page_count > 1 && first_complete.next_block_index > 11,
11203 "no boundary may be retained inside the split note-bearing paragraph"
11204 );
11205 for page in &initial.pages {
11206 let displayed = compatibility_page_elements(page)
11207 .into_iter()
11208 .find_map(|element| match element {
11209 PositionedElement::Text(run)
11210 if matches!(run.field_kind, Some(FieldKind::Page)) =>
11211 {
11212 Some(run.text.as_str())
11213 }
11214 _ => None,
11215 })
11216 .expect("each page has a displayed PAGE footer");
11217 assert_eq!(displayed, page.page_number.to_string());
11218 }
11219
11220 change_page_spanning_paragraph(&mut input, 80, 1);
11221 let warm = engine.layout(&input).expect("warm note and footer edit");
11222 let fresh = Engine::new_deterministic()
11223 .expect("bundled fonts load")
11224 .layout(&input)
11225 .expect("fresh note and footer edit");
11226 assert_layout_results_equal(&warm, &fresh);
11227 assert!(engine.page_layout_invocation_count() <= 2);
11228 }
11229
11230 #[test]
11231 fn ordinary_multiline_heading_and_keep_paragraphs_publish_restart_records() {
11232 let input = ordinary_prose_restart_input(140);
11233 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11234 let result = engine
11235 .layout(&input)
11236 .expect("ordinary prose layout succeeds");
11237
11238 let multiline = engine
11239 .paragraph_cache
11240 .iter()
11241 .find(|entry| entry.key.paragraph.text().starts_with("ordinary multiline"))
11242 .expect("multiline paragraph is cached");
11243 assert!(multiline.block.lines.len() > 2);
11244 assert_eq!(result.outlines.len(), 1);
11245 assert_eq!(result.outlines[0].level, 1);
11246 assert!(
11247 engine
11248 .restart_cache
11249 .as_ref()
11250 .is_some_and(|cache| !cache.checkpoints.is_empty()),
11251 "complete ordinary-prose block boundaries must publish restart checkpoints"
11252 );
11253 }
11254
11255 #[test]
11256 fn restart_candidate_uses_available_aggregate_cache_budget() {
11257 let mut input = make_input_with_text("aggregate candidate");
11258 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
11259 panic!("body entry is a paragraph");
11260 };
11261 paragraph.properties = Some(CT_PPr {
11262 style_id: Some("x".repeat(LEGACY_RESTART_CACHE_MAX_BYTES + 64 * 1024)),
11263 ..CT_PPr::default()
11264 });
11265
11266 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11267 engine
11268 .layout(&input)
11269 .expect("large candidate layout succeeds");
11270 assert!(engine.last_restart_candidate_bytes > LEGACY_RESTART_CACHE_MAX_BYTES);
11271 assert!(
11272 engine
11273 .paragraph_cache_bytes
11274 .checked_add(engine.table_cache_bytes)
11275 .and_then(|bytes| bytes.checked_add(engine.header_footer_cache_bytes))
11276 .and_then(|bytes| bytes.checked_add(engine.last_restart_candidate_bytes))
11277 .is_some_and(|bytes| bytes <= CACHE_MAX_BYTES)
11278 );
11279 assert!(
11280 engine.restart_cache.is_some(),
11281 "candidate above 8 MiB must use available aggregate capacity"
11282 );
11283 }
11284
11285 #[test]
11286 fn restart_candidate_over_aggregate_budget_fails_closed() {
11287 for occupied in [CACHE_MAX_BYTES, usize::MAX] {
11288 let input = restart_input();
11289 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11290 let original = engine.layout(&input).expect("prime restart state");
11291 engine.paragraph_cache_bytes = occupied;
11292
11293 let warm = engine.layout(&input).expect("pressured layout succeeds");
11294 let fresh = Engine::new_deterministic()
11295 .expect("bundled fonts load")
11296 .layout(&input)
11297 .expect("fresh pressured layout succeeds");
11298 assert_layout_results_equal(&warm, &fresh);
11299 assert_layout_results_equal(&warm, &original);
11300 assert!(
11301 engine.restart_cache.is_none(),
11302 "aggregate pressure {occupied} must reject the candidate"
11303 );
11304 }
11305 }
11306
11307 #[test]
11308 fn ordinary_prose_late_edit_insert_delete_and_undo_match_fresh_layout() {
11309 let mut input = ordinary_prose_restart_input(700);
11310 let original_input = input.clone();
11311 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11312 let original = engine.layout(&input).expect("prime ordinary prose state");
11313 assert!(engine.restart_cache.is_some());
11314
11315 set_body_paragraph_text(&mut input, 650, "ordinary prose paragraph 650 changed");
11316 let warm_edit = engine.layout(&input).expect("warm late edit");
11317 assert!(
11318 engine.page_layout_invocation_count() <= 2,
11319 "edit recomputed {} pages",
11320 engine.page_layout_invocation_count()
11321 );
11322 let fresh_edit = Engine::new_deterministic()
11323 .expect("bundled fonts load")
11324 .layout(&input)
11325 .expect("fresh late edit");
11326 assert_layout_results_equal(&warm_edit, &fresh_edit);
11327
11328 let mut inserted = CT_P::new();
11329 inserted.add_run("ordinary inserted paragraph");
11330 input
11331 .document
11332 .body
11333 .content
11334 .insert(640, BodyContent::Paragraph(inserted));
11335 let warm_insert = engine.layout(&input).expect("warm insertion");
11336 assert!(
11337 engine.page_layout_invocation_count() <= 3,
11338 "insertion recomputed {} pages",
11339 engine.page_layout_invocation_count()
11340 );
11341 let fresh_insert = Engine::new_deterministic()
11342 .expect("bundled fonts load")
11343 .layout(&input)
11344 .expect("fresh insertion");
11345 assert_layout_results_equal(&warm_insert, &fresh_insert);
11346
11347 input.document.body.content.remove(640);
11348 let warm_delete = engine.layout(&input).expect("warm deletion");
11349 assert!(
11350 engine.page_layout_invocation_count() <= 3,
11351 "deletion recomputed {} pages",
11352 engine.page_layout_invocation_count()
11353 );
11354 let fresh_delete = Engine::new_deterministic()
11355 .expect("bundled fonts load")
11356 .layout(&input)
11357 .expect("fresh deletion");
11358 assert_layout_results_equal(&warm_delete, &fresh_delete);
11359
11360 input = original_input;
11361 let warm_undo = engine.layout(&input).expect("warm undo");
11362 assert!(
11363 engine.page_layout_invocation_count() <= 3,
11364 "undo recomputed {} pages",
11365 engine.page_layout_invocation_count()
11366 );
11367 let fresh_undo = Engine::new_deterministic()
11368 .expect("bundled fonts load")
11369 .layout(&input)
11370 .expect("fresh undo");
11371 assert_layout_results_equal(&warm_undo, &fresh_undo);
11372 assert_layout_results_equal(&warm_undo, &original);
11373 }
11374
11375 #[test]
11376 fn ordinary_prose_restart_bounds_recomputed_page_work() {
11377 let mut input = ordinary_prose_restart_input(700);
11378 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11379 let initial = engine.layout(&input).expect("prime ordinary prose state");
11380 assert!(initial.pages.len() > 2);
11381 assert!(engine.restart_cache.is_some());
11382
11383 set_body_paragraph_text(&mut input, 650, "ordinary prose paragraph 650 changed");
11384 let warm = engine.layout(&input).expect("warm late edit");
11385 assert!(engine.page_layout_invocation_count() <= 2);
11386 let fresh = Engine::new_deterministic()
11387 .expect("bundled fonts load")
11388 .layout(&input)
11389 .expect("fresh late edit");
11390 assert_layout_results_equal(&warm, &fresh);
11391 }
11392
11393 #[test]
11394 fn unrepresented_restart_content_remains_rejected() {
11395 let assert_no_checkpoints = |label: &str, input: LayoutInput| {
11396 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11397 engine
11398 .layout(&input)
11399 .unwrap_or_else(|error| panic!("{label}: {error}"));
11400 assert!(
11401 engine
11402 .restart_cache
11403 .as_ref()
11404 .is_none_or(|cache| cache.checkpoints.is_empty()),
11405 "{label}"
11406 );
11407 };
11408
11409 let mut numbered = restart_input();
11410 let BodyContent::Paragraph(paragraph) = &mut numbered.document.body.content[20] else {
11411 panic!("numbered body entry is a paragraph");
11412 };
11413 paragraph.properties.get_or_insert_default().num_id = Some(1);
11414 assert_no_checkpoints("numbering", numbered);
11415
11416 for instruction in ["PAGE", "DATE", "REF missing", "UNSUPPORTED"] {
11417 let mut field = restart_input();
11418 let BodyContent::Paragraph(paragraph) = &mut field.document.body.content[20] else {
11419 panic!("field body entry is a paragraph");
11420 };
11421 let mut run = CT_R::new("");
11422 run.content = vec![RunContent::Field(Field::new(instruction, "cached"))];
11423 paragraph.runs.push(run);
11424 assert_no_checkpoints(instruction, field);
11425 }
11426
11427 let mut drawing = restart_input();
11428 let BodyContent::Paragraph(paragraph) = &mut drawing.document.body.content[20] else {
11429 panic!("drawing body entry is a paragraph");
11430 };
11431 let mut run = CT_R::new("");
11432 run.content = vec![RunContent::Drawing(rdocx_oxml::drawing::CT_Drawing {
11433 inline: None,
11434 anchor: None,
11435 })];
11436 paragraph.runs.push(run);
11437 assert_no_checkpoints("drawing", drawing);
11438
11439 let mut raw = restart_input();
11440 let BodyContent::Paragraph(paragraph) = &mut raw.document.body.content[20] else {
11441 panic!("raw body entry is a paragraph");
11442 };
11443 paragraph.extra_xml.push((0, br#"<w:unknown/>"#.to_vec()));
11444 assert_no_checkpoints("raw child", raw);
11445
11446 let mut foreign_bookmark = restart_input();
11447 let BodyContent::Paragraph(paragraph) = &mut foreign_bookmark.document.body.content[20]
11448 else {
11449 panic!("bookmark body entry is a paragraph");
11450 };
11451 assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11452 assert!(paragraph.insert_bookmark_end(1, 7));
11453 paragraph.extra_xml[0].1 =
11454 br#"<ext:bookmarkStart xmlns:ext="urn:foreign" ext:id="7"/>"#.to_vec();
11455 assert_no_checkpoints("same-count foreign bookmark raw", foreign_bookmark);
11456
11457 for (label, duplicate_raw) in [
11458 (
11459 "duplicate expanded bookmark id",
11460 br#"<w:bookmarkStart xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:id="7" x:id="7" w:name="target"/>"#.as_slice(),
11461 ),
11462 (
11463 "duplicate expanded bookmark name",
11464 br#"<w:bookmarkStart xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:id="7" w:name="target" x:name="target"/>"#.as_slice(),
11465 ),
11466 ] {
11467 let mut duplicate_bookmark = restart_input();
11468 let BodyContent::Paragraph(paragraph) =
11469 &mut duplicate_bookmark.document.body.content[20]
11470 else {
11471 panic!("bookmark body entry is a paragraph");
11472 };
11473 assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11474 assert!(paragraph.insert_bookmark_end(1, 7));
11475 paragraph.extra_xml[0].1 = duplicate_raw.to_vec();
11476 assert_no_checkpoints(label, duplicate_bookmark);
11477 }
11478
11479 let mut nested_bookmark = restart_input();
11480 let BodyContent::Paragraph(paragraph) = &mut nested_bookmark.document.body.content[20]
11481 else {
11482 panic!("bookmark body entry is a paragraph");
11483 };
11484 assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11485 assert!(paragraph.insert_bookmark_end(1, 7));
11486 paragraph.extra_xml[0].1 =
11487 br#"<w:bookmarkStart w:id="7" w:name="target"><w:unknown/></w:bookmarkStart>"#.to_vec();
11488 assert_no_checkpoints("non-empty bookmark root", nested_bookmark);
11489
11490 let mut trailing_bookmark = restart_input();
11491 let BodyContent::Paragraph(paragraph) = &mut trailing_bookmark.document.body.content[20]
11492 else {
11493 panic!("bookmark body entry is a paragraph");
11494 };
11495 assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11496 assert!(paragraph.insert_bookmark_end(1, 7));
11497 paragraph.extra_xml[0].1 =
11498 br#"<w:bookmarkStart w:id="7" w:name="target"/><w:unknown/>"#.to_vec();
11499 assert_no_checkpoints("trailing bookmark raw", trailing_bookmark);
11500
11501 let mut stale_raw_before = restart_input();
11502 let BodyContent::Paragraph(paragraph) = &mut stale_raw_before.document.body.content[20]
11503 else {
11504 panic!("bookmark body entry is a paragraph");
11505 };
11506 assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11507 assert!(paragraph.insert_bookmark_start(0, 7, "target"));
11508 paragraph.bookmark_markers.swap(0, 1);
11509 assert_no_checkpoints("stale bookmark raw order", stale_raw_before);
11510
11511 let aliased = CT_Document::from_xml(
11512 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><x:bookmarkStart xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" x:id="7" x:name="target"/><w:r><w:t>text</w:t></w:r><x:bookmarkEnd xmlns:x="http://schemas.openxmlformats.org/wordprocessingml/2006/main" x:id="7"/></w:p></w:body></w:document>"#,
11513 )
11514 .expect("locally aliased bookmarks parse");
11515 let BodyContent::Paragraph(aliased) = &aliased.body.content[0] else {
11516 panic!("aliased bookmark body entry is a paragraph");
11517 };
11518 assert!(paragraph_bookmark_raw_is_exact(aliased));
11519
11520 let mut multilingual = restart_input();
11521 let BodyContent::Paragraph(paragraph) = &mut multilingual.document.body.content[20] else {
11522 panic!("multilingual body entry is a paragraph");
11523 };
11524 paragraph.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text::new(
11525 "다국어 상태",
11526 ))];
11527 paragraph.runs[0]
11528 .properties
11529 .get_or_insert_default()
11530 .language = Some("ko-KR".to_owned());
11531 assert_no_checkpoints("multilingual state", multilingual);
11532
11533 assert_no_checkpoints(
11534 "anchored empty paragraph",
11535 make_wrapping_document(WrapType::Square, None, 120.0, 60.0, 5.0),
11536 );
11537 }
11538
11539 fn related_story_restart_input(paragraph_count: usize) -> LayoutInput {
11540 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
11541 use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
11542
11543 let mut input = make_input_with_text("paragraph 000 stable line");
11544 for index in 1..paragraph_count {
11545 let mut paragraph = CT_P::new();
11546 paragraph.add_run(&format!("paragraph {index:03} stable line"));
11547 input.document.body.add_paragraph(paragraph);
11548 }
11549
11550 for (index, content) in [
11551 (20, RunContent::FootnoteRef { id: 1 }),
11552 (40, RunContent::EndnoteRef { id: 2 }),
11553 ] {
11554 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[index] else {
11555 panic!("related story reference belongs to a paragraph");
11556 };
11557 let mut run = CT_R::new("");
11558 run.content = vec![content];
11559 paragraph.runs.push(run);
11560 }
11561
11562 let mut footnote = CT_P::new();
11563 footnote.add_run("stable footnote text");
11564 input.footnotes = Some(CT_Footnotes {
11565 footnotes: vec![CT_Footnote {
11566 id: 1,
11567 note_type: NoteType::Normal,
11568 paragraphs: vec![footnote],
11569 }],
11570 });
11571 let mut endnote = CT_P::new();
11572 endnote.add_run("stable endnote text");
11573 input.endnotes = Some(CT_Footnotes {
11574 footnotes: vec![CT_Footnote {
11575 id: 2,
11576 note_type: NoteType::Normal,
11577 paragraphs: vec![endnote],
11578 }],
11579 });
11580
11581 let mut section = CT_SectPr::default_letter();
11582 section.header_refs.push(HdrFtrRef {
11583 hdr_ftr_type: HdrFtrType::Default,
11584 rel_id: "rIdHeader".to_owned(),
11585 });
11586 section.footer_refs.push(HdrFtrRef {
11587 hdr_ftr_type: HdrFtrType::Default,
11588 rel_id: "rIdFooter".to_owned(),
11589 });
11590 input.document.body.sect_pr = Some(section);
11591
11592 let mut header = CT_HdrFtr::new();
11593 let mut header_paragraph = CT_P::new();
11594 header_paragraph.add_run("stable header text");
11595 header.paragraphs.push(header_paragraph);
11596 input.headers.insert("rIdHeader".to_owned(), header);
11597
11598 let mut footer = CT_HdrFtr::new();
11599 let mut footer_paragraph = CT_P::new();
11600 let mut page_run = CT_R::new("");
11601 page_run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
11602 footer_paragraph.runs.push(page_run);
11603 footer.paragraphs.push(footer_paragraph);
11604 input.footers.insert("rIdFooter".to_owned(), footer);
11605 input
11606 }
11607
11608 #[test]
11609 fn unchanged_footnote_and_endnote_context_restarts_only_at_note_clean_boundaries() {
11610 let mut input = related_story_restart_input(700);
11611 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11612 let initial = engine.layout(&input).expect("initial related-story layout");
11613 let initial_pages = initial.pages.clone();
11614 assert!(
11615 engine.restart_cache.is_some(),
11616 "unchanged note context must permit a restart record, candidate {} bytes",
11617 engine.last_restart_candidate_bytes
11618 );
11619
11620 set_body_paragraph_text(&mut input, 350, "paragraph 350 changed line");
11621 let warm = engine.layout(&input).expect("warm related-story layout");
11622 let fresh = Engine::new_deterministic()
11623 .expect("bundled fonts load")
11624 .layout(&input)
11625 .expect("fresh related-story layout");
11626 assert_layout_results_equal(&warm, &fresh);
11627 assert!((1..=2).contains(&engine.page_layout_invocation_count()));
11628 assert!(
11629 warm.pages
11630 .iter()
11631 .zip(&initial_pages)
11632 .filter(|(current, retained)| Arc::ptr_eq(current, retained))
11633 .count()
11634 >= warm.pages.len().saturating_sub(2)
11635 );
11636 let rendered_text = warm
11637 .pages
11638 .iter()
11639 .flat_map(|page| compatibility_page_elements(page))
11640 .filter_map(|element| match element {
11641 PositionedElement::Text(run) => Some(run.text.as_str()),
11642 _ => None,
11643 })
11644 .collect::<String>();
11645 assert_eq!(
11646 rendered_text.matches("stable endnote text").count(),
11647 1,
11648 "endnote pages append exactly once: {rendered_text}"
11649 );
11650 }
11651
11652 #[test]
11653 fn restarted_body_completion_appends_prefix_and_suffix_endnotes_with_final_page_numbers() {
11654 use rdocx_oxml::footnotes::{CT_Footnote, NoteType};
11655
11656 let mut input = related_story_restart_input(700);
11657 let BodyContent::Paragraph(last) = &mut input.document.body.content[699] else {
11658 panic!("last body entry is a paragraph");
11659 };
11660 let mut marker = CT_R::new("");
11661 marker.content = vec![RunContent::EndnoteRef { id: 3 }];
11662 last.runs.push(marker);
11663 let mut suffix_endnote = CT_P::new();
11664 suffix_endnote.add_run("suffix endnote text");
11665 input
11666 .endnotes
11667 .as_mut()
11668 .expect("endnote stream")
11669 .footnotes
11670 .push(CT_Footnote {
11671 id: 3,
11672 note_type: NoteType::Normal,
11673 paragraphs: vec![suffix_endnote],
11674 });
11675
11676 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11677 let initial = engine.layout(&input).expect("initial endnote layout");
11678 set_body_paragraph_text(&mut input, 699, "paragraph 699 changed line");
11679 let warm = engine.layout(&input).expect("completed warm body layout");
11680 let fresh = Engine::new_deterministic()
11681 .expect("bundled fonts load")
11682 .layout(&input)
11683 .expect("fresh completed body layout");
11684 assert_layout_results_equal(&warm, &fresh);
11685 assert!((1..=2).contains(&engine.page_layout_invocation_count()));
11686 assert_eq!(
11687 warm.pages.last().map(|page| page.page_number),
11688 Some(warm.pages.len()),
11689 "the final endnote page keeps its document-wide page number"
11690 );
11691 assert!(
11692 !Arc::ptr_eq(
11693 warm.pages.last().expect("warm final endnote page"),
11694 initial.pages.last().expect("initial final endnote page")
11695 ),
11696 "completion must append endnotes instead of attaching the cached tail"
11697 );
11698 let rendered_text = warm
11699 .pages
11700 .iter()
11701 .flat_map(|page| compatibility_page_elements(page))
11702 .filter_map(|element| match element {
11703 PositionedElement::Text(run) => Some(run.text.as_str()),
11704 _ => None,
11705 })
11706 .collect::<String>();
11707 assert_eq!(rendered_text.matches("stable endnote text").count(), 1);
11708 assert_eq!(rendered_text.matches("suffix endnote text").count(), 1);
11709 }
11710
11711 #[test]
11712 fn unchanged_header_and_footer_context_keeps_restart_pagination_eligible() {
11713 let mut input = related_story_restart_input(700);
11714 input.footnotes = None;
11715 input.endnotes = None;
11716 for content in &mut input.document.body.content {
11717 let BodyContent::Paragraph(paragraph) = content else {
11718 continue;
11719 };
11720 for run in &mut paragraph.runs {
11721 run.content.retain(|content| {
11722 !matches!(
11723 content,
11724 RunContent::FootnoteRef { .. } | RunContent::EndnoteRef { .. }
11725 )
11726 });
11727 }
11728 }
11729 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11730 engine.layout(&input).expect("initial header-footer layout");
11731 assert!(
11732 engine.restart_cache.is_some(),
11733 "default headers and footers must permit a restart record, candidate {} bytes",
11734 engine.last_restart_candidate_bytes
11735 );
11736
11737 set_body_paragraph_text(&mut input, 350, "paragraph 350 changed line");
11738 let warm = engine.layout(&input).expect("warm header-footer layout");
11739 let fresh = Engine::new_deterministic()
11740 .expect("bundled fonts load")
11741 .layout(&input)
11742 .expect("fresh header-footer layout");
11743 assert_layout_results_equal(&warm, &fresh);
11744 assert!((1..=2).contains(&engine.page_layout_invocation_count()));
11745 }
11746
11747 #[test]
11748 fn changed_related_story_context_invalidates_restart_state() {
11749 fn assert_invalidated(label: &str, mutate: impl FnOnce(&mut LayoutInput)) {
11750 let mut input = related_story_restart_input(700);
11751 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11752 engine
11753 .layout(&input)
11754 .unwrap_or_else(|error| panic!("prime {label}: {error}"));
11755 assert!(
11756 engine.restart_cache.is_some(),
11757 "prime {label}, candidate {} bytes",
11758 engine.last_restart_candidate_bytes
11759 );
11760 mutate(&mut input);
11761 let warm = engine
11762 .layout(&input)
11763 .unwrap_or_else(|error| panic!("warm {label}: {error}"));
11764 let fresh = Engine::new_deterministic()
11765 .expect("bundled fonts load")
11766 .layout(&input)
11767 .unwrap_or_else(|error| panic!("fresh {label}: {error}"));
11768 assert_layout_results_equal(&warm, &fresh);
11769 assert!(
11770 engine.page_layout_invocation_count() > 2,
11771 "changed {label} must force full pagination"
11772 );
11773 }
11774
11775 assert_invalidated("footnote", |input| {
11776 set_body_paragraph_text_in_story(
11777 &mut input.footnotes.as_mut().expect("footnote stream").footnotes[0].paragraphs[0],
11778 "changed footnote text",
11779 );
11780 });
11781 assert_invalidated("endnote", |input| {
11782 set_body_paragraph_text_in_story(
11783 &mut input.endnotes.as_mut().expect("endnote stream").footnotes[0].paragraphs[0],
11784 "changed endnote text",
11785 );
11786 });
11787 assert_invalidated("header", |input| {
11788 set_body_paragraph_text_in_story(
11789 &mut input
11790 .headers
11791 .get_mut("rIdHeader")
11792 .expect("header")
11793 .paragraphs[0],
11794 "changed header text",
11795 );
11796 });
11797 assert_invalidated("footer", |input| {
11798 let footer = input.footers.get_mut("rIdFooter").expect("footer");
11799 footer.paragraphs[0].add_run("changed footer text");
11800 });
11801 }
11802
11803 fn set_body_paragraph_text_in_story(paragraph: &mut CT_P, text: &str) {
11804 paragraph.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text {
11805 text: text.to_owned(),
11806 preserve_space: false,
11807 })];
11808 }
11809
11810 #[test]
11811 fn a_footnote_continuation_never_creates_a_dirty_restart_boundary() {
11812 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
11813
11814 let mut input = make_input_with_text("body carrying a long note");
11815 let BodyContent::Paragraph(first) = &mut input.document.body.content[0] else {
11816 panic!("first body entry is a paragraph");
11817 };
11818 let mut marker = CT_R::new("");
11819 marker.content = vec![RunContent::FootnoteRef { id: 1 }];
11820 first.runs.push(marker);
11821 for index in 1..8 {
11822 let mut paragraph = CT_P::new();
11823 paragraph.properties = Some(CT_PPr {
11824 page_break_before: Some(true),
11825 ..Default::default()
11826 });
11827 paragraph.add_run(&format!("body page {index}"));
11828 input.document.body.add_paragraph(paragraph);
11829 }
11830 let mut long_note = CT_P::new();
11831 long_note.add_run(&"continuing footnote text ".repeat(2_000));
11832 input.footnotes = Some(CT_Footnotes {
11833 footnotes: vec![CT_Footnote {
11834 id: 1,
11835 note_type: NoteType::Normal,
11836 paragraphs: vec![long_note],
11837 }],
11838 });
11839
11840 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11841 let output = engine.layout(&input).expect("continued footnote layout");
11842 assert!(output.pages.len() > 2, "fixture must continue the footnote");
11843 let retained = engine
11844 .restart_cache
11845 .as_ref()
11846 .expect("continued notes retain only clean boundaries");
11847 assert!(
11848 retained
11849 .checkpoints
11850 .iter()
11851 .all(|checkpoint| checkpoint.next_block_index != 1),
11852 "the boundary carrying pending note state must not be retained"
11853 );
11854 }
11855
11856 fn set_body_paragraph_text(input: &mut LayoutInput, index: usize, text: &str) {
11857 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[index] else {
11858 panic!("body entry is a paragraph");
11859 };
11860 paragraph.runs[0].content = vec![RunContent::Text(rdocx_oxml::text::CT_Text {
11861 text: text.to_owned(),
11862 preserve_space: false,
11863 })];
11864 }
11865
11866 fn substituted_restart_input() -> LayoutInput {
11867 let mut input = restart_input();
11868 let BodyContent::Paragraph(fields) = &mut input.document.body.content[0] else {
11869 panic!("body entry is a paragraph");
11870 };
11871 for (instruction, display) in [
11872 ("PAGE", "page"),
11873 ("NUMPAGES", "pages"),
11874 ("PAGEREF destination", "target"),
11875 ] {
11876 let mut run = CT_R::new("");
11877 run.content = vec![RunContent::Field(Field::new(instruction, display))];
11878 fields.runs.push(run);
11879 }
11880 let BodyContent::Paragraph(target) = &mut input.document.body.content[100] else {
11881 panic!("body entry is a paragraph");
11882 };
11883 assert!(target.insert_bookmark_start(0, 46, "destination"));
11884 assert!(target.insert_bookmark_end(1, 46));
11885 input
11886 }
11887
11888 fn substituted_page_index(engine: &Engine) -> usize {
11889 engine
11890 .restart_cache
11891 .as_ref()
11892 .expect("restart record retained")
11893 .substitution_inputs
11894 .iter()
11895 .position(Option::is_some)
11896 .expect("field page retained")
11897 }
11898
11899 #[test]
11900 fn unchanged_page_fields_reuse_substituted_frames() {
11901 let input = substituted_restart_input();
11902 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11903 let first = engine.layout(&input).expect("initial field layout");
11904 let field_page = substituted_page_index(&engine);
11905 let retained = engine
11906 .restart_cache
11907 .as_ref()
11908 .expect("restart record retained");
11909 assert!(
11910 retained.checkpoints.is_empty(),
11911 "field pages remain excluded from pagination restart"
11912 );
11913 assert!(!Arc::ptr_eq(
11914 &retained.raw_pages[field_page],
11915 &retained.pages[field_page]
11916 ));
11917 assert!(
11918 retained
11919 .raw_pages
11920 .iter()
11921 .zip(&retained.pages)
11922 .zip(&retained.substitution_inputs)
11923 .filter(|(_, inputs)| inputs.is_none())
11924 .all(|((pristine, substituted), _)| Arc::ptr_eq(pristine, substituted))
11925 );
11926
11927 let warm = engine.layout(&input).expect("warm field layout");
11928 assert!(Arc::ptr_eq(
11929 &first.pages[field_page],
11930 &warm.pages[field_page]
11931 ));
11932 }
11933
11934 #[test]
11935 fn changed_substitution_context_reshapes_pages() {
11936 fn assert_retained_key_miss(
11937 label: &str,
11938 mutate: impl FnOnce(&mut FieldSubstitutionInputs),
11939 ) {
11940 let input = substituted_restart_input();
11941 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11942 let first = engine.layout(&input).expect("initial field layout");
11943 let field_page = substituted_page_index(&engine);
11944 let retained = engine
11945 .restart_cache
11946 .as_mut()
11947 .expect("restart record retained");
11948 mutate(
11949 retained.substitution_inputs[field_page]
11950 .as_mut()
11951 .expect("field inputs retained"),
11952 );
11953 let warm = engine.layout(&input).expect("warm field layout");
11954 let cold = Engine::new_deterministic()
11955 .expect("bundled fonts load")
11956 .layout(&input)
11957 .expect("cold field layout");
11958 assert_layout_results_equal(&warm, &cold);
11959 assert!(
11960 !Arc::ptr_eq(&first.pages[field_page], &warm.pages[field_page]),
11961 "{label}"
11962 );
11963 }
11964
11965 assert_retained_key_miss("page index must miss", |inputs| inputs.page_index += 1);
11966 assert_retained_key_miss("displayed page number must miss", |inputs| {
11967 inputs.page_number += 1;
11968 });
11969 assert_retained_key_miss("page count must miss", |inputs| inputs.total_pages += 1);
11970 assert_retained_key_miss("bookmark targets must miss", |inputs| {
11971 inputs.bookmark_pages.push((usize::MAX, usize::MAX));
11972 });
11973 assert_retained_key_miss("font identity must miss", |inputs| {
11974 inputs.font_identity.reverse();
11975 inputs.font_identity.push(FontId(u32::MAX));
11976 });
11977 assert_retained_key_miss("revision view must miss", |inputs| {
11978 inputs.revision_view = RevisionView::Tracked;
11979 });
11980
11981 let mut input = substituted_restart_input();
11982 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
11983 let first = engine.layout(&input).expect("initial field layout");
11984 let field_page = substituted_page_index(&engine);
11985 set_body_paragraph_text(&mut input, 0, "changed pristine field page");
11986 let warm = engine.layout(&input).expect("changed pristine layout");
11987 let cold = Engine::new_deterministic()
11988 .expect("bundled fonts load")
11989 .layout(&input)
11990 .expect("cold changed pristine layout");
11991 assert_layout_results_equal(&warm, &cold);
11992 assert!(!Arc::ptr_eq(
11993 &first.pages[field_page],
11994 &warm.pages[field_page]
11995 ));
11996
11997 fn set_field_page_family(input: &mut LayoutInput, family: &str) {
11998 let BodyContent::Paragraph(fields) = &mut input.document.body.content[0] else {
11999 panic!("body entry is a paragraph");
12000 };
12001 for run in &mut fields.runs {
12002 run.properties = Some(rdocx_oxml::properties::CT_RPr {
12003 font_ascii: Some(family.to_owned()),
12004 font_hansi: Some(family.to_owned()),
12005 ..Default::default()
12006 });
12007 }
12008 }
12009
12010 let mut input = substituted_restart_input();
12011 set_field_page_family(&mut input, "Caladea");
12012 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12013 engine.layout(&input).expect("first bundled-family layout");
12014 set_field_page_family(&mut input, "Carlito");
12015 let transitioned = engine.layout(&input).expect("font-transition field layout");
12016 let field_page = substituted_page_index(&engine);
12017 let field_free_page = engine
12018 .restart_cache
12019 .as_ref()
12020 .expect("restart record retained")
12021 .substitution_inputs
12022 .iter()
12023 .position(Option::is_none)
12024 .expect("field-free page retained");
12025 let warm = engine.layout(&input).expect("post-transition warm layout");
12026 let cold = Engine::new_deterministic()
12027 .expect("bundled fonts load")
12028 .layout(&input)
12029 .expect("post-transition cold layout");
12030 assert_layout_results_equal(&warm, &cold);
12031 assert!(Arc::ptr_eq(
12032 &transitioned.pages[field_page],
12033 &warm.pages[field_page]
12034 ));
12035 assert!(Arc::ptr_eq(
12036 &transitioned.pages[field_free_page],
12037 &warm.pages[field_free_page]
12038 ));
12039 }
12040
12041 #[test]
12042 fn substituted_page_reuse_is_bounded_and_complete_equal() {
12043 let input = substituted_restart_input();
12044 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12045 engine.layout(&input).expect("initial field layout");
12046 let warm = engine.layout(&input).expect("warm field layout");
12047 let cold = Engine::new_deterministic()
12048 .expect("bundled fonts load")
12049 .layout(&input)
12050 .expect("cold field layout");
12051 assert_layout_results_equal(&warm, &cold);
12052 let retained = engine
12053 .restart_cache
12054 .as_ref()
12055 .expect("restart record retained");
12056 assert!(
12057 retained.raw_pages.len().max(retained.checkpoints.len()) <= RESTART_CACHE_MAX_ENTRIES
12058 );
12059 assert_restart_cache_within_aggregate(&engine);
12060
12061 let mut bounded_input = make_input_with_text("");
12062 bounded_input.document.body.content.clear();
12063 for page in 0..1_024 {
12064 let mut paragraph = CT_P::new();
12065 paragraph.properties = Some(CT_PPr {
12066 page_break_before: (page > 0).then_some(true),
12067 ..Default::default()
12068 });
12069 let mut run = CT_R::new("");
12070 run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
12071 paragraph.runs.push(run);
12072 bounded_input.document.body.add_paragraph(paragraph);
12073 }
12074 let mut bounded = Engine::new_deterministic().expect("bundled fonts load");
12075 let result = bounded
12076 .layout(&bounded_input)
12077 .expect("bounded layout succeeds");
12078 assert_eq!(result.pages.len(), 1_024);
12079 bounded
12080 .restart_cache
12081 .as_ref()
12082 .expect("1,024 substituted page slots remain reusable");
12083 assert_restart_cache_within_aggregate(&bounded);
12084
12085 let mut oversized = bounded_input;
12086 let mut paragraph = CT_P::new();
12087 paragraph.properties = Some(CT_PPr {
12088 page_break_before: Some(true),
12089 ..Default::default()
12090 });
12091 let mut run = CT_R::new("");
12092 run.content = vec![RunContent::Field(Field::new("PAGE", "1"))];
12093 paragraph.runs.push(run);
12094 oversized.document.body.add_paragraph(paragraph);
12095 let result = bounded
12096 .layout(&oversized)
12097 .expect("oversized layout succeeds");
12098 assert_eq!(result.pages.len(), 1_025);
12099 assert!(
12100 bounded.restart_cache.is_none(),
12101 "an oversized pair set drops the optimization"
12102 );
12103
12104 fn over_limit<T>() -> usize {
12105 LEGACY_RESTART_CACHE_MAX_BYTES / std::mem::size_of::<T>() + 1
12106 }
12107 fn assert_capacity_rejected(label: &str, mutate: impl FnOnce(&mut RestartCache)) {
12108 let mut candidate = RestartCache {
12109 body: Vec::new(),
12110 with_provenance: false,
12111 raw_pages: Vec::new(),
12112 pages: Vec::new(),
12113 substitution_inputs: Vec::new(),
12114 outlines: Vec::new(),
12115 checkpoints: Vec::new(),
12116 font_trace: Vec::new(),
12117 bytes: 0,
12118 };
12119 mutate(&mut candidate);
12120 assert!(
12121 restart_cache_bytes(&candidate) > LEGACY_RESTART_CACHE_MAX_BYTES,
12122 "{label}"
12123 );
12124 }
12125
12126 assert_capacity_rejected("body vector capacity is charged", |cache| {
12127 cache.body = Vec::with_capacity(over_limit::<RestartBodyEntry>());
12128 });
12129 assert_capacity_rejected("pristine page vector capacity is charged", |cache| {
12130 cache.raw_pages = Vec::with_capacity(over_limit::<Arc<PageFrame>>());
12131 });
12132 assert_capacity_rejected("substituted page vector capacity is charged", |cache| {
12133 cache.pages = Vec::with_capacity(over_limit::<Arc<PageFrame>>());
12134 });
12135 assert_capacity_rejected("substitution vector capacity is charged", |cache| {
12136 cache.substitution_inputs =
12137 Vec::with_capacity(over_limit::<Option<FieldSubstitutionInputs>>());
12138 });
12139 assert_capacity_rejected("outline vector capacity is charged", |cache| {
12140 cache.outlines = Vec::with_capacity(over_limit::<oxml_layout::OutlineEntry>());
12141 });
12142 assert_capacity_rejected("checkpoint vector capacity is charged", |cache| {
12143 cache.checkpoints = Vec::with_capacity(over_limit::<paginator::PaginationCheckpoint>());
12144 });
12145 assert_capacity_rejected("font trace vector capacity is charged", |cache| {
12146 cache.font_trace = Vec::with_capacity(over_limit::<FontId>());
12147 });
12148 assert_capacity_rejected("body payload capacity is charged", |cache| {
12149 cache.body.push(RestartBodyEntry::Paragraph {
12150 fingerprint: 0,
12151 identity: Vec::new(),
12152 note_references: Vec::new(),
12153 bytes: LEGACY_RESTART_CACHE_MAX_BYTES + 1,
12154 });
12155 });
12156 assert_capacity_rejected("outline title capacity is charged", |cache| {
12157 cache.outlines.push(oxml_layout::OutlineEntry {
12158 title: String::with_capacity(LEGACY_RESTART_CACHE_MAX_BYTES + 1),
12159 level: 1,
12160 page_index: 0,
12161 y_position: 0.0,
12162 });
12163 });
12164 for nested in ["bookmark targets", "font identity"] {
12165 assert_capacity_rejected(&format!("{nested} capacity is charged"), |cache| {
12166 cache
12167 .substitution_inputs
12168 .push(Some(FieldSubstitutionInputs {
12169 page_index: 0,
12170 page_number: 1,
12171 total_pages: 1,
12172 bookmark_pages: if nested == "bookmark targets" {
12173 Vec::with_capacity(over_limit::<(usize, usize)>())
12174 } else {
12175 Vec::new()
12176 },
12177 font_identity: if nested == "font identity" {
12178 Vec::with_capacity(over_limit::<FontId>())
12179 } else {
12180 Vec::new()
12181 },
12182 revision_view: RevisionView::Accepted,
12183 }));
12184 });
12185 }
12186 }
12187
12188 #[test]
12189 fn thousand_page_restart_records_at_most_two_page_layout_invocations() {
12190 let mut input = make_input_with_text("");
12191 input.document.body.content.clear();
12192 for page in 0..1_000 {
12193 let mut paragraph = CT_P::new();
12194 paragraph.properties = Some(CT_PPr {
12195 page_break_before: (page > 0).then_some(true),
12196 ..Default::default()
12197 });
12198 paragraph.add_run(&format!("Incremental page {}", page + 1));
12199 input.document.body.add_paragraph(paragraph);
12200 }
12201
12202 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12203 let initial = engine.layout(&input).expect("initial thousand-page layout");
12204 assert_eq!(initial.pages.len(), 1_000);
12205 assert_eq!(engine.page_layout_invocation_count(), 1_000);
12206 engine
12207 .restart_cache
12208 .as_ref()
12209 .expect("thousand-page restart record retained");
12210 let cache_counts = engine.paragraph_cache_counts();
12211
12212 set_body_paragraph_text(&mut input, 499, "Incremental page 500 changed");
12213 let warm = engine.layout(&input).expect("warm thousand-page layout");
12214 assert!((1..=2).contains(&engine.page_layout_invocation_count()));
12215 let fresh = Engine::new_deterministic()
12216 .expect("bundled fonts load")
12217 .layout(&input)
12218 .expect("fresh thousand-page layout");
12219 assert_layout_results_equal(&warm, &fresh);
12220 let rebuilt = engine
12221 .last_rebuilt_page_range
12222 .clone()
12223 .expect("rebuilt range recorded");
12224 assert!(
12225 rebuilt.end.saturating_sub(rebuilt.start) <= 2,
12226 "{rebuilt:?}"
12227 );
12228 let warm_cache_counts = engine.paragraph_cache_counts();
12229 assert_eq!(warm_cache_counts.0 - cache_counts.0, 999);
12230 assert_eq!(warm_cache_counts.1 - cache_counts.1, 1);
12231 }
12232
12233 #[test]
12234 fn warm_restart_rebuilds_only_the_bounded_changed_region() {
12235 let mut input = restart_input();
12236 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12237 let cold_initial = engine.layout(&input).expect("initial pagination");
12238 let initial_pages = cold_initial.pages.clone();
12239 let retained = engine
12240 .restart_cache
12241 .as_ref()
12242 .expect("restart state retained");
12243 assert!(retained.checkpoints.len() > 1);
12244 assert!(retained.checkpoints.len() <= RESTART_CACHE_MAX_ENTRIES);
12245 assert_restart_cache_within_aggregate(&engine);
12246
12247 set_body_paragraph_text(&mut input, 70, "paragraph 070 changed line");
12248 let warm = engine.layout(&input).expect("warm middle edit");
12249 let cold = Engine::new_deterministic()
12250 .expect("bundled fonts load")
12251 .layout(&input)
12252 .expect("cold middle edit");
12253 assert_layout_results_equal(&warm, &cold);
12254 let rebuilt = engine
12255 .last_rebuilt_page_range
12256 .clone()
12257 .expect("rebuilt range recorded");
12258 assert!(
12259 rebuilt.end.saturating_sub(rebuilt.start) <= 2,
12260 "{rebuilt:?}"
12261 );
12262 assert!(
12263 warm.pages
12264 .iter()
12265 .zip(&initial_pages)
12266 .take(rebuilt.start)
12267 .all(|(current, previous)| Arc::ptr_eq(current, previous))
12268 );
12269 assert!(
12270 warm.pages
12271 .iter()
12272 .zip(&initial_pages)
12273 .skip(rebuilt.end)
12274 .all(|(current, previous)| Arc::ptr_eq(current, previous))
12275 );
12276
12277 for (index, label) in [(0, "start"), (139, "tail")] {
12278 set_body_paragraph_text(
12279 &mut input,
12280 index,
12281 &format!("paragraph {index:03} {label:>7} line"),
12282 );
12283 let warm = engine.layout(&input).expect("warm boundary edit");
12284 let cold = Engine::new_deterministic()
12285 .expect("bundled fonts load")
12286 .layout(&input)
12287 .expect("cold boundary edit");
12288 assert_layout_results_equal(&warm, &cold);
12289 }
12290 }
12291
12292 #[test]
12293 fn unsafe_pagination_state_falls_back_to_full_layout() {
12294 let assert_fallback = |label: &str, input: LayoutInput| {
12295 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12296 engine
12297 .layout(&input)
12298 .unwrap_or_else(|error| panic!("{label}: {error}"));
12299 assert!(engine.restart_cache.is_none(), "{label}");
12300 };
12301
12302 let mut table = make_input_with_text("before unsafe table");
12303 let mut unsafe_table = safe_table("table");
12304 unsafe_table.rows[0].cells[0].paragraphs_mut()[0]
12305 .properties
12306 .get_or_insert_default()
12307 .num_id = Some(1);
12308 table.document.body.add_table(unsafe_table);
12309 assert_fallback("traversal-sensitive table", table);
12310
12311 assert_fallback(
12312 "floating drawing",
12313 make_wrapping_document(WrapType::Square, None, 120.0, 60.0, 5.0),
12314 );
12315
12316 let note_source = make_input_with_footnote(&["note in table"]);
12317 let BodyContent::Paragraph(note_paragraph) = ¬e_source.document.body.content[0] else {
12318 panic!("note source is a paragraph");
12319 };
12320 let mut note_table_input = make_input_with_text("before note table");
12321 let mut note_table = safe_table("table text");
12322 note_table.rows[0].cells[0].paragraphs_mut()[0]
12323 .runs
12324 .push(note_paragraph.runs[1].clone());
12325 note_table_input.document.body.add_table(note_table);
12326 note_table_input.footnotes = note_source.footnotes;
12327 assert_fallback("note-bearing table", note_table_input);
12328
12329 let mut sections = restart_input();
12330 let BodyContent::Paragraph(first) = &mut sections.document.body.content[20] else {
12331 panic!("paragraph");
12332 };
12333 first.properties.get_or_insert_default().sect_pr = Some(CT_SectPr::default_letter());
12334 assert_fallback("multiple sections", sections);
12335
12336 let mut background = restart_input();
12337 background.document.background_xml = Some(
12338 br#"<w:background xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:color="FFFFFF"/>"#
12339 .to_vec(),
12340 );
12341 assert_fallback("page background", background);
12342
12343 let mut field = restart_input();
12344 let BodyContent::Paragraph(paragraph) = &mut field.document.body.content[20] else {
12345 panic!("paragraph");
12346 };
12347 let mut field_run = CT_R::new("");
12348 field_run.content = vec![RunContent::Field(rdocx_oxml::text::Field::new("PAGE", "1"))];
12349 paragraph.runs.push(field_run);
12350 let mut field_engine = Engine::new_deterministic().expect("bundled fonts load");
12351 field_engine.layout(&field).expect("field layout");
12352 let retained = field_engine
12353 .restart_cache
12354 .as_ref()
12355 .expect("field substitution pairs retained");
12356 assert!(
12357 retained.checkpoints.is_empty(),
12358 "fields must not become pagination restart boundaries"
12359 );
12360
12361 let mut boundary = restart_input();
12362 let mut boundary_engine = Engine::new_deterministic().expect("bundled fonts load");
12363 boundary_engine
12364 .layout(&boundary)
12365 .expect("prime boundary state");
12366 let stale = boundary_engine
12367 .restart_cache
12368 .as_mut()
12369 .and_then(|cache| {
12370 cache
12371 .checkpoints
12372 .iter_mut()
12373 .find(|checkpoint| checkpoint.next_block_index > 80)
12374 })
12375 .expect("later checkpoint exists");
12376 stale.next_header_page_number += 1;
12377 set_body_paragraph_text(&mut boundary, 70, "changed before stale boundary");
12378 let warm = boundary_engine
12379 .layout(&boundary)
12380 .expect("warm boundary mismatch");
12381 let cold = Engine::new_deterministic()
12382 .expect("bundled fonts load")
12383 .layout(&boundary)
12384 .expect("cold boundary mismatch");
12385 assert_layout_results_equal(&warm, &cold);
12386 assert!(
12387 boundary_engine
12388 .restart_cache
12389 .as_ref()
12390 .expect("correct state republished")
12391 .checkpoints
12392 .iter()
12393 .all(|checkpoint| {
12394 checkpoint.next_header_page_number == checkpoint.page_count + 1
12395 })
12396 );
12397 }
12398
12399 #[test]
12400 fn warm_and_cold_outputs_are_complete_equals() {
12401 let mut input = restart_input();
12402 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12403 engine.layout(&input).expect("prime restart state");
12404
12405 let mut inserted = CT_P::new();
12406 inserted.add_run("inserted stable line");
12407 input
12408 .document
12409 .body
12410 .content
12411 .insert(60, BodyContent::Paragraph(inserted));
12412 let warm_insert = engine.layout(&input).expect("warm insertion");
12413 let cold_insert = Engine::new_deterministic()
12414 .expect("bundled fonts load")
12415 .layout(&input)
12416 .expect("cold insertion");
12417 assert_layout_results_equal(&warm_insert, &cold_insert);
12418
12419 input.document.body.content.remove(60);
12420 let warm_delete = engine.layout(&input).expect("warm deletion");
12421 let cold_delete = Engine::new_deterministic()
12422 .expect("bundled fonts load")
12423 .layout(&input)
12424 .expect("cold deletion");
12425 assert_layout_results_equal(&warm_delete, &cold_delete);
12426
12427 let mut sourced_engine = Engine::new_deterministic().expect("bundled fonts load");
12428 let (original, original_sources) = sourced_engine
12429 .layout_with_provenance(&input)
12430 .expect("prime sourced restart state");
12431 input.document.body.content.insert(
12432 60,
12433 BodyContent::Paragraph({
12434 let mut paragraph = CT_P::new();
12435 paragraph.add_run("inserted sourced line");
12436 paragraph
12437 }),
12438 );
12439 let (warm_insert, warm_insert_sources) = sourced_engine
12440 .layout_with_provenance(&input)
12441 .expect("warm sourced insertion");
12442 let (cold_insert, cold_insert_sources) = Engine::new_deterministic()
12443 .expect("bundled fonts load")
12444 .layout_with_provenance(&input)
12445 .expect("cold sourced insertion");
12446 assert_layout_results_equal(&warm_insert, &cold_insert);
12447 assert_eq!(warm_insert_sources, cold_insert_sources);
12448
12449 input.document.body.content.remove(60);
12450 let (warm_delete, warm_delete_sources) = sourced_engine
12451 .layout_with_provenance(&input)
12452 .expect("warm sourced deletion");
12453 assert_layout_results_equal(&warm_delete, &original);
12454 assert_eq!(warm_delete_sources, original_sources);
12455
12456 let mut truncate_engine = Engine::new_deterministic().expect("bundled fonts load");
12457 truncate_engine
12458 .layout(&input)
12459 .expect("prime whole-suffix deletion state");
12460 let checkpoint = truncate_engine
12461 .restart_cache
12462 .as_ref()
12463 .and_then(|cache| cache.checkpoints.iter().next_back().copied())
12464 .expect("restart cache has a final safe boundary");
12465 input
12466 .document
12467 .body
12468 .content
12469 .truncate(checkpoint.next_block_index);
12470 let warm_truncate = truncate_engine
12471 .layout(&input)
12472 .expect("warm whole-suffix deletion");
12473 let cold_truncate = Engine::new_deterministic()
12474 .expect("bundled fonts load")
12475 .layout(&input)
12476 .expect("cold whole-suffix deletion");
12477 assert_layout_results_equal(&warm_truncate, &cold_truncate);
12478 }
12479
12480 #[test]
12481 fn paragraph_cache_failure_and_eviction_remain_bounded() {
12482 let mut input = make_input_with_text("");
12483 input.document = rdocx_oxml::CT_Document::from_xml(
12484 br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>cache-safe prefix</w:t></w:r></w:p><w:p><w:fldSimple w:instr="REF missing"><w:r><w:t>stored</w:t></w:r></w:fldSimple></w:p></w:body></w:document>"#,
12485 )
12486 .expect("diagnostic document parses");
12487 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12488 let cold = engine.layout(&input).expect("cold layout succeeds");
12489 let warm = engine.layout(&input).expect("warm layout succeeds");
12490 assert!(!cold.diagnostics.is_empty());
12491 assert_eq!(cold.diagnostics, warm.diagnostics);
12492 assert_eq!(engine.paragraph_cache_counts(), (1, 1));
12493
12494 let (valid_family, valid_bytes) = oxml_layout::bundled_fonts::bundled_font_data()[0];
12495 let (invalid_family, invalid_source) = oxml_layout::bundled_fonts::bundled_font_data()[4];
12496 let mut invalid_bytes = invalid_source.to_vec();
12497 let table_count = u16::from_be_bytes([invalid_bytes[4], invalid_bytes[5]]) as usize;
12498 let head_offset = (0..table_count)
12499 .find_map(|table| {
12500 let record = 12 + table * 16;
12501 (&invalid_bytes[record..record + 4] == b"head").then(|| {
12502 u32::from_be_bytes(
12503 invalid_bytes[record + 8..record + 12]
12504 .try_into()
12505 .expect("head offset"),
12506 ) as usize
12507 })
12508 })
12509 .expect("font has head table");
12510 invalid_bytes[head_offset + 18..head_offset + 20].copy_from_slice(&0u16.to_be_bytes());
12511
12512 let mut failing = Engine::with_font_manager(FontManager::new_with_fonts(vec![(
12513 valid_family.to_owned(),
12514 valid_bytes.to_vec(),
12515 )]));
12516 let mut failing_input = make_input_with_text("cache-safe successful prefix");
12517 let BodyContent::Paragraph(prefix) = &mut failing_input.document.body.content[0] else {
12518 panic!("prefix paragraph");
12519 };
12520 prefix.runs[0].properties.get_or_insert_default().font_ascii =
12521 Some(valid_family.to_owned());
12522 failing_input
12523 .document
12524 .body
12525 .content
12526 .push(BodyContent::Table(safe_nested_table(
12527 "staged outer before failure",
12528 "staged nested before failure",
12529 )));
12530 let mut later = CT_P::new();
12531 later
12532 .add_run("late font failure")
12533 .properties
12534 .get_or_insert_default()
12535 .font_ascii = Some(invalid_family.to_owned());
12536 failing_input.document.body.add_paragraph(later);
12537 failing_input.fonts.push(oxml_layout::FontFile {
12538 family: invalid_family.to_owned(),
12539 data: invalid_bytes,
12540 });
12541 assert!(failing.layout(&failing_input).is_err());
12542 assert!(failing.paragraph_cache.is_empty());
12543 assert!(failing.table_cache.is_empty());
12544 assert_eq!(failing.paragraph_cache_counts(), (0, 1));
12545 assert_eq!(failing.table_cache_counts(), (0, 1));
12546
12547 let template_input = make_input_with_text("eviction template");
12548 let mut bounded = Engine::new_deterministic().expect("bundled fonts load");
12549 bounded
12550 .layout(&template_input)
12551 .expect("template layout succeeds");
12552 let template = bounded
12553 .paragraph_cache
12554 .pop_front()
12555 .expect("template paragraph is retained");
12556 bounded.paragraph_cache_bytes = 0;
12557 for index in 0..=PARAGRAPH_CACHE_MAX_ENTRIES {
12558 let mut paragraph = CT_P::new();
12559 paragraph.add_run(&format!("eviction paragraph {index}"));
12560 let bytes = paragraph_cache_entry_bytes(
12561 ¶graph,
12562 &template.block,
12563 &template.diagnostics,
12564 template.font_trace.len(),
12565 );
12566 bounded.publish_paragraph_cache_entry(ParagraphCacheEntry {
12567 fingerprint: paragraph_fingerprint(¶graph),
12568 key: ParagraphCacheKey {
12569 paragraph,
12570 content_width_bits: PageGeometry::default().content_width().to_bits(),
12571 revision_view: RevisionView::Accepted,
12572 },
12573 block: template.block.clone(),
12574 diagnostics: template.diagnostics.clone(),
12575 font_trace: template.font_trace.clone(),
12576 reflow_direction: template.reflow_direction,
12577 bytes,
12578 });
12579 }
12580 assert_eq!(bounded.paragraph_cache.len(), PARAGRAPH_CACHE_MAX_ENTRIES);
12581 assert!(bounded.paragraph_cache_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
12582 assert_eq!(
12583 bounded
12584 .paragraph_cache
12585 .front()
12586 .expect("FIFO cache has a front")
12587 .key
12588 .paragraph
12589 .text(),
12590 "eviction paragraph 1"
12591 );
12592 }
12593
12594 #[test]
12595 fn paragraph_relayout_cache_is_bounded() {
12596 let mut input = make_input_with_text("bounded paragraph 0");
12597 for index in 1..(PARAGRAPH_CACHE_MAX_ENTRIES + 20) {
12598 let mut paragraph = CT_P::new();
12599 paragraph.add_run(&format!("bounded paragraph {index}"));
12600 input.document.body.add_paragraph(paragraph);
12601 }
12602 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12603 engine.layout(&input).expect("bounded layout succeeds");
12604 assert!(engine.paragraph_cache.len() <= PARAGRAPH_CACHE_MAX_ENTRIES);
12605 assert!(engine.paragraph_cache_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
12606 assert!(engine.pending_paragraph_cache_peak_entries <= PARAGRAPH_CACHE_MAX_ENTRIES);
12607 assert!(engine.pending_paragraph_cache_peak_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
12608 }
12609
12610 #[test]
12611 fn transactional_paragraph_staging_is_bounded_before_publication() {
12612 let mut input = make_input_with_text("staged paragraph 0");
12613 for index in 1..(PARAGRAPH_CACHE_MAX_ENTRIES * 2) {
12614 let mut paragraph = CT_P::new();
12615 paragraph.add_run(&format!("staged paragraph {index}"));
12616 input.document.body.add_paragraph(paragraph);
12617 }
12618 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12619 engine
12620 .layout(&input)
12621 .expect("transactional layout succeeds");
12622 assert_eq!(
12623 engine.pending_paragraph_cache_peak_entries,
12624 PARAGRAPH_CACHE_MAX_ENTRIES
12625 );
12626 assert!(engine.pending_paragraph_cache_peak_bytes <= PARAGRAPH_CACHE_MAX_BYTES);
12627 }
12628
12629 #[test]
12630 fn paragraph_relayout_cache_enforces_the_reflow_byte_ceiling() {
12631 let input = make_input_with_text("reflow accounting template");
12632 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12633 engine.layout(&input).expect("template layout succeeds");
12634 let template = engine
12635 .paragraph_cache
12636 .front()
12637 .expect("safe paragraph cached")
12638 .block
12639 .clone();
12640 let mut retained = match &template.lines[0].items[0] {
12641 LineItem::Text(text) => text.clone(),
12642 other => panic!("expected text line item, got {other:?}"),
12643 };
12644 retained.advances = vec![0.0; PARAGRAPH_CACHE_MAX_BYTES / 8 + 1];
12645
12646 let mut block = template.as_ref().clone();
12647 block.reflow = Some(Box::new(block::ParagraphReflow {
12648 items: vec![InlineItem::Text(retained)],
12649 params: oxml_layout::LineBreakParams::default(),
12650 }));
12651 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
12652 panic!("body paragraph");
12653 };
12654 let bytes = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
12655 assert!(bytes > PARAGRAPH_CACHE_MAX_BYTES);
12656
12657 engine.paragraph_cache.clear();
12658 engine.paragraph_cache_bytes = 0;
12659 engine.publish_paragraph_cache_entry(ParagraphCacheEntry {
12660 fingerprint: paragraph_fingerprint(paragraph),
12661 key: ParagraphCacheKey {
12662 paragraph: paragraph.clone(),
12663 content_width_bits: PageGeometry::default().content_width().to_bits(),
12664 revision_view: RevisionView::Accepted,
12665 },
12666 block: Arc::new(block),
12667 diagnostics: Vec::new(),
12668 font_trace: Vec::new(),
12669 reflow_direction: TextDirection::Auto,
12670 bytes,
12671 });
12672 assert!(engine.paragraph_cache.is_empty());
12673 assert_eq!(engine.paragraph_cache_bytes, 0);
12674 }
12675
12676 #[test]
12677 fn tab_heavy_paragraph_in_wrapping_document_counts_reflow_parameter_buffers() {
12678 use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
12679 use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
12680 use rdocx_oxml::shared::ST_TabJc;
12681 use rdocx_oxml::units::Twips;
12682
12683 let mut input =
12684 make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
12685 let retained_per_stop =
12686 std::mem::size_of::<CT_TabStop>() + std::mem::size_of::<oxml_layout::TabStop>();
12687 let stop_count = PARAGRAPH_CACHE_MAX_BYTES / retained_per_stop + 1;
12688 let mut paragraph = CT_P::new();
12689 paragraph.properties = Some(CT_PPr {
12690 tabs: Some(CT_Tabs {
12691 tabs: (0..stop_count)
12692 .map(|_| CT_TabStop::new(ST_TabJc::Left, Twips(720)))
12693 .collect(),
12694 }),
12695 ..CT_PPr::default()
12696 });
12697 paragraph.add_run("cache-safe paragraph with many owned tab definitions");
12698 assert!(paragraph_is_cache_safe(¶graph, &input.styles));
12699 input.document.body.add_paragraph(paragraph);
12700
12701 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12702 engine.layout(&input).expect("tab-heavy layout succeeds");
12703 assert!(engine.paragraph_cache.is_empty());
12704 assert_eq!(engine.paragraph_cache_bytes, 0);
12705 }
12706
12707 #[test]
12708 fn paragraph_relayout_cache_counts_all_reflow_parameter_vectors() {
12709 let input = make_input_with_text("reflow parameter accounting template");
12710 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12711 engine.layout(&input).expect("template layout succeeds");
12712 let mut block = engine
12713 .paragraph_cache
12714 .front()
12715 .expect("safe paragraph cached")
12716 .block
12717 .as_ref()
12718 .clone();
12719 let BodyContent::Paragraph(paragraph) = &input.document.body.content[0] else {
12720 panic!("body paragraph");
12721 };
12722 let baseline = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
12723 let reflow = block.reflow.as_mut().expect("cache retains reflow inputs");
12724 reflow.params.tab_stops = vec![
12725 oxml_layout::TabStop {
12726 pos_pt: 36.0,
12727 align: oxml_layout::TabAlign::Left,
12728 leader: None,
12729 };
12730 3
12731 ];
12732 reflow.params.line_prefix_widths = vec![0.0; 5];
12733 reflow.params.line_suffix_widths = vec![0.0; 7];
12734 let with_parameters = paragraph_cache_entry_bytes(paragraph, &block, &[], 0);
12735 let expected =
12736 3 * std::mem::size_of::<oxml_layout::TabStop>() + 12 * std::mem::size_of::<f64>();
12737 assert_eq!(with_parameters - baseline, expected);
12738 }
12739
12740 #[test]
12741 fn paragraph_relayout_cache_counts_fixed_storage_in_owned_keys() {
12742 let input = make_input_with_text("key accounting template");
12743 let mut engine = Engine::new_deterministic().expect("bundled fonts load");
12744 engine.layout(&input).expect("template layout succeeds");
12745 let block = engine
12746 .paragraph_cache
12747 .front()
12748 .expect("safe paragraph cached")
12749 .block
12750 .clone();
12751
12752 let mut paragraph = CT_P::new();
12753 let mut run = CT_R::new("");
12754 let content_count = PARAGRAPH_CACHE_MAX_BYTES / std::mem::size_of::<RunContent>() + 1;
12755 run.content = std::iter::repeat_n(RunContent::Tab, content_count).collect();
12756 paragraph.runs.push(run);
12757 assert!(paragraph_is_cache_safe(¶graph, &input.styles));
12758 let bytes = paragraph_cache_entry_bytes(¶graph, &block, &[], 0);
12759 assert!(bytes > PARAGRAPH_CACHE_MAX_BYTES);
12760
12761 engine.paragraph_cache.clear();
12762 engine.paragraph_cache_bytes = 0;
12763 engine.publish_paragraph_cache_entry(ParagraphCacheEntry {
12764 fingerprint: paragraph_fingerprint(¶graph),
12765 key: ParagraphCacheKey {
12766 paragraph,
12767 content_width_bits: PageGeometry::default().content_width().to_bits(),
12768 revision_view: RevisionView::Accepted,
12769 },
12770 block,
12771 diagnostics: Vec::new(),
12772 font_trace: Vec::new(),
12773 reflow_direction: TextDirection::Auto,
12774 bytes,
12775 });
12776 assert!(engine.paragraph_cache.is_empty());
12777 assert_eq!(engine.paragraph_cache_bytes, 0);
12778 }
12779
12780 #[test]
12781 fn every_sourced_glyph_run_resolves_to_its_exact_word_text() {
12782 use rdocx_oxml::document::CT_SectPr;
12783 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
12784 use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
12785
12786 let body_text = "Body ASCII 🚀 界 wraps across several exact source slices ".repeat(5);
12787 let mut input = make_input_with_text(&body_text);
12788
12789 let mut outer = CT_Tbl::new();
12790 let mut outer_row = CT_Row::new();
12791 let mut outer_cell = CT_Tc::new();
12792 outer_cell.paragraphs_mut()[0].add_run("outer cell");
12793 let mut nested = CT_Tbl::new();
12794 let mut nested_row = CT_Row::new();
12795 let mut nested_cell = CT_Tc::new();
12796 nested_cell.paragraphs_mut()[0].add_run("nested cell");
12797 nested_row.cells.push(nested_cell);
12798 nested.rows.push(nested_row);
12799 outer_cell.content.push(CellContent::Table(nested));
12800 outer_row.cells.push(outer_cell);
12801 outer.rows.push(outer_row);
12802 input.document.body.add_table(outer);
12803
12804 let mut references = CT_P::new();
12805 let mut reference_run = CT_R::new("");
12806 reference_run.content = vec![
12807 RunContent::FootnoteRef { id: 4 },
12808 RunContent::EndnoteRef { id: 9 },
12809 ];
12810 references.runs.push(reference_run);
12811 input.document.body.add_paragraph(references);
12812
12813 let mut header = CT_HdrFtr::new();
12814 let mut header_paragraph = CT_P::new();
12815 header_paragraph.add_run("رأس الصفحة");
12816 header.paragraphs.push(header_paragraph);
12817 input.headers.insert("rIdHeader".to_owned(), header);
12818
12819 let mut footer = CT_HdrFtr::new();
12820 let mut footer_paragraph = CT_P::new();
12821 footer_paragraph.add_run("تذييل الصفحة");
12822 footer.paragraphs.push(footer_paragraph);
12823 input.footers.insert("rIdFooter".to_owned(), footer);
12824
12825 let mut section = CT_SectPr::default_letter();
12826 section.header_refs.push(HdrFtrRef {
12827 hdr_ftr_type: HdrFtrType::Default,
12828 rel_id: "rIdHeader".to_owned(),
12829 });
12830 section.footer_refs.push(HdrFtrRef {
12831 hdr_ftr_type: HdrFtrType::Default,
12832 rel_id: "rIdFooter".to_owned(),
12833 });
12834 input.document.body.sect_pr = Some(section);
12835
12836 let mut footnote_paragraph = CT_P::new();
12837 footnote_paragraph.add_run("footnote text");
12838 input.footnotes = Some(CT_Footnotes {
12839 footnotes: vec![CT_Footnote {
12840 id: 4,
12841 note_type: NoteType::Normal,
12842 paragraphs: vec![footnote_paragraph],
12843 }],
12844 });
12845 let mut endnote_paragraph = CT_P::new();
12846 endnote_paragraph.add_run("endnote text");
12847 input.endnotes = Some(CT_Footnotes {
12848 footnotes: vec![CT_Footnote {
12849 id: 9,
12850 note_type: NoteType::Normal,
12851 paragraphs: vec![endnote_paragraph],
12852 }],
12853 });
12854
12855 let expected = HashMap::from([
12856 (
12857 WordSourcePath {
12858 story: WordStory::Document,
12859 children: vec![0],
12860 },
12861 body_text,
12862 ),
12863 (
12864 WordSourcePath {
12865 story: WordStory::Document,
12866 children: vec![1, 0, 0, 0],
12867 },
12868 "outer cell".to_owned(),
12869 ),
12870 (
12871 WordSourcePath {
12872 story: WordStory::Document,
12873 children: vec![1, 0, 0, 1, 0, 0, 0],
12874 },
12875 "nested cell".to_owned(),
12876 ),
12877 (
12878 WordSourcePath {
12879 story: WordStory::Header {
12880 relationship_id: "rIdHeader".to_owned(),
12881 },
12882 children: vec![0],
12883 },
12884 "رأس الصفحة".to_owned(),
12885 ),
12886 (
12887 WordSourcePath {
12888 story: WordStory::Footer {
12889 relationship_id: "rIdFooter".to_owned(),
12890 },
12891 children: vec![0],
12892 },
12893 "تذييل الصفحة".to_owned(),
12894 ),
12895 (
12896 WordSourcePath {
12897 story: WordStory::Footnote { id: 4 },
12898 children: vec![0],
12899 },
12900 "footnote text".to_owned(),
12901 ),
12902 (
12903 WordSourcePath {
12904 story: WordStory::Endnote { id: 9 },
12905 children: vec![0],
12906 },
12907 "endnote text".to_owned(),
12908 ),
12909 ]);
12910
12911 let result = crate::layout_document_deterministic_with_provenance(&input)
12912 .expect("layout with provenance");
12913 let mut seen = std::collections::HashSet::new();
12914 for (source, text) in result.layout.pages.iter().flat_map(|page| {
12915 compatibility_page_elements(page)
12916 .into_iter()
12917 .filter_map(|element| match element {
12918 PositionedElement::Text(run) => Some((run.source, run.text.as_str())),
12919 PositionedElement::MultilingualText(run) => {
12920 Some((run.source, run.logical_text.as_str()))
12921 }
12922 _ => None,
12923 })
12924 }) {
12925 let Some(span) = source else {
12926 continue;
12927 };
12928 let path = result.source_node(span.node).expect("source node resolves");
12929 let source_text = expected.get(path).expect("source path belongs to fixture");
12930 let selected = source_text
12931 .chars()
12932 .skip(span.char_start as usize)
12933 .take((span.char_end - span.char_start) as usize)
12934 .collect::<String>();
12935 assert_eq!(selected, text, "mismatch at {path:?}");
12936 seen.insert(path.clone());
12937 }
12938 assert_eq!(
12939 seen.len(),
12940 expected.len(),
12941 "every supported story is sourced"
12942 );
12943 for path in expected.keys() {
12944 assert!(seen.contains(path), "missing source path {path:?}");
12945 }
12946
12947 let without_provenance = deterministic_layout(&input);
12948 let rich_header_footer = multilingual_runs(&without_provenance)
12949 .into_iter()
12950 .filter(|run| run.logical_text.contains("الصفحة"))
12951 .collect::<Vec<_>>();
12952 assert_eq!(rich_header_footer.len(), 2);
12953 assert!(
12954 rich_header_footer.iter().all(|run| run.source.is_none()),
12955 "cache-only source IDs must not escape without provenance"
12956 );
12957 }
12958
12959 #[test]
12960 fn repeated_text_and_repeated_stories_keep_distinct_source_nodes() {
12961 use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef};
12962
12963 let repeated = "duplicate phrase ".repeat(220);
12964 let mut input = make_input_with_text(&repeated);
12965 let mut second = CT_P::new();
12966 second.add_run(&repeated);
12967 input.document.body.add_paragraph(second);
12968
12969 let mut header = CT_HdrFtr::new();
12970 let mut paragraph = CT_P::new();
12971 paragraph.add_run("repeated header");
12972 header.paragraphs.push(paragraph);
12973 input.headers.insert("rIdRepeated".to_owned(), header);
12974 input
12975 .document
12976 .body
12977 .sect_pr
12978 .as_mut()
12979 .expect("default section")
12980 .header_refs
12981 .push(HdrFtrRef {
12982 hdr_ftr_type: HdrFtrType::Default,
12983 rel_id: "rIdRepeated".to_owned(),
12984 });
12985
12986 let result = crate::layout_document_deterministic_with_provenance(&input)
12987 .expect("layout repeated stories");
12988 assert!(result.layout.pages.len() > 1, "header must be reused");
12989 let mut first_body = std::collections::HashSet::new();
12990 let mut second_body = std::collections::HashSet::new();
12991 let mut header_nodes = std::collections::HashSet::new();
12992 let mut header_runs = 0usize;
12993 for run in result.layout.pages.iter().flat_map(|page| {
12994 compatibility_page_elements(page)
12995 .into_iter()
12996 .filter_map(|element| match element {
12997 PositionedElement::Text(run) => Some(run),
12998 _ => None,
12999 })
13000 }) {
13001 let Some(source) = run.source else {
13002 continue;
13003 };
13004 match result.source_node(source.node).expect("source resolves") {
13005 WordSourcePath {
13006 story: WordStory::Document,
13007 children,
13008 } if children == &[0] => {
13009 first_body.insert(source.node);
13010 }
13011 WordSourcePath {
13012 story: WordStory::Document,
13013 children,
13014 } if children == &[1] => {
13015 second_body.insert(source.node);
13016 }
13017 WordSourcePath {
13018 story: WordStory::Header { relationship_id },
13019 children,
13020 } if relationship_id == "rIdRepeated" && children == &[0] => {
13021 header_nodes.insert(source.node);
13022 header_runs += 1;
13023 }
13024 _ => {}
13025 }
13026 }
13027 assert_eq!(first_body.len(), 1);
13028 assert_eq!(second_body.len(), 1);
13029 assert_ne!(
13030 first_body, second_body,
13031 "duplicate paragraphs must not alias"
13032 );
13033 assert_eq!(header_nodes.len(), 1, "repeated header reuses one node");
13034 assert!(header_runs > 1, "header must be emitted more than once");
13035 }
13036
13037 #[test]
13038 fn accepted_and_tracked_views_record_projection_local_ranges() {
13039 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>A</w:t></w:r><w:del w:id="1" w:author="Ada"><w:r><w:delText>B</w:delText></w:r></w:del><w:ins w:id="2" w:author="Ada"><w:r><w:t>C</w:t></w:r></w:ins></w:p></w:body></w:document>"#;
13040 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("revision XML parses");
13041 let BodyContent::Paragraph(paragraph) = &document.body.content[0] else {
13042 panic!("expected paragraph");
13043 };
13044
13045 for (view, expected) in [
13046 (RevisionView::Accepted, "AC"),
13047 (RevisionView::Tracked, "ABC"),
13048 ] {
13049 assert_eq!(projected_paragraph_text(paragraph, view), expected);
13050 let mut input = make_input_with_text("");
13051 input.document = document.clone();
13052 input.revision_view = view;
13053 let result = crate::layout_document_deterministic_with_provenance(&input)
13054 .expect("revision layout with provenance");
13055 assert_eq!(result.revision_view, view);
13056 let mut selected = String::new();
13057 for run in result.layout.pages.iter().flat_map(|page| {
13058 compatibility_page_elements(page)
13059 .into_iter()
13060 .filter_map(|element| match element {
13061 PositionedElement::Text(run) => Some(run),
13062 _ => None,
13063 })
13064 }) {
13065 let Some(span) = run.source else {
13066 continue;
13067 };
13068 assert!(matches!(
13069 result.source_node(span.node),
13070 Some(WordSourcePath {
13071 story: WordStory::Document,
13072 children,
13073 }) if children == &[0]
13074 ));
13075 let exact = expected
13076 .chars()
13077 .skip(span.char_start as usize)
13078 .take((span.char_end - span.char_start) as usize)
13079 .collect::<String>();
13080 assert_eq!(exact, run.text);
13081 selected.push_str(&run.text);
13082 }
13083 assert_eq!(selected, expected);
13084 }
13085 }
13086
13087 #[test]
13088 fn field_projection_ownership_disambiguates_repeated_literal_ranges() {
13089 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>DATE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>a</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
13090 let document = rdocx_oxml::CT_Document::from_xml(xml).expect("complex field parses");
13091 let BodyContent::Paragraph(parsed) = &document.body.content[0] else {
13092 panic!("expected paragraph");
13093 };
13094 let [RunContent::Field(complex)] = parsed.runs[0].content.as_slice() else {
13095 panic!("expected projected complex field");
13096 };
13097
13098 let cases = [
13099 (
13100 vec![
13101 RunContent::Field(complex.clone()),
13102 RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
13103 RunContent::Field(Field::new("DATE", "a")),
13104 ],
13105 "aa",
13106 vec![("a", 1, 2)],
13107 ),
13108 (
13109 vec![
13110 RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
13111 RunContent::Field(complex.clone()),
13112 RunContent::Text(rdocx_oxml::text::CT_Text::new("aa")),
13113 RunContent::Field(Field::new("DATE", "a")),
13114 RunContent::Text(rdocx_oxml::text::CT_Text::new("a")),
13115 ],
13116 "aaaaa",
13117 vec![("a", 0, 1), ("aa", 2, 4), ("a", 4, 5)],
13118 ),
13119 ];
13120
13121 for (content, expected_projection, expected_literals) in cases {
13122 let mut input = make_input_with_text("");
13123 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
13124 panic!("expected paragraph");
13125 };
13126 let mut run = CT_R::new("");
13127 run.content = content;
13128 assert_eq!(run.text(), expected_projection);
13129 paragraph.runs = vec![run];
13130
13131 let result = crate::layout_document_deterministic_with_provenance(&input)
13132 .expect("mixed field layout");
13133 let sourced = result
13134 .layout
13135 .pages
13136 .iter()
13137 .flat_map(|page| compatibility_page_elements(page))
13138 .filter_map(|element| match element {
13139 PositionedElement::Text(run) => run
13140 .source
13141 .map(|span| (run.text.as_str(), span.char_start, span.char_end)),
13142 _ => None,
13143 })
13144 .collect::<Vec<_>>();
13145 assert_eq!(sourced, expected_literals);
13146 }
13147 }
13148
13149 #[test]
13150 fn generated_or_transformed_text_remains_unattributed() {
13151 use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
13152 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
13153 use rdocx_oxml::numbering::{
13154 CT_AbstractNum, CT_Lvl, CT_Num, CT_Numbering, ST_NumberFormat,
13155 };
13156 use rdocx_oxml::properties::CT_RPr;
13157 use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
13158 use rdocx_oxml::units::Twips;
13159
13160 let mut input = make_input_with_text("ordinary");
13161
13162 let mut transformed = CT_P::new();
13163 let mut caps = CT_R::new("straße");
13164 caps.properties = Some(CT_RPr {
13165 caps: Some(true),
13166 ..Default::default()
13167 });
13168 transformed.runs.push(caps);
13169 input.document.body.add_paragraph(transformed);
13170
13171 let mut generated = CT_P::new();
13172 generated.properties = Some(CT_PPr {
13173 tabs: Some(CT_Tabs {
13174 tabs: vec![CT_TabStop {
13175 val: ST_TabJc::Left,
13176 pos: Twips(3600),
13177 leader: Some(ST_TabLeader::Dot),
13178 source_occurrence: None,
13179 }],
13180 }),
13181 ..Default::default()
13182 });
13183 let mut generated_run = CT_R::new("");
13184 generated_run.content = vec![
13185 RunContent::Text(rdocx_oxml::text::CT_Text::new("left")),
13186 RunContent::Tab,
13187 RunContent::Text(rdocx_oxml::text::CT_Text::new("right")),
13188 RunContent::Field(Field::new("PAGE", "7")),
13189 RunContent::Text(rdocx_oxml::text::CT_Text::new("after")),
13190 RunContent::FootnoteRef { id: 4 },
13191 ];
13192 generated.runs.push(generated_run);
13193 input.document.body.add_paragraph(generated);
13194
13195 let mut list = CT_P::new();
13196 list.properties = Some(CT_PPr {
13197 num_id: Some(1),
13198 num_ilvl: Some(0),
13199 ..Default::default()
13200 });
13201 list.add_run("listed");
13202 input.document.body.add_paragraph(list);
13203 let mut level = CT_Lvl::new(0);
13204 level.start = Some(1);
13205 level.num_fmt = Some(ST_NumberFormat::Decimal);
13206 level.lvl_text = Some("%1.".to_owned());
13207 let mut abstract_num = CT_AbstractNum::new(1);
13208 abstract_num.levels.push(level);
13209 input.numbering = Some(CT_Numbering {
13210 abstract_nums: vec![abstract_num],
13211 nums: vec![CT_Num {
13212 num_id: 1,
13213 abstract_num_id: 1,
13214 extra_xml: Vec::new(),
13215 extra_attributes: Vec::new(),
13216 }],
13217 root_attributes: Vec::new(),
13218 extra_xml: Vec::new(),
13219 });
13220
13221 let mut note = CT_P::new();
13222 note.add_run("note body");
13223 input.footnotes = Some(CT_Footnotes {
13224 footnotes: vec![CT_Footnote {
13225 id: 4,
13226 note_type: NoteType::Normal,
13227 paragraphs: vec![note],
13228 }],
13229 });
13230
13231 let result = crate::layout_document_deterministic_with_provenance(&input)
13232 .expect("generated text layout");
13233 let runs = result
13234 .layout
13235 .pages
13236 .iter()
13237 .flat_map(|page| compatibility_page_elements(page))
13238 .filter_map(|element| match element {
13239 PositionedElement::Text(run) => Some(run),
13240 _ => None,
13241 })
13242 .collect::<Vec<_>>();
13243 assert!(
13244 runs.iter()
13245 .any(|run| run.text == "ordinary" && run.source.is_some())
13246 );
13247 assert!(
13248 runs.iter()
13249 .any(|run| run.text == "after" && run.source.is_some())
13250 );
13251 assert!(
13252 runs.iter()
13253 .any(|run| run.text == "STRASSE" && run.source.is_none())
13254 );
13255 assert!(
13256 runs.iter()
13257 .any(|run| run.text == "1." && run.source.is_none())
13258 );
13259 assert!(runs.iter().any(|run| {
13260 !run.text.is_empty()
13261 && run.text.chars().all(|character| character == '.')
13262 && run.source.is_none()
13263 }));
13264 assert!(
13265 runs.iter()
13266 .any(|run| run.field_kind == Some(FieldKind::Page) && run.source.is_none())
13267 );
13268 assert!(
13269 runs.iter()
13270 .any(|run| run.note.is_some() && run.source.is_none())
13271 );
13272 }
13273
13274 #[test]
13275 fn existing_low_level_layout_functions_keep_identical_output() {
13276 fn clear_sources(elements: &mut [PositionedElement]) {
13277 for element in elements {
13278 match element {
13279 PositionedElement::Text(run) => run.source = None,
13280 PositionedElement::MarkedContent { children, .. } => clear_sources(children),
13281 _ => {}
13282 }
13283 }
13284 }
13285
13286 let input = make_input_with_text("compatibility 🚀 text that wraps ".repeat(30).as_str());
13287 let ordinary = crate::layout_document_deterministic(&input).expect("ordinary layout");
13288 let mut sourced = crate::layout_document_deterministic_with_provenance(&input)
13289 .expect("provenance layout")
13290 .into_layout_result();
13291 for page in &mut sourced.pages {
13292 clear_sources(&mut Arc::make_mut(page).elements);
13293 }
13294 assert_eq!(format!("{ordinary:?}"), format!("{sourced:?}"));
13295 }
13296
13297 #[test]
13298 fn caller_font_and_deterministic_provenance_variants_return_complete_maps() {
13299 let mut input = make_input_with_text("caller font provenance");
13300 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
13301 panic!("expected paragraph");
13302 };
13303 paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
13304 font_ascii: Some("Caller Carlito".to_owned()),
13305 font_hansi: Some("Caller Carlito".to_owned()),
13306 ..Default::default()
13307 });
13308 input.fonts.push(oxml_layout::FontFile {
13309 family: "Caller Carlito".to_owned(),
13310 data: include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf").to_vec(),
13311 });
13312
13313 let normal = crate::layout_document_with_provenance(&input).expect("caller font layout");
13314 let deterministic = crate::layout_document_deterministic_with_provenance(&input)
13315 .expect("deterministic caller font layout");
13316 for result in [&normal, &deterministic] {
13317 assert!(
13318 result
13319 .layout
13320 .fonts
13321 .iter()
13322 .any(|font| font.data.as_ref() == input.fonts[0].data.as_slice()),
13323 "the caller-provided font bytes shaped the result"
13324 );
13325 let runs = result
13326 .layout
13327 .pages
13328 .iter()
13329 .flat_map(|page| compatibility_page_elements(page))
13330 .filter_map(|element| match element {
13331 PositionedElement::Text(run) if run.source.is_some() => Some(run),
13332 _ => None,
13333 })
13334 .collect::<Vec<_>>();
13335 assert!(!runs.is_empty(), "caller-font text is sourced");
13336 assert_eq!(
13337 runs.iter().map(|run| run.text.as_str()).collect::<String>(),
13338 "caller font provenance"
13339 );
13340 for run in runs {
13341 let source = run.source.expect("run is sourced");
13342 assert!(matches!(
13343 result.source_node(source.node),
13344 Some(WordSourcePath {
13345 story: WordStory::Document,
13346 children,
13347 }) if children == &[0]
13348 ));
13349 }
13350 }
13351 }
13352
13353 #[test]
13354 fn layout_simple_document() {
13355 let input = make_input_with_text("Hello World");
13356 let result = Engine::new().layout(&input);
13357 if let Ok(result) = result {
13359 assert!(!result.pages.is_empty());
13360 assert_eq!(result.pages[0].page_number, 1);
13361 assert!((result.pages[0].width - 612.0).abs() < 0.01);
13362 }
13363 }
13364
13365 #[test]
13366 fn layout_empty_document() {
13367 let mut doc = rdocx_oxml::document::CT_Document::new();
13368 doc.body.add_paragraph(CT_P::new());
13369
13370 let input = LayoutInput {
13371 revision_view: crate::input::RevisionView::Accepted,
13372 automatic_hyphenation: false,
13373 document: doc,
13374 styles: CT_Styles::new_default(),
13375 numbering: None,
13376 headers: HashMap::new(),
13377 footers: HashMap::new(),
13378 images: HashMap::new(),
13379 charts: HashMap::new(),
13380 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
13381 chart_color_map: oxml_drawing::color::ColorMap::default(),
13382 core_properties: None,
13383 hyperlink_urls: HashMap::new(),
13384 footnotes: None,
13385 endnotes: None,
13386 theme: None,
13387 fonts: Vec::new(),
13388 };
13389
13390 let result = Engine::new().layout(&input);
13391 if let Ok(result) = result {
13392 assert_eq!(result.pages.len(), 1);
13393 }
13394 }
13395
13396 #[test]
13397 fn word_blocks_build_document_order_semantics_before_pagination() {
13398 fn paragraph() -> ParagraphBlock {
13399 block::build_paragraph_block(
13400 Vec::new(),
13401 0.0,
13402 0.0,
13403 None,
13404 None,
13405 0.0,
13406 0.0,
13407 None,
13408 false,
13409 false,
13410 false,
13411 true,
13412 )
13413 }
13414
13415 let mut heading = paragraph();
13416 heading.heading_level = Some(1);
13417 let mut list_item = paragraph();
13418 list_item.list = Some((7, 0));
13419 let mut nested_item = paragraph();
13420 nested_item.list = Some((7, 1));
13421 nested_item.lines = vec![oxml_layout::LayoutLine {
13422 items: vec![LineItem::Figure {
13423 item: Box::new(LineItem::Group {
13424 width: 10.0,
13425 height: 10.0,
13426 group: GroupElement {
13427 transform: oxml_layout::Transform::IDENTITY,
13428 clip: None,
13429 opacity: 1.0,
13430 effects: Vec::new(),
13431 children: vec![PositionedElement::FilledRect {
13432 rect: Rect {
13433 x: 0.0,
13434 y: 0.0,
13435 width: 10.0,
13436 height: 10.0,
13437 },
13438 color: Color::BLACK,
13439 }],
13440 },
13441 }),
13442 alternate_text: "Revenue by quarter".to_owned(),
13443 structure_id: None,
13444 }],
13445 width: 10.0,
13446 ascent: 10.0,
13447 descent: 0.0,
13448 line_gap: 0.0,
13449 height: 10.0,
13450 indent_left: 0.0,
13451 available_width: 468.0,
13452 is_last: true,
13453 }];
13454 let cell = |is_first_row: bool| table::TableCell {
13455 structure_id: None,
13456 blocks: vec![table::CellBlock::Paragraph(paragraph())],
13457 width: 100.0,
13458 height: 12.0,
13459 grid_span: 1,
13460 is_vmerge_continue: false,
13461 starts_vmerge: false,
13462 merged_height: 12.0,
13463 merge_with_below: false,
13464 clip_content: false,
13465 col_index: 0,
13466 borders: None,
13467 shading: None,
13468 margin_left: 0.0,
13469 margin_right: 0.0,
13470 margin_top: 0.0,
13471 margin_bottom: 0.0,
13472 is_first_row,
13473 is_last_row: !is_first_row,
13474 v_align: None,
13475 };
13476 let table = table::TableBlock {
13477 structure_id: None,
13478 col_widths: vec![100.0],
13479 rows: vec![
13480 table::TableRow {
13481 structure_id: None,
13482 cells: vec![cell(true)],
13483 height: 12.0,
13484 is_header: true,
13485 },
13486 table::TableRow {
13487 structure_id: None,
13488 cells: vec![cell(false)],
13489 height: 12.0,
13490 is_header: false,
13491 },
13492 ],
13493 header_row_indices: vec![0],
13494 table_width: 100.0,
13495 table_indent: 0.0,
13496 borders: None,
13497 };
13498 let mut sections = [paginator::Section {
13499 blocks: vec![
13500 LayoutBlock::Paragraph(heading),
13501 LayoutBlock::Paragraph(list_item),
13502 LayoutBlock::Paragraph(nested_item),
13503 LayoutBlock::Table(table),
13504 ],
13505 geometry: PageGeometry::default(),
13506 header_footer: None,
13507 title_pg: false,
13508 page_number_start: None,
13509 }];
13510
13511 let structure = assign_document_structure(&mut sections);
13512 let roles = structure
13513 .nodes
13514 .iter()
13515 .map(|node| node.role)
13516 .collect::<Vec<_>>();
13517 assert_eq!(
13518 roles,
13519 [
13520 StructureRole::Document,
13521 StructureRole::Heading(1),
13522 StructureRole::List,
13523 StructureRole::ListItem,
13524 StructureRole::Paragraph,
13525 StructureRole::List,
13526 StructureRole::ListItem,
13527 StructureRole::Paragraph,
13528 StructureRole::Figure,
13529 StructureRole::Table,
13530 StructureRole::TableRow,
13531 StructureRole::TableHeaderCell,
13532 StructureRole::Paragraph,
13533 StructureRole::TableRow,
13534 StructureRole::TableCell,
13535 StructureRole::Paragraph,
13536 ]
13537 );
13538 assert_eq!(
13539 structure.nodes[8].alternate_text.as_deref(),
13540 Some("Revenue by quarter")
13541 );
13542 assert_eq!(structure.nodes[5].children, [structure.nodes[6].id]);
13543 assert_eq!(
13544 structure.nodes[9].children,
13545 [structure.nodes[10].id, structure.nodes[13].id]
13546 );
13547 }
13548
13549 #[test]
13550 fn behind_document_figure_follows_its_source_paragraph_in_structure() {
13551 let mut paragraph = block::build_paragraph_block(
13552 Vec::new(),
13553 0.0,
13554 0.0,
13555 None,
13556 None,
13557 0.0,
13558 0.0,
13559 None,
13560 false,
13561 false,
13562 false,
13563 true,
13564 );
13565 paragraph.anchored.push(block::AnchoredDrawing {
13566 behind_doc: true,
13567 rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
13568 off_h: 0.0,
13569 rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
13570 off_v: 0.0,
13571 width: 10.0,
13572 height: 10.0,
13573 wrap: rdocx_oxml::drawing::WrapType::None,
13574 dist_top: 0.0,
13575 dist_bottom: 0.0,
13576 dist_left: 0.0,
13577 dist_right: 0.0,
13578 align_h: None,
13579 align_v: None,
13580 content: block::AnchoredContent::Image {
13581 media_id: MediaId(1),
13582 },
13583 alternate_text: Some("Background diagram".to_owned()),
13584 structure_id: None,
13585 });
13586 let mut sections = [paginator::Section {
13587 blocks: vec![LayoutBlock::Paragraph(paragraph)],
13588 geometry: PageGeometry::default(),
13589 header_footer: None,
13590 title_pg: false,
13591 page_number_start: None,
13592 }];
13593
13594 let structure = assign_document_structure(&mut sections);
13595
13596 assert_eq!(
13597 structure
13598 .nodes
13599 .iter()
13600 .map(|node| node.role)
13601 .collect::<Vec<_>>(),
13602 [
13603 StructureRole::Document,
13604 StructureRole::Paragraph,
13605 StructureRole::Figure,
13606 ]
13607 );
13608 assert_eq!(
13609 structure.nodes[0].children,
13610 [structure.nodes[1].id, structure.nodes[2].id]
13611 );
13612 assert!(structure.nodes[1].children.is_empty());
13613 assert_eq!(
13614 structure.nodes[2].alternate_text.as_deref(),
13615 Some("Background diagram")
13616 );
13617 }
13618
13619 #[test]
13620 fn empty_shapeless_anchor_keeps_the_pre_cutover_omission() {
13621 let input = make_input_with_text("");
13622 let mut paragraph = CT_P::new();
13623 paragraph.add_run("").content = vec![RunContent::Drawing(
13624 rdocx_oxml::drawing::CT_Drawing::anchor(rdocx_oxml::drawing::CT_Anchor::background(
13625 "", 914_400, 914_400,
13626 )),
13627 )];
13628 let mut font_manager = FontManager::new();
13629 let mut numbering_state = NumberingState::new();
13630 let mut diagnostics = Vec::new();
13631 let media = MediaRegistry::new(&input.images);
13632
13633 let anchored = collect_anchored_drawings(
13634 ¶graph,
13635 &input.styles,
13636 &input,
13637 &media,
13638 &mut font_manager,
13639 &mut numbering_state,
13640 &mut diagnostics,
13641 )
13642 .expect("empty shapeless anchor collection should succeed");
13643
13644 assert!(anchored.is_empty());
13645 }
13646
13647 #[test]
13648 fn colliding_media_ids_keep_inline_and_anchored_image_bytes_distinct() {
13649 let mut input = make_input_with_text("");
13650 input.images.insert(
13651 "rIdInline".to_string(),
13652 ImageData {
13653 data: vec![1, 2, 3],
13654 content_type: "image/png".to_string(),
13655 },
13656 );
13657 input.images.insert(
13658 "rIdAnchor".to_string(),
13659 ImageData {
13660 data: vec![4, 5, 6],
13661 content_type: "image/jpeg".to_string(),
13662 },
13663 );
13664
13665 let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
13666 let inline_id = media.id_for_relationship("rIdInline");
13667 let anchor_id = media.id_for_relationship("rIdAnchor");
13668 assert_ne!(inline_id, anchor_id);
13669
13670 let line = oxml_layout::LayoutLine {
13671 items: vec![oxml_layout::LineItem::Image {
13672 width: 12.0,
13673 height: 10.0,
13674 media_id: inline_id,
13675 }],
13676 width: 12.0,
13677 ascent: 10.0,
13678 descent: 0.0,
13679 line_gap: 0.0,
13680 height: 10.0,
13681 indent_left: 0.0,
13682 available_width: 468.0,
13683 is_last: true,
13684 };
13685 let mut paragraph = block::build_paragraph_block(
13686 vec![line],
13687 0.0,
13688 0.0,
13689 None,
13690 None,
13691 0.0,
13692 0.0,
13693 None,
13694 false,
13695 false,
13696 false,
13697 true,
13698 );
13699 paragraph.anchored.push(block::AnchoredDrawing {
13700 behind_doc: false,
13701 rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
13702 off_h: 20.0,
13703 rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
13704 off_v: 20.0,
13705 width: 12.0,
13706 height: 10.0,
13707 wrap: rdocx_oxml::drawing::WrapType::None,
13708 dist_top: 0.0,
13709 dist_bottom: 0.0,
13710 dist_left: 0.0,
13711 dist_right: 0.0,
13712 align_h: None,
13713 align_v: None,
13714 content: block::AnchoredContent::Image {
13715 media_id: anchor_id,
13716 },
13717 alternate_text: None,
13718 structure_id: None,
13719 });
13720 let sections = [paginator::Section {
13721 blocks: vec![LayoutBlock::Paragraph(paragraph)],
13722 geometry: PageGeometry::default(),
13723 header_footer: None,
13724 title_pg: false,
13725 page_number_start: None,
13726 }];
13727
13728 let (pages, _) = paginator::paginate_sections(
13729 §ions,
13730 &FontManager::new(),
13731 &media,
13732 &NoteRegistry::default(),
13733 );
13734 let images = compatibility_page_elements(&pages[0])
13735 .into_iter()
13736 .filter_map(|element| match element {
13737 PositionedElement::Image {
13738 data,
13739 content_type,
13740 media_id,
13741 ..
13742 } => Some((data.as_slice(), content_type.as_str(), *media_id)),
13743 _ => None,
13744 })
13745 .collect::<Vec<_>>();
13746
13747 assert!(images.contains(&(b"\x01\x02\x03".as_slice(), "image/png", inline_id)));
13748 assert!(images.contains(&(b"\x04\x05\x06".as_slice(), "image/jpeg", anchor_id)));
13749 }
13750
13751 #[test]
13752 fn watermark_image_uses_the_collision_safe_media_registry_id() {
13753 let mut input = make_input_with_text("body");
13754 input.images.insert(
13755 "rIdHeader\0rIdOrdinary".to_owned(),
13756 ImageData {
13757 data: vec![1],
13758 content_type: "image/png".to_owned(),
13759 },
13760 );
13761 input.images.insert(
13762 "rIdHeader\0rIdWatermark".to_owned(),
13763 ImageData {
13764 data: vec![2],
13765 content_type: "image/png".to_owned(),
13766 },
13767 );
13768 let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
13769 let expected = media.id_for_relationship("rIdHeader\0rIdWatermark");
13770 let mut font_manager = FontManager::new();
13771 let mut diagnostics = Vec::new();
13772 let group = layout_watermark(
13773 &VmlWatermark::Image {
13774 relationship_id: "rIdWatermark".to_owned(),
13775 width_pt: 72.0,
13776 height_pt: 36.0,
13777 rotation_degrees: 0.0,
13778 opacity: 0.5,
13779 },
13780 "rIdHeader",
13781 &input,
13782 &media,
13783 &mut font_manager,
13784 PageGeometry::default(),
13785 &mut diagnostics,
13786 )
13787 .unwrap()
13788 .unwrap();
13789 let PositionedElement::Image { media_id, data, .. } = &group.children[0] else {
13790 panic!("expected watermark image");
13791 };
13792 assert_eq!(*media_id, expected);
13793 assert_eq!(data, &[2]);
13794 assert!(diagnostics.is_empty());
13795 }
13796
13797 #[test]
13798 fn group_inline_item_breaks_and_positions_like_an_image() {
13799 let child = PositionedElement::FilledRect {
13800 rect: Rect {
13801 x: 2.0,
13802 y: 3.0,
13803 width: 4.0,
13804 height: 5.0,
13805 },
13806 color: Color::BLACK,
13807 };
13808 let group = GroupElement {
13809 transform: oxml_layout::Transform::IDENTITY,
13810 clip: None,
13811 opacity: 1.0,
13812 effects: Vec::new(),
13813 children: vec![child.clone()],
13814 };
13815 let line = oxml_layout::LayoutLine {
13816 items: vec![oxml_layout::LineItem::Group {
13817 width: 80.0,
13818 height: 40.0,
13819 group,
13820 }],
13821 width: 80.0,
13822 ascent: 40.0,
13823 descent: 0.0,
13824 line_gap: 0.0,
13825 height: 40.0,
13826 indent_left: 0.0,
13827 available_width: 468.0,
13828 is_last: true,
13829 };
13830 let paragraph = block::build_paragraph_block(
13831 vec![line],
13832 0.0,
13833 0.0,
13834 None,
13835 None,
13836 0.0,
13837 0.0,
13838 None,
13839 false,
13840 false,
13841 false,
13842 true,
13843 );
13844 let sections = [paginator::Section {
13845 blocks: vec![LayoutBlock::Paragraph(paragraph)],
13846 geometry: PageGeometry::default(),
13847 header_footer: None,
13848 title_pg: false,
13849 page_number_start: None,
13850 }];
13851 let media = MediaRegistry::new(&HashMap::new());
13852 let (pages, _) = paginator::paginate_sections(
13853 §ions,
13854 &FontManager::new(),
13855 &media,
13856 &NoteRegistry::default(),
13857 );
13858
13859 let PositionedElement::Group(actual) = &pages[0].elements[0] else {
13860 panic!("group line item should become a positioned group");
13861 };
13862 assert_eq!((actual.transform.e, actual.transform.f), (72.0, 72.0));
13863 assert_eq!(actual.children, vec![child]);
13864 }
13865
13866 #[test]
13867 fn layout_with_heading_style() {
13868 let mut doc = rdocx_oxml::document::CT_Document::new();
13869 let mut p = CT_P::new();
13870 p.properties = Some(CT_PPr {
13871 style_id: Some("Heading1".to_string()),
13872 ..Default::default()
13873 });
13874 p.add_run("Chapter 1");
13875 doc.body.add_paragraph(p);
13876
13877 let input = LayoutInput {
13878 revision_view: crate::input::RevisionView::Accepted,
13879 automatic_hyphenation: false,
13880 document: doc,
13881 styles: CT_Styles::new_default(),
13882 numbering: None,
13883 headers: HashMap::new(),
13884 footers: HashMap::new(),
13885 images: HashMap::new(),
13886 charts: HashMap::new(),
13887 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
13888 chart_color_map: oxml_drawing::color::ColorMap::default(),
13889 core_properties: None,
13890 hyperlink_urls: HashMap::new(),
13891 footnotes: None,
13892 endnotes: None,
13893 theme: None,
13894 fonts: Vec::new(),
13895 };
13896
13897 let result = Engine::new().layout(&input);
13898 if let Ok(result) = result {
13899 assert!(!result.pages.is_empty());
13900 assert_eq!(result.outlines.len(), 1);
13902 assert_eq!(result.outlines[0].title, "Chapter 1");
13903 assert_eq!(result.outlines[0].level, 1);
13904 assert_eq!(result.outlines[0].page_index, 0);
13905 }
13906 }
13907
13908 #[test]
13909 fn layout_nested_headings_produce_outlines() {
13910 let mut doc = rdocx_oxml::document::CT_Document::new();
13911
13912 let mut h1 = CT_P::new();
13914 h1.properties = Some(CT_PPr {
13915 style_id: Some("Heading1".to_string()),
13916 ..Default::default()
13917 });
13918 h1.add_run("Chapter 1");
13919 doc.body.add_paragraph(h1);
13920
13921 let mut h2 = CT_P::new();
13923 h2.properties = Some(CT_PPr {
13924 style_id: Some("Heading2".to_string()),
13925 ..Default::default()
13926 });
13927 h2.add_run("Section 1.1");
13928 doc.body.add_paragraph(h2);
13929
13930 let mut h1b = CT_P::new();
13932 h1b.properties = Some(CT_PPr {
13933 style_id: Some("Heading1".to_string()),
13934 ..Default::default()
13935 });
13936 h1b.add_run("Chapter 2");
13937 doc.body.add_paragraph(h1b);
13938
13939 let input = LayoutInput {
13940 revision_view: crate::input::RevisionView::Accepted,
13941 automatic_hyphenation: false,
13942 document: doc,
13943 styles: CT_Styles::new_default(),
13944 numbering: None,
13945 headers: HashMap::new(),
13946 footers: HashMap::new(),
13947 images: HashMap::new(),
13948 charts: HashMap::new(),
13949 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
13950 chart_color_map: oxml_drawing::color::ColorMap::default(),
13951 core_properties: None,
13952 hyperlink_urls: HashMap::new(),
13953 footnotes: None,
13954 endnotes: None,
13955 theme: None,
13956 fonts: Vec::new(),
13957 };
13958
13959 let result = Engine::new().layout(&input);
13960 if let Ok(result) = result {
13961 assert_eq!(result.outlines.len(), 3);
13962 assert_eq!(result.outlines[0].level, 1);
13963 assert_eq!(result.outlines[0].title, "Chapter 1");
13964 assert_eq!(result.outlines[1].level, 2);
13965 assert_eq!(result.outlines[1].title, "Section 1.1");
13966 assert_eq!(result.outlines[2].level, 1);
13967 assert_eq!(result.outlines[2].title, "Chapter 2");
13968 }
13969 }
13970
13971 #[test]
13972 fn sect_pr_geometry_conversion() {
13973 let sect = CT_SectPr::default_letter();
13974 let geom = sect_pr_to_geometry(§);
13975 assert!((geom.page_width - 612.0).abs() < 0.01);
13976 assert!((geom.page_height - 792.0).abs() < 0.01);
13977 assert!((geom.margin_top - 72.0).abs() < 0.01);
13978 assert!((geom.content_width() - 468.0).abs() < 0.01);
13979 }
13980
13981 #[test]
13982 fn section_page_number_start_requires_a_direct_word_child_and_decodes_entities() {
13983 let mut section = CT_SectPr::default_letter();
13984 section.extra_xml = vec![
13985 br#"<x:pgNumType xmlns:x="urn:producer" x:start="2"/>"#.to_vec(),
13986 br#"<w:pgNumType xmlns:w="urn:producer" w:start="2"/>"#.to_vec(),
13987 format!(
13988 r#"<w:wrapper xmlns:w="{}"><w:pgNumType w:start="2"/></w:wrapper>"#,
13989 rdocx_oxml::namespace::W_NS
13990 )
13991 .into_bytes(),
13992 format!(
13993 r#"<q:pgNumType xmlns:q="{}" q:start="1"/>"#,
13994 rdocx_oxml::namespace::W_NS
13995 )
13996 .into_bytes(),
13997 ];
13998
13999 assert_eq!(section_page_number_start(§ion), Some(1));
14000 }
14001
14002 #[test]
14003 fn sect_pr_a4_geometry() {
14004 let sect = CT_SectPr::default_a4();
14005 let geom = sect_pr_to_geometry(§);
14006 assert!((geom.page_width - 595.3).abs() < 0.5);
14008 assert!((geom.page_height - 841.9).abs() < 0.5);
14009 }
14010
14011 fn make_input_with_footnote(note_runs: &[&str]) -> LayoutInput {
14016 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14017 use rdocx_oxml::text::CT_R;
14018
14019 let mut doc = rdocx_oxml::document::CT_Document::new();
14020 let mut body = CT_P::new();
14021 body.add_run("Body text carrying a note");
14022 let mut marker_run = CT_R::new("");
14023 marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
14024 body.runs.push(marker_run);
14025 doc.body.add_paragraph(body);
14026
14027 let mut note = CT_P::new();
14028 for text in note_runs {
14029 note.add_run(text);
14030 }
14031
14032 LayoutInput {
14033 revision_view: crate::input::RevisionView::Accepted,
14034 automatic_hyphenation: false,
14035 document: doc,
14036 styles: CT_Styles::new_default(),
14037 numbering: None,
14038 headers: HashMap::new(),
14039 footers: HashMap::new(),
14040 images: HashMap::new(),
14041 charts: HashMap::new(),
14042 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14043 chart_color_map: oxml_drawing::color::ColorMap::default(),
14044 core_properties: None,
14045 hyperlink_urls: HashMap::new(),
14046 footnotes: Some(CT_Footnotes {
14047 footnotes: vec![CT_Footnote {
14048 id: 1,
14049 note_type: NoteType::Normal,
14050 paragraphs: vec![note],
14051 }],
14052 }),
14053 endnotes: None,
14054 theme: None,
14055 fonts: Vec::new(),
14056 }
14057 }
14058
14059 #[test]
14060 fn automatic_hyphenation_reaches_note_story_paragraphs() {
14061 let mut input = make_input_with_footnote(&["representation"]);
14062 input.automatic_hyphenation = true;
14063 let note = &mut input.footnotes.as_mut().unwrap().footnotes[0].paragraphs[0];
14064 note.properties = Some(CT_PPr {
14065 ind_right: Some(rdocx_oxml::units::Twips(8_500)),
14066 ..Default::default()
14067 });
14068 note.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
14069 language: Some("en-US".to_owned()),
14070 ..Default::default()
14071 });
14072
14073 let text = output_text(&deterministic_layout(&input));
14074 assert!(text.iter().any(|item| item == "-"), "{text:?}");
14075 }
14076
14077 fn footnote_glyph_x(page: &oxml_layout::output::PageFrame) -> Vec<f64> {
14080 let elements = compatibility_page_elements(page);
14081 let separator_y = elements
14082 .iter()
14083 .find_map(|element| match element {
14084 PositionedElement::Line { start, .. } => Some(start.y),
14085 _ => None,
14086 })
14087 .expect("a page with a footnote draws a separator line");
14088
14089 elements
14090 .iter()
14091 .filter_map(|element| match element {
14092 PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.x),
14093 _ => None,
14094 })
14095 .collect()
14096 }
14097
14098 #[test]
14099 fn a_multi_segment_footnote_does_not_stack_its_segments_at_one_x() {
14100 let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
14101 let mut engine = Engine::new();
14102 let output = engine.layout(&input).expect("layout succeeds");
14103 let xs = footnote_glyph_x(&output.pages[0]);
14104
14105 assert!(
14106 xs.len() >= 4,
14107 "expected a marker and three note segments, got {xs:?}"
14108 );
14109 for pair in xs.windows(2) {
14110 assert!(
14111 pair[1] > pair[0],
14112 "footnote segments must advance, got {xs:?}"
14113 );
14114 }
14115 }
14116
14117 #[test]
14118 fn a_single_segment_footnote_keeps_its_original_position() {
14119 let input = make_input_with_footnote(&["Solitary"]);
14120 let mut engine = Engine::new();
14121 let output = engine.layout(&input).expect("layout succeeds");
14122 let xs = footnote_glyph_x(&output.pages[0]);
14123 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14124
14125 assert_eq!(xs.len(), 2, "expected a marker and one segment, got {xs:?}");
14127 assert!(
14128 (xs[0] - geometry.margin_left).abs() < 0.01,
14129 "marker at {xs:?}"
14130 );
14131 assert!(
14132 (xs[1] - (geometry.margin_left + 12.0)).abs() < 0.01,
14133 "segment at {xs:?}"
14134 );
14135 }
14136
14137 #[test]
14138 fn a_long_footnote_does_not_overrun_the_right_margin() {
14139 let long = "In paged media, footnotes are usually displayed at the \
14142 bottom of the text. However, in ebooks, a better paradigm \
14143 is to make them clickable endnotes that the reader can \
14144 browse at leisure, which this sentence exists to force.";
14145 let input = make_input_with_footnote(&[long]);
14146 let mut engine = Engine::new();
14147 let output = engine.layout(&input).expect("layout succeeds");
14148 let page = &output.pages[0];
14149 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14150 let right_margin = geometry.page_width - geometry.margin_right;
14151
14152 let elements = compatibility_page_elements(page);
14153 let separator_y = elements
14154 .iter()
14155 .find_map(|element| match element {
14156 PositionedElement::Line { start, .. } => Some(start.y),
14157 _ => None,
14158 })
14159 .expect("a page with a footnote draws a separator line");
14160
14161 let mut wrapped = false;
14162 let mut first_y = None;
14163 for element in elements {
14164 let PositionedElement::Text(run) = element else {
14165 continue;
14166 };
14167 if run.origin.y <= separator_y {
14168 continue;
14169 }
14170 let first = *first_y.get_or_insert(run.origin.y);
14171 if run.origin.y > first + 0.01 {
14172 wrapped = true;
14173 }
14174 let right_edge = run.origin.x + run.advances.iter().sum::<f64>();
14175 assert!(
14176 right_edge <= right_margin + 0.01,
14177 "note text reaches {right_edge}, past the right margin {right_margin}"
14178 );
14179 }
14180 assert!(wrapped, "the note must wrap for this test to mean anything");
14181 }
14182
14183 #[test]
14184 fn a_tab_inside_a_footnote_still_advances_the_text_after_it() {
14185 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14186 use rdocx_oxml::text::CT_R;
14187
14188 let build = |with_tab: bool| {
14191 let mut doc = rdocx_oxml::document::CT_Document::new();
14192 let mut body = CT_P::new();
14193 body.add_run("Body");
14194 let mut marker_run = CT_R::new("");
14195 marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
14196 body.runs.push(marker_run);
14197 doc.body.add_paragraph(body);
14198
14199 let mut note = CT_P::new();
14200 note.add_run("Alpha");
14201 if with_tab {
14202 let mut tab_run = CT_R::new("");
14203 tab_run.content = vec![RunContent::Tab];
14204 note.runs.push(tab_run);
14205 }
14206 note.add_run("Beta");
14207
14208 LayoutInput {
14209 revision_view: crate::input::RevisionView::Accepted,
14210 automatic_hyphenation: false,
14211 document: doc,
14212 styles: CT_Styles::new_default(),
14213 numbering: None,
14214 headers: HashMap::new(),
14215 footers: HashMap::new(),
14216 images: HashMap::new(),
14217 charts: HashMap::new(),
14218 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14219 chart_color_map: oxml_drawing::color::ColorMap::default(),
14220 core_properties: None,
14221 hyperlink_urls: HashMap::new(),
14222 footnotes: Some(CT_Footnotes {
14223 footnotes: vec![CT_Footnote {
14224 id: 1,
14225 note_type: NoteType::Normal,
14226 paragraphs: vec![note],
14227 }],
14228 }),
14229 endnotes: None,
14230 theme: None,
14231 fonts: Vec::new(),
14232 }
14233 };
14234
14235 let mut engine = Engine::new();
14236 let plain = engine.layout(&build(false)).expect("layout succeeds");
14237 let tabbed = engine.layout(&build(true)).expect("layout succeeds");
14238
14239 let plain_x = footnote_glyph_x(&plain.pages[0]);
14240 let tabbed_x = footnote_glyph_x(&tabbed.pages[0]);
14241
14242 assert_eq!(plain_x.len(), 3, "plain note glyphs {plain_x:?}");
14244 assert_eq!(tabbed_x.len(), 3, "tabbed note glyphs {tabbed_x:?}");
14245 assert!(
14246 tabbed_x[2] > plain_x[2] + 1.0,
14247 "the run after a tab must shift right, plain {plain_x:?} tabbed {tabbed_x:?}"
14248 );
14249 }
14250
14251 #[test]
14252 fn footnote_segment_advance_matches_body_segment_advance() {
14253 let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
14254 let mut engine = Engine::new();
14255 let output = engine.layout(&input).expect("layout succeeds");
14256 let page = &output.pages[0];
14257
14258 let elements = compatibility_page_elements(page);
14259 let separator_y = elements
14260 .iter()
14261 .find_map(|element| match element {
14262 PositionedElement::Line { start, .. } => Some(start.y),
14263 _ => None,
14264 })
14265 .expect("a page with a footnote draws a separator line");
14266
14267 let notes: Vec<&oxml_layout::GlyphRun> = elements
14270 .iter()
14271 .filter_map(|element| match element {
14272 PositionedElement::Text(run) if run.origin.y > separator_y => Some(run),
14273 _ => None,
14274 })
14275 .skip(1) .collect();
14277
14278 assert_eq!(notes.len(), 3, "expected three note segments");
14279 for pair in notes.windows(2) {
14280 let advance: f64 = pair[0].advances.iter().sum();
14281 let gap = pair[1].origin.x - pair[0].origin.x;
14282 assert!(
14283 (gap - advance).abs() < 0.01,
14284 "gap {gap} should equal preceding segment advance {advance}"
14285 );
14286 }
14287 }
14288
14289 fn make_noted_document(
14295 body_paras: usize,
14296 ref_positions: &[usize],
14297 note_paras: usize,
14298 note_text: &str,
14299 continuation_separator: bool,
14300 ) -> LayoutInput {
14301 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14302 use rdocx_oxml::text::CT_R;
14303
14304 let mut doc = rdocx_oxml::document::CT_Document::new();
14305 for index in 0..body_paras {
14306 let mut para = CT_P::new();
14307 para.add_run("Body paragraph text that occupies a line of the page.");
14308 if ref_positions.contains(&index) {
14309 let mut marker = CT_R::new("");
14310 marker.content = vec![RunContent::FootnoteRef { id: 1 }];
14311 para.runs.push(marker);
14312 }
14313 doc.body.add_paragraph(para);
14314 }
14315
14316 let mut entries = Vec::new();
14317 if continuation_separator {
14318 entries.push(CT_Footnote {
14319 id: 0,
14320 note_type: NoteType::ContinuationSeparator,
14321 paragraphs: vec![CT_P::new()],
14322 });
14323 }
14324 entries.push(CT_Footnote {
14325 id: 1,
14326 note_type: NoteType::Normal,
14327 paragraphs: (0..note_paras)
14328 .map(|_| {
14329 let mut p = CT_P::new();
14330 p.add_run(note_text);
14331 p
14332 })
14333 .collect(),
14334 });
14335
14336 LayoutInput {
14337 revision_view: crate::input::RevisionView::Accepted,
14338 automatic_hyphenation: false,
14339 document: doc,
14340 styles: CT_Styles::new_default(),
14341 numbering: None,
14342 headers: HashMap::new(),
14343 footers: HashMap::new(),
14344 images: HashMap::new(),
14345 charts: HashMap::new(),
14346 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14347 chart_color_map: oxml_drawing::color::ColorMap::default(),
14348 core_properties: None,
14349 hyperlink_urls: HashMap::new(),
14350 footnotes: Some(CT_Footnotes { footnotes: entries }),
14351 endnotes: None,
14352 theme: None,
14353 fonts: Vec::new(),
14354 }
14355 }
14356
14357 fn split_at_separator(
14361 page: &oxml_layout::output::PageFrame,
14362 ) -> Option<(f64, Vec<f64>, Vec<String>)> {
14363 let elements = compatibility_page_elements(page);
14364 let separator_index = elements.iter().position(|element| {
14365 matches!(element, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001)
14366 })?;
14367 let PositionedElement::Line { start, end, .. } = elements[separator_index] else {
14368 return None;
14369 };
14370 let separator_y = start.y;
14371 let separator_width = end.x - start.x;
14372
14373 let body_ys: Vec<f64> = elements[..separator_index]
14374 .iter()
14375 .filter_map(|element| match element {
14376 PositionedElement::Text(run) => Some(run.origin.y),
14377 _ => None,
14378 })
14379 .collect();
14380 let note_text: Vec<String> = elements[separator_index + 1..]
14381 .iter()
14382 .filter_map(|element| match element {
14383 PositionedElement::Text(run) => Some(run.text.clone()),
14384 _ => None,
14385 })
14386 .collect();
14387
14388 let _ = separator_y;
14389 Some((separator_width, body_ys, note_text))
14390 }
14391
14392 fn separator_y_of(page: &oxml_layout::output::PageFrame) -> Option<f64> {
14393 compatibility_page_elements(page)
14394 .into_iter()
14395 .find_map(|element| match element {
14396 PositionedElement::Line { start, width, .. } if (*width - 0.5).abs() < 0.001 => {
14397 Some(start.y)
14398 }
14399 _ => None,
14400 })
14401 }
14402
14403 #[test]
14404 fn a_page_whose_body_fills_the_text_area_does_not_overlap_its_notes() {
14405 let input = make_noted_document(
14408 60,
14409 &[0],
14410 2,
14411 "A note long enough to wrap onto a second line of the note area.",
14412 false,
14413 );
14414 let mut engine = Engine::new();
14415 let output = engine.layout(&input).expect("layout succeeds");
14416 let page = &output.pages[0];
14417
14418 let separator_y = separator_y_of(page).expect("the page draws a separator");
14419 let (_, body_ys, note_text) = split_at_separator(page).unwrap();
14420
14421 assert!(!note_text.is_empty(), "the note must be drawn");
14422 let lowest_body = body_ys.iter().cloned().fold(f64::MIN, f64::max);
14423 assert!(
14424 lowest_body < separator_y,
14425 "body text reaches {lowest_body}, at or below the separator at {separator_y}"
14426 );
14427 }
14428
14429 #[test]
14430 fn a_page_referencing_one_note_twice_reserves_it_once() {
14431 let input = make_noted_document(4, &[0, 1], 1, "Referenced twice from one page.", false);
14432 let mut engine = Engine::new();
14433 let output = engine.layout(&input).expect("layout succeeds");
14434 let page = &output.pages[0];
14435
14436 let separators = compatibility_page_elements(page)
14437 .into_iter()
14438 .filter(|e| matches!(e, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001))
14439 .count();
14440 assert_eq!(separators, 1, "one note area, so one separator");
14441
14442 let (_, _, note_text) = split_at_separator(page).unwrap();
14443 let markers = note_text.iter().filter(|t| t.as_str() == "1").count();
14444 assert_eq!(markers, 1, "the note is drawn once, got {note_text:?}");
14445 }
14446
14447 #[test]
14448 fn a_note_taller_than_its_remaining_space_continues_on_the_next_page() {
14449 let input = make_noted_document(30, &[25], 120, "Note paragraph line.", true);
14451 let mut engine = Engine::new();
14452 let output = engine.layout(&input).expect("layout succeeds");
14453
14454 let note_pages: Vec<usize> = output
14455 .pages
14456 .iter()
14457 .enumerate()
14458 .filter(|(_, page)| separator_y_of(page).is_some())
14459 .map(|(index, _)| index)
14460 .collect();
14461
14462 assert!(
14463 note_pages.len() >= 2,
14464 "a note taller than a page must span pages, got {note_pages:?}"
14465 );
14466
14467 let first = split_at_separator(&output.pages[note_pages[0]]).unwrap().2;
14468 let second = split_at_separator(&output.pages[note_pages[1]]).unwrap().2;
14469
14470 assert!(!first.is_empty(), "the first page draws part of the note");
14471 assert!(!second.is_empty(), "the next page draws the rest");
14472 assert_eq!(
14473 first.iter().filter(|t| t.as_str() == "1").count(),
14474 1,
14475 "the marker is drawn on the page the note starts on"
14476 );
14477 assert_eq!(
14478 second.iter().filter(|t| t.as_str() == "1").count(),
14479 0,
14480 "a continuation does not repeat the marker, got {second:?}"
14481 );
14482 }
14483
14484 #[test]
14485 fn a_continued_note_draws_the_continuation_separator() {
14486 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14487
14488 let widths = |continuation: bool| {
14489 let input = make_noted_document(30, &[25], 120, "Note paragraph line.", continuation);
14490 let mut engine = Engine::new();
14491 let output = engine.layout(&input).expect("layout succeeds");
14492 let pages: Vec<usize> = output
14493 .pages
14494 .iter()
14495 .enumerate()
14496 .filter(|(_, page)| separator_y_of(page).is_some())
14497 .map(|(index, _)| index)
14498 .collect();
14499 assert!(pages.len() >= 2, "the note must span pages");
14500 (
14501 split_at_separator(&output.pages[pages[0]]).unwrap().0,
14502 split_at_separator(&output.pages[pages[1]]).unwrap().0,
14503 )
14504 };
14505
14506 let (first, second) = widths(true);
14507 assert!(
14508 (first - geometry.content_width() * 0.33).abs() < 0.5,
14509 "a note starting on its page gets the short rule, got {first}"
14510 );
14511 assert!(
14512 (second - geometry.content_width()).abs() < 0.5,
14513 "a continued note gets the full-width rule, got {second}"
14514 );
14515
14516 let (_, second) = widths(false);
14518 assert!(
14519 (second - geometry.content_width() * 0.33).abs() < 0.5,
14520 "without a continuation separator the short rule is kept, got {second}"
14521 );
14522 }
14523
14524 #[test]
14525 fn an_oversized_note_still_leaves_room_for_body_text() {
14526 let input = make_noted_document(3, &[0], 200, "A line of an enormous note.", true);
14528 let mut engine = Engine::new();
14529 let output = engine.layout(&input).expect("layout terminates");
14530
14531 let (_, body_ys, _) = split_at_separator(&output.pages[0]).unwrap();
14532 assert!(
14533 !body_ys.is_empty(),
14534 "an oversized note must not starve the page of body text"
14535 );
14536 assert!(
14537 output.pages.len() > 1 && output.pages.len() < 100,
14538 "the note spills over a bounded number of pages, got {}",
14539 output.pages.len()
14540 );
14541
14542 for (index, page) in output.pages.iter().enumerate() {
14545 let Some(separator_y) = separator_y_of(page) else {
14546 continue;
14547 };
14548 assert!(
14549 separator_y >= 0.0,
14550 "page {} draws its separator at {separator_y}, off the sheet",
14551 index + 1
14552 );
14553 }
14554 }
14555
14556 #[test]
14557 fn a_note_is_drawn_on_the_page_that_carries_its_reference() {
14558 let mut mismatches = Vec::new();
14563 for position in 0..60 {
14564 let input = make_noted_document(60, &[position], 1, "Note text.", false);
14565 let mut engine = Engine::new();
14566 let output = engine.layout(&input).expect("layout succeeds");
14567
14568 let reference_page = output.pages.iter().position(|page| {
14569 compatibility_page_elements(page)
14570 .into_iter()
14571 .any(|element| {
14572 matches!(element, PositionedElement::Text(run)
14573 if run.note == Some(oxml_layout::NoteRef {
14574 stream: oxml_layout::NoteStream::Footnote,
14575 id: 1,
14576 }))
14577 })
14578 });
14579 let note_page = output
14580 .pages
14581 .iter()
14582 .position(|page| separator_y_of(page).is_some());
14583
14584 if reference_page != note_page {
14585 mismatches.push((position, reference_page, note_page));
14586 }
14587 }
14588
14589 assert!(
14590 mismatches.is_empty(),
14591 "note and reference landed on different pages for (position, ref, note): {mismatches:?}"
14592 );
14593 }
14594
14595 fn make_document_with_both_streams(id: i32, body_paras: usize) -> LayoutInput {
14600 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14601 use rdocx_oxml::text::CT_R;
14602
14603 let mut doc = rdocx_oxml::document::CT_Document::new();
14604 for index in 0..body_paras {
14605 let mut para = CT_P::new();
14606 para.add_run("Body paragraph text that occupies a line of the page.");
14607 if index == 0 {
14608 let mut foot = CT_R::new("");
14609 foot.content = vec![RunContent::FootnoteRef { id }];
14610 para.runs.push(foot);
14611 let mut end = CT_R::new("");
14612 end.content = vec![RunContent::EndnoteRef { id }];
14613 para.runs.push(end);
14614 }
14615 doc.body.add_paragraph(para);
14616 }
14617
14618 let note = |text: &str| {
14619 let mut p = CT_P::new();
14620 p.add_run(text);
14621 CT_Footnote {
14622 id,
14623 note_type: NoteType::Normal,
14624 paragraphs: vec![p],
14625 }
14626 };
14627
14628 LayoutInput {
14629 revision_view: crate::input::RevisionView::Accepted,
14630 automatic_hyphenation: false,
14631 document: doc,
14632 styles: CT_Styles::new_default(),
14633 numbering: None,
14634 headers: HashMap::new(),
14635 footers: HashMap::new(),
14636 images: HashMap::new(),
14637 charts: HashMap::new(),
14638 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14639 chart_color_map: oxml_drawing::color::ColorMap::default(),
14640 core_properties: None,
14641 hyperlink_urls: HashMap::new(),
14642 footnotes: Some(CT_Footnotes {
14643 footnotes: vec![note("FOOTNOTETEXT")],
14644 }),
14645 endnotes: Some(CT_Footnotes {
14646 footnotes: vec![note("ENDNOTETEXT")],
14647 }),
14648 theme: None,
14649 fonts: Vec::new(),
14650 }
14651 }
14652
14653 fn page_text(page: &oxml_layout::output::PageFrame) -> String {
14654 compatibility_page_elements(page)
14655 .into_iter()
14656 .filter_map(|element| match element {
14657 PositionedElement::Text(run) => Some(run.text.as_str()),
14658 _ => None,
14659 })
14660 .collect::<Vec<_>>()
14661 .join(" ")
14662 }
14663
14664 #[test]
14665 fn a_footnote_and_an_endnote_sharing_a_number_render_their_own_text() {
14666 let input = make_document_with_both_streams(2, 3);
14667 let mut engine = Engine::new();
14668 let output = engine.layout(&input).expect("layout succeeds");
14669
14670 let all: String = output
14671 .pages
14672 .iter()
14673 .map(|page| page_text(page))
14674 .collect::<Vec<_>>()
14675 .join(" | ");
14676 assert!(
14677 all.contains("FOOTNOTETEXT"),
14678 "the footnote must render its own text, got {all}"
14679 );
14680 assert!(
14681 all.contains("ENDNOTETEXT"),
14682 "the endnote must render its own text, got {all}"
14683 );
14684 }
14685
14686 #[test]
14687 fn endnotes_render_after_the_last_body_page() {
14688 let input = make_document_with_both_streams(2, 3);
14689 let mut engine = Engine::new();
14690 let output = engine.layout(&input).expect("layout succeeds");
14691
14692 let endnote_page = output
14693 .pages
14694 .iter()
14695 .position(|page| page_text(page).contains("ENDNOTETEXT"))
14696 .expect("the endnote is rendered somewhere");
14697 let last_body_page = output
14698 .pages
14699 .iter()
14700 .rposition(|page| page_text(page).contains("occupies"))
14701 .expect("the body is rendered somewhere");
14702
14703 assert!(
14704 endnote_page > last_body_page,
14705 "endnotes come after every body page, endnote on {endnote_page} and body to {last_body_page}"
14706 );
14707 assert!(
14708 !page_text(&output.pages[endnote_page]).contains("occupies"),
14709 "an endnote page carries no body text"
14710 );
14711 }
14712
14713 #[test]
14714 fn footnotes_and_endnotes_keep_their_own_regions() {
14715 let input = make_document_with_both_streams(2, 3);
14716 let mut engine = Engine::new();
14717 let output = engine.layout(&input).expect("layout succeeds");
14718
14719 let footnote_page = output
14720 .pages
14721 .iter()
14722 .position(|page| page_text(page).contains("FOOTNOTETEXT"))
14723 .expect("the footnote is rendered");
14724
14725 assert!(
14727 page_text(&output.pages[footnote_page]).contains("occupies"),
14728 "a footnote sits on the page carrying its reference"
14729 );
14730 assert!(
14731 separator_y_of(&output.pages[footnote_page]).is_some(),
14732 "the footnote page draws a separator"
14733 );
14734
14735 let endnote_page = output
14738 .pages
14739 .iter()
14740 .position(|page| page_text(page).contains("ENDNOTETEXT"))
14741 .expect("the endnote is rendered");
14742 assert_ne!(footnote_page, endnote_page, "the two regions are distinct");
14743 assert!(
14744 separator_y_of(&output.pages[endnote_page]).is_none(),
14745 "an endnote page draws no separator rule"
14746 );
14747 }
14748
14749 #[test]
14750 fn an_endnote_reference_does_not_reserve_space_at_the_page_foot() {
14751 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
14752 use rdocx_oxml::text::CT_R;
14753
14754 let build = |with_endnote: bool| {
14758 let mut doc = rdocx_oxml::document::CT_Document::new();
14759 for index in 0..60 {
14760 let mut para = CT_P::new();
14761 para.add_run("Body paragraph text that occupies a line of the page.");
14762 if index == 0 && with_endnote {
14763 let mut end = CT_R::new("");
14764 end.content = vec![RunContent::EndnoteRef { id: 1 }];
14765 para.runs.push(end);
14766 }
14767 doc.body.add_paragraph(para);
14768 }
14769 let mut note = CT_P::new();
14770 note.add_run("An endnote that would be tall in the margin.");
14771 LayoutInput {
14772 revision_view: crate::input::RevisionView::Accepted,
14773 automatic_hyphenation: false,
14774 document: doc,
14775 styles: CT_Styles::new_default(),
14776 numbering: None,
14777 headers: HashMap::new(),
14778 footers: HashMap::new(),
14779 images: HashMap::new(),
14780 charts: HashMap::new(),
14781 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14782 chart_color_map: oxml_drawing::color::ColorMap::default(),
14783 core_properties: None,
14784 hyperlink_urls: HashMap::new(),
14785 footnotes: None,
14786 endnotes: Some(CT_Footnotes {
14787 footnotes: vec![CT_Footnote {
14788 id: 1,
14789 note_type: NoteType::Normal,
14790 paragraphs: vec![note],
14791 }],
14792 }),
14793 theme: None,
14794 fonts: Vec::new(),
14795 }
14796 };
14797
14798 let mut engine = Engine::new();
14799 let plain = engine.layout(&build(false)).expect("layout succeeds");
14800 let noted = engine.layout(&build(true)).expect("layout succeeds");
14801
14802 assert_eq!(
14804 noted.pages.len(),
14805 plain.pages.len() + 1,
14806 "an endnote adds its own page and takes none from the body"
14807 );
14808 for (index, page) in noted.pages.iter().enumerate() {
14809 if index < plain.pages.len() {
14810 assert!(
14811 separator_y_of(page).is_none(),
14812 "page {} reserved foot space for an endnote",
14813 index + 1
14814 );
14815 }
14816 }
14817
14818 for (index, plain_page) in plain.pages.iter().enumerate() {
14820 let body_lines = |page: &oxml_layout::output::PageFrame| {
14821 compatibility_page_elements(page)
14822 .into_iter()
14823 .filter(|element| {
14824 matches!(element, PositionedElement::Text(run)
14825 if run.text.starts_with("occupies"))
14826 })
14827 .count()
14828 };
14829 assert_eq!(
14830 body_lines(plain_page),
14831 body_lines(¬ed.pages[index]),
14832 "page {} holds a different amount of body text",
14833 index + 1
14834 );
14835 }
14836 }
14837
14838 fn make_wrapping_document(
14843 wrap: rdocx_oxml::drawing::WrapType,
14844 align: Option<rdocx_oxml::drawing::AnchorAlignH>,
14845 width_pt: f64,
14846 height_pt: f64,
14847 dist_pt: f64,
14848 ) -> LayoutInput {
14849 use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV};
14850 use rdocx_oxml::text::CT_R;
14851 use rdocx_oxml::units::Emu;
14852
14853 let emu = |pt: f64| Emu((pt * 12700.0) as i64);
14854
14855 let mut doc = rdocx_oxml::document::CT_Document::new();
14856 let mut para = CT_P::new();
14857 let mut body = String::new();
14860 for index in 0..40 {
14861 body.push_str(&format!(
14862 "Sentence {index} of running text that fills the paragraph out. "
14863 ));
14864 }
14865 para.add_run(&body);
14866
14867 let mut anchor = CT_Anchor::background("rId1", 0, 0);
14868 anchor.extent_cx = emu(width_pt);
14869 anchor.extent_cy = emu(height_pt);
14870 anchor.behind_doc = false;
14871 anchor.wrap = wrap;
14872 anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
14873 anchor.pos_h_align = align;
14874 anchor.pos_v_relative_from = ST_RelativeFromV::Paragraph;
14875 anchor.pos_v_offset = Emu(0);
14876 anchor.dist_t = emu(dist_pt);
14877 anchor.dist_b = emu(dist_pt);
14878 anchor.dist_l = emu(dist_pt);
14879 anchor.dist_r = emu(dist_pt);
14880
14881 let mut drawing_run = CT_R::new("");
14882 drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
14883 inline: None,
14884 anchor: Some(anchor),
14885 })];
14886 para.runs.push(drawing_run);
14887 doc.body.add_paragraph(para);
14888
14889 let mut images = HashMap::new();
14890 images.insert(
14891 "rId1".to_string(),
14892 ImageData {
14893 data: vec![0u8; 8],
14894 content_type: "image/png".to_string(),
14895 },
14896 );
14897
14898 LayoutInput {
14899 revision_view: crate::input::RevisionView::Accepted,
14900 automatic_hyphenation: false,
14901 document: doc,
14902 styles: CT_Styles::new_default(),
14903 numbering: None,
14904 headers: HashMap::new(),
14905 footers: HashMap::new(),
14906 images,
14907 charts: HashMap::new(),
14908 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
14909 chart_color_map: oxml_drawing::color::ColorMap::default(),
14910 core_properties: None,
14911 hyperlink_urls: HashMap::new(),
14912 footnotes: None,
14913 endnotes: None,
14914 theme: None,
14915 fonts: Vec::new(),
14916 }
14917 }
14918
14919 fn text_extents(page: &oxml_layout::output::PageFrame) -> Vec<(f64, f64)> {
14921 let mut by_line: Vec<(f64, f64, f64)> = Vec::new();
14922 for element in compatibility_page_elements(page) {
14923 let PositionedElement::Text(run) = element else {
14924 continue;
14925 };
14926 let right = run.origin.x + run.advances.iter().sum::<f64>();
14927 if let Some(entry) = by_line
14928 .iter_mut()
14929 .find(|(y, _, _)| (*y - run.origin.y).abs() < 0.01)
14930 {
14931 entry.1 = entry.1.min(run.origin.x);
14932 entry.2 = entry.2.max(right);
14933 } else {
14934 by_line.push((run.origin.y, run.origin.x, right));
14935 }
14936 }
14937 by_line.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
14938 by_line.into_iter().map(|(_, l, r)| (l, r)).collect()
14939 }
14940
14941 #[test]
14942 fn text_wraps_beside_a_left_aligned_square_drawing() {
14943 use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
14944
14945 let input =
14946 make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
14947 let mut engine = Engine::new();
14948 let output = engine.layout(&input).expect("layout succeeds");
14949 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
14950 let extents = text_extents(&output.pages[0]);
14951
14952 assert!(
14953 extents.len() > 2,
14954 "the paragraph must wrap, got {extents:?}"
14955 );
14956
14957 let expected_left = geometry.margin_left + 100.0 + 5.0;
14959 assert!(
14960 (extents[0].0 - expected_left).abs() < 1.0,
14961 "first line should start at {expected_left}, got {:?}",
14962 extents[0]
14963 );
14964
14965 let last = extents.last().unwrap();
14967 assert!(
14968 (last.0 - geometry.margin_left).abs() < 1.0,
14969 "the last line should return to the margin, got {last:?}"
14970 );
14971 }
14972
14973 #[test]
14974 fn drawing_reflow_can_select_a_conditional_hyphen() {
14975 use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
14976
14977 let mut input =
14978 make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 400.0, 40.0, 5.0);
14979 input.automatic_hyphenation = true;
14980 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
14981 panic!("expected paragraph")
14982 };
14983 paragraph.runs[0] = rdocx_oxml::text::CT_R::new("representation representation");
14984 paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
14985 language: Some("en-US".to_owned()),
14986 ..Default::default()
14987 });
14988
14989 let text = output_text(&deterministic_layout(&input));
14990 assert!(text.iter().any(|item| item == "-"), "{text:?}");
14991 }
14992
14993 #[test]
14994 fn drawing_reflow_retains_the_exact_word_rich_baseline() {
14995 use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
14996
14997 let mut input =
14998 make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
14999 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
15000 panic!("expected paragraph")
15001 };
15002 paragraph.properties = Some(CT_PPr {
15003 line_spacing: Some(rdocx_oxml::units::Twips(480)),
15004 line_rule: Some("exact".to_owned()),
15005 ..Default::default()
15006 });
15007 paragraph.runs[0] = rdocx_oxml::text::CT_R::new(&"العربية مرحبا بالعالم ".repeat(12));
15008 paragraph.runs[0].properties = Some(rdocx_oxml::properties::CT_RPr {
15009 sz: Some(rdocx_oxml::units::HalfPoint(48)),
15010 language: Some("ar-SA".to_owned()),
15011 language_bidi: Some("ar-SA".to_owned()),
15012 ..Default::default()
15013 });
15014
15015 let output = deterministic_layout(&input);
15016 let first = multilingual_runs(&output)
15017 .into_iter()
15018 .min_by(|left, right| left.origin.y.total_cmp(&right.origin.y))
15019 .expect("wrapped paragraph emits rich text");
15020 assert!(
15021 (first.origin.y - 91.2).abs() < 0.001,
15022 "wrapped rich baseline was {}, expected 91.2",
15023 first.origin.y
15024 );
15025 }
15026
15027 #[test]
15028 fn drawing_reflow_retains_the_explicit_ltr_paragraph_base() {
15029 use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
15030
15031 let mut input =
15032 make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
15033 input.automatic_hyphenation = true;
15034 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
15035 panic!("expected paragraph")
15036 };
15037 paragraph.properties = Some(CT_PPr {
15038 bidi: Some(false),
15039 ..Default::default()
15040 });
15041 let drawing = paragraph.runs.pop().expect("wrapping drawing run");
15042 let mut arabic = CT_R::new("العربية ");
15043 arabic.properties = Some(CT_RPr {
15044 language_bidi: Some("ar-SA".to_owned()),
15045 ..Default::default()
15046 });
15047 let mut english = CT_R::new(&"representation ".repeat(12));
15048 english.properties = Some(CT_RPr {
15049 language: Some("en-US".to_owned()),
15050 ..Default::default()
15051 });
15052 paragraph.runs = vec![arabic, english, drawing];
15053
15054 let output = deterministic_layout(&input);
15055 let arabic = multilingual_runs(&output)
15056 .into_iter()
15057 .min_by(|left, right| left.origin.y.total_cmp(&right.origin.y))
15058 .expect("Arabic rich run");
15059 let english = output
15060 .pages
15061 .iter()
15062 .flat_map(|page| compatibility_page_elements(page))
15063 .filter_map(|element| match element {
15064 PositionedElement::Text(run) if run.text.contains("repre") => Some(run),
15065 _ => None,
15066 })
15067 .filter(|run| (run.origin.y - arabic.origin.y).abs() < 0.001)
15068 .min_by(|left, right| left.origin.x.total_cmp(&right.origin.x))
15069 .expect("hyphenatable English shares the first line");
15070 assert!(
15071 arabic.origin.x < english.origin.x,
15072 "explicit LTR must survive drawing reflow: Arabic {}, English {}",
15073 arabic.origin.x,
15074 english.origin.x
15075 );
15076 }
15077
15078 #[test]
15079 fn text_wraps_beside_a_right_aligned_square_drawing() {
15080 use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
15081
15082 let input = make_wrapping_document(
15083 WrapType::Square,
15084 Some(AnchorAlignH::Right),
15085 100.0,
15086 40.0,
15087 5.0,
15088 );
15089 let mut engine = Engine::new();
15090 let output = engine.layout(&input).expect("layout succeeds");
15091 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15092 let extents = text_extents(&output.pages[0]);
15093
15094 assert!(
15095 extents.len() > 2,
15096 "the paragraph must wrap, got {extents:?}"
15097 );
15098
15099 let text_right = geometry.page_width - geometry.margin_right;
15101 let drawing_left = text_right - 100.0;
15102 assert!(
15103 (extents[0].0 - geometry.margin_left).abs() < 1.0,
15104 "a right-aligned drawing does not move the line start, got {:?}",
15105 extents[0]
15106 );
15107 assert!(
15108 extents[0].1 <= drawing_left - 5.0 + 1.0,
15109 "the first line should stop before the drawing at {}, got {:?}",
15110 drawing_left - 5.0,
15111 extents[0]
15112 );
15113
15114 let widest = extents
15118 .iter()
15119 .map(|(_, right)| *right)
15120 .fold(f64::MIN, f64::max);
15121 assert!(
15122 widest > drawing_left,
15123 "a line below the drawing should reach past {drawing_left}, got {extents:?}"
15124 );
15125 }
15126
15127 #[test]
15128 fn a_top_and_bottom_drawing_pushes_text_below_it() {
15129 use rdocx_oxml::drawing::WrapType;
15130
15131 let input = make_wrapping_document(WrapType::TopAndBottom, None, 100.0, 40.0, 5.0);
15132 let mut engine = Engine::new();
15133 let output = engine.layout(&input).expect("layout succeeds");
15134 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15135 let extents = text_extents(&output.pages[0]);
15136
15137 assert!(!extents.is_empty(), "the paragraph renders");
15138
15139 let first_baseline = compatibility_page_elements(&output.pages[0])
15142 .into_iter()
15143 .find_map(|element| match element {
15144 PositionedElement::Text(run) => Some(run.origin.y),
15145 _ => None,
15146 })
15147 .expect("text is rendered");
15148 let drawing_bottom = geometry.margin_top + 40.0 + 5.0;
15149 assert!(
15150 first_baseline >= drawing_bottom,
15151 "the first line at {first_baseline} should sit below {drawing_bottom}"
15152 );
15153 }
15154
15155 #[test]
15156 fn a_wrap_none_drawing_leaves_text_untouched() {
15157 use rdocx_oxml::drawing::WrapType;
15158
15159 let with = make_wrapping_document(WrapType::None, None, 100.0, 40.0, 5.0);
15162 let mut engine = Engine::new();
15163 let output = engine.layout(&with).expect("layout succeeds");
15164 let wrapped_extents = text_extents(&output.pages[0]);
15165
15166 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15167 for (left, _) in &wrapped_extents {
15168 assert!(
15169 (left - geometry.margin_left).abs() < 0.01,
15170 "a wrapNone drawing must not indent any line, got {wrapped_extents:?}"
15171 );
15172 }
15173 }
15174
15175 #[test]
15176 fn a_drawing_anchored_to_a_later_paragraph_still_pushes_text_aside() {
15177 use rdocx_oxml::drawing::{
15178 AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
15179 WrapType,
15180 };
15181 use rdocx_oxml::text::CT_R;
15182 use rdocx_oxml::units::Emu;
15183
15184 let emu = |pt: f64| Emu((pt * 12700.0) as i64);
15187 let mut doc = rdocx_oxml::document::CT_Document::new();
15188
15189 let mut first = CT_P::new();
15190 let mut body = String::new();
15191 for index in 0..40 {
15192 body.push_str(&format!("Sentence {index} of running text to fill lines. "));
15193 }
15194 first.add_run(&body);
15195 doc.body.add_paragraph(first);
15196
15197 let mut second = CT_P::new();
15198 second.add_run("A later paragraph that owns the drawing.");
15199 let mut anchor = CT_Anchor::background("rId1", 0, 0);
15200 anchor.extent_cx = emu(100.0);
15201 anchor.extent_cy = emu(40.0);
15202 anchor.behind_doc = false;
15203 anchor.wrap = WrapType::Square;
15204 anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
15205 anchor.pos_h_align = Some(AnchorAlignH::Left);
15206 anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
15209 anchor.pos_v_align = Some(AnchorAlignV::Top);
15210 let mut drawing_run = CT_R::new("");
15211 drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
15212 inline: None,
15213 anchor: Some(anchor),
15214 })];
15215 second.runs.push(drawing_run);
15216 doc.body.add_paragraph(second);
15217
15218 let mut images = HashMap::new();
15219 images.insert(
15220 "rId1".to_string(),
15221 ImageData {
15222 data: vec![0u8; 8],
15223 content_type: "image/png".to_string(),
15224 },
15225 );
15226
15227 let input = LayoutInput {
15228 revision_view: crate::input::RevisionView::Accepted,
15229 automatic_hyphenation: false,
15230 document: doc,
15231 styles: CT_Styles::new_default(),
15232 numbering: None,
15233 headers: HashMap::new(),
15234 footers: HashMap::new(),
15235 images,
15236 charts: HashMap::new(),
15237 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15238 chart_color_map: oxml_drawing::color::ColorMap::default(),
15239 core_properties: None,
15240 hyperlink_urls: HashMap::new(),
15241 footnotes: None,
15242 endnotes: None,
15243 theme: None,
15244 fonts: Vec::new(),
15245 };
15246
15247 let mut engine = Engine::new();
15248 let output = engine.layout(&input).expect("layout succeeds");
15249 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15250 let extents = text_extents(&output.pages[0]);
15251
15252 assert!(!extents.is_empty(), "text renders");
15253 let expected_left = geometry.margin_left + 100.0;
15254 assert!(
15255 extents[0].0 >= expected_left - 1.0,
15256 "the first line of the earlier paragraph should clear the drawing at \
15257 {expected_left}, got {:?}",
15258 extents[0]
15259 );
15260 }
15261
15262 #[test]
15263 fn a_split_paragraph_clearing_a_drawing_stays_inside_the_page() {
15264 use rdocx_oxml::drawing::{
15265 AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
15266 WrapType,
15267 };
15268 use rdocx_oxml::text::CT_R;
15269 use rdocx_oxml::units::Emu;
15270
15271 let emu = |pt: f64| Emu((pt * 12700.0) as i64);
15275 let mut doc = rdocx_oxml::document::CT_Document::new();
15276 let mut para = CT_P::new();
15277 let mut body = String::new();
15278 for index in 0..300 {
15279 body.push_str(&format!("Sentence {index} of a very long paragraph. "));
15280 }
15281 para.add_run(&body);
15282
15283 let mut anchor = CT_Anchor::background("rId1", 0, 0);
15284 anchor.extent_cx = emu(200.0);
15285 anchor.extent_cy = emu(120.0);
15286 anchor.behind_doc = false;
15287 anchor.wrap = WrapType::TopAndBottom;
15288 anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
15289 anchor.pos_h_align = Some(AnchorAlignH::Center);
15290 anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
15291 anchor.pos_v_align = Some(AnchorAlignV::Top);
15292 anchor.dist_b = emu(10.0);
15293 let mut drawing_run = CT_R::new("");
15294 drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
15295 inline: None,
15296 anchor: Some(anchor),
15297 })];
15298 para.runs.push(drawing_run);
15299 doc.body.add_paragraph(para);
15300
15301 let mut images = HashMap::new();
15302 images.insert(
15303 "rId1".to_string(),
15304 ImageData {
15305 data: vec![0u8; 8],
15306 content_type: "image/png".to_string(),
15307 },
15308 );
15309
15310 let input = LayoutInput {
15311 revision_view: crate::input::RevisionView::Accepted,
15312 automatic_hyphenation: false,
15313 document: doc,
15314 styles: CT_Styles::new_default(),
15315 numbering: None,
15316 headers: HashMap::new(),
15317 footers: HashMap::new(),
15318 images,
15319 charts: HashMap::new(),
15320 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15321 chart_color_map: oxml_drawing::color::ColorMap::default(),
15322 core_properties: None,
15323 hyperlink_urls: HashMap::new(),
15324 footnotes: None,
15325 endnotes: None,
15326 theme: None,
15327 fonts: Vec::new(),
15328 };
15329
15330 let mut engine = Engine::new();
15331 let output = engine.layout(&input).expect("layout succeeds");
15332 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
15333 let bottom = geometry.page_height - geometry.margin_bottom;
15334
15335 assert!(output.pages.len() > 1, "the paragraph must split");
15336 for (index, page) in output.pages.iter().enumerate() {
15337 for element in compatibility_page_elements(page) {
15338 let PositionedElement::Text(run) = element else {
15339 continue;
15340 };
15341 assert!(
15342 run.origin.y <= bottom + 0.5,
15343 "page {} draws text at {}, past the bottom margin at {bottom}",
15344 index + 1,
15345 run.origin.y
15346 );
15347 }
15348 }
15349 }
15350
15351 const NOTE_PROSE: &str = "A note long enough that the measure it is broken \
15356 to decides how many lines it occupies, which is the whole point of \
15357 breaking it to the width of the section that references it rather than \
15358 to the width of whichever section happens to come last in the document.";
15359
15360 fn make_two_section_input(first_page_width: i32, endnotes_instead: bool) -> LayoutInput {
15366 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
15367 use rdocx_oxml::text::CT_R;
15368 use rdocx_oxml::units::Twips;
15369
15370 let note_of = |id: i32| {
15371 let mut note = CT_P::new();
15372 note.add_run(NOTE_PROSE);
15373 CT_Footnote {
15374 id,
15375 note_type: NoteType::Normal,
15376 paragraphs: vec![note],
15377 }
15378 };
15379 let reference = |id: i32| {
15380 let mut run = CT_R::new("");
15381 run.content = vec![if endnotes_instead {
15382 RunContent::EndnoteRef { id }
15383 } else {
15384 RunContent::FootnoteRef { id }
15385 }];
15386 run
15387 };
15388
15389 let mut first_sect = CT_SectPr::default_letter();
15390 first_sect.page_width = Some(Twips(first_page_width));
15391
15392 let mut doc = rdocx_oxml::document::CT_Document::new();
15393
15394 let mut first = CT_P::new();
15396 first.add_run("Body text in the first section");
15397 first.runs.push(reference(1));
15398 first.properties = Some(rdocx_oxml::properties::CT_PPr {
15399 sect_pr: Some(first_sect),
15400 ..Default::default()
15401 });
15402 doc.body.add_paragraph(first);
15403
15404 let mut second = CT_P::new();
15405 second.add_run("Body text in the second section");
15406 second.runs.push(reference(2));
15407 doc.body.add_paragraph(second);
15408 doc.body.sect_pr = Some(CT_SectPr::default_letter());
15409
15410 let stream = CT_Footnotes {
15411 footnotes: vec![note_of(1), note_of(2)],
15412 };
15413 LayoutInput {
15414 revision_view: crate::input::RevisionView::Accepted,
15415 automatic_hyphenation: false,
15416 document: doc,
15417 styles: CT_Styles::new_default(),
15418 numbering: None,
15419 headers: HashMap::new(),
15420 footers: HashMap::new(),
15421 images: HashMap::new(),
15422 charts: HashMap::new(),
15423 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15424 chart_color_map: oxml_drawing::color::ColorMap::default(),
15425 core_properties: None,
15426 hyperlink_urls: HashMap::new(),
15427 footnotes: (!endnotes_instead).then(|| stream.clone()),
15428 endnotes: endnotes_instead.then_some(stream),
15429 theme: None,
15430 fonts: Vec::new(),
15431 }
15432 }
15433
15434 fn note_baseline_count(page: &oxml_layout::output::PageFrame) -> usize {
15442 let Some(separator_y) = separator_y_of(page) else {
15443 return 0;
15444 };
15445 let mut baselines: Vec<f64> = compatibility_page_elements(page)
15446 .into_iter()
15447 .filter_map(|element| match element {
15448 PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.y),
15449 _ => None,
15450 })
15451 .collect();
15452 baselines.sort_by(|a, b| a.partial_cmp(b).expect("baselines are finite"));
15453 baselines.dedup_by(|a, b| (*a - *b).abs() < 0.01);
15454 baselines.len()
15455 }
15456
15457 #[test]
15458 fn a_note_is_broken_to_the_width_of_its_own_section() {
15459 let output = Engine::new()
15462 .layout(&make_two_section_input(24480, false))
15463 .expect("layout succeeds");
15464
15465 let wide = note_baseline_count(&output.pages[0]);
15466 let narrow = note_baseline_count(&output.pages[1]);
15467
15468 assert!(wide > 0 && narrow > 0, "both sections must draw their note");
15469 assert!(
15470 wide < narrow,
15471 "the same note took {wide} lines in the wide section and {narrow} \
15472 in the narrow one, so both were broken to one measure"
15473 );
15474 }
15475
15476 #[test]
15477 fn a_single_section_document_lays_notes_out_exactly_as_before() {
15478 let two = Engine::new()
15482 .layout(&make_two_section_input(12240, false))
15483 .expect("layout succeeds");
15484
15485 let single = Engine::new()
15486 .layout(&make_input_with_footnote(&[NOTE_PROSE]))
15487 .expect("layout succeeds");
15488
15489 assert_eq!(
15490 note_baseline_count(&two.pages[0]),
15491 note_baseline_count(&single.pages[0]),
15492 "a note in a letter section stopped matching the same note in a \
15493 single-section letter document"
15494 );
15495
15496 let again = Engine::new()
15498 .layout(&make_input_with_footnote(&[NOTE_PROSE]))
15499 .expect("layout succeeds");
15500 assert_eq!(single.pages.len(), again.pages.len());
15501 assert_eq!(single.pages[0].elements, again.pages[0].elements);
15502 }
15503
15504 #[test]
15505 fn an_endnote_is_broken_to_the_final_sections_width() {
15506 let wide_first = Engine::new()
15510 .layout(&make_two_section_input(24480, true))
15511 .expect("layout succeeds");
15512 let all_narrow = Engine::new()
15513 .layout(&make_two_section_input(12240, true))
15514 .expect("layout succeeds");
15515
15516 let endnote_lines = |output: &LayoutResult| {
15520 output.pages[2..]
15521 .iter()
15522 .map(|page| {
15523 compatibility_page_elements(page)
15524 .into_iter()
15525 .filter(|element| matches!(element, PositionedElement::Text(_)))
15526 .count()
15527 })
15528 .sum::<usize>()
15529 };
15530
15531 assert_eq!(wide_first.pages.len(), all_narrow.pages.len());
15532 assert!(
15533 all_narrow.pages.len() > 2,
15534 "the endnotes must reach pages of their own"
15535 );
15536 assert!(endnote_lines(&all_narrow) > 0, "the endnotes must be drawn");
15537 assert_eq!(
15538 endnote_lines(&wide_first),
15539 endnote_lines(&all_narrow),
15540 "an endnote whose reference sits in a wide section was broken to \
15541 that section rather than to the final one it is drawn in"
15542 );
15543 }
15544
15545 fn make_lookahead_document(
15552 rel_v: rdocx_oxml::drawing::ST_RelativeFromV,
15553 wrap: rdocx_oxml::drawing::WrapType,
15554 off_v_pt: f64,
15555 ) -> LayoutInput {
15556 use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH};
15557 use rdocx_oxml::text::CT_R;
15558 use rdocx_oxml::units::Emu;
15559
15560 let emu = |pt: f64| Emu((pt * 12700.0) as i64);
15561
15562 let mut doc = rdocx_oxml::document::CT_Document::new();
15563
15564 let mut first = CT_P::new();
15565 let mut body = String::new();
15566 for index in 0..30 {
15567 body.push_str(&format!(
15568 "Sentence {index} of running text that fills the paragraph out. "
15569 ));
15570 }
15571 first.add_run(&body);
15572 doc.body.add_paragraph(first);
15573
15574 let mut second = CT_P::new();
15575 second.add_run("The paragraph the drawing is anchored to.");
15576 let mut anchor = CT_Anchor::background("rId1", 0, 0);
15577 anchor.extent_cx = emu(200.0);
15578 anchor.extent_cy = emu(120.0);
15579 anchor.behind_doc = false;
15580 anchor.wrap = wrap;
15581 anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
15582 anchor.pos_h_align = Some(rdocx_oxml::drawing::AnchorAlignH::Right);
15583 anchor.pos_v_relative_from = rel_v;
15584 anchor.pos_v_offset = emu(off_v_pt);
15590
15591 let mut drawing_run = CT_R::new("");
15592 drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
15593 inline: None,
15594 anchor: Some(anchor),
15595 })];
15596 second.runs.push(drawing_run);
15597 doc.body.add_paragraph(second);
15598
15599 let mut images = HashMap::new();
15600 images.insert(
15601 "rId1".to_string(),
15602 ImageData {
15603 data: vec![0u8; 8],
15604 content_type: "image/png".to_string(),
15605 },
15606 );
15607
15608 LayoutInput {
15609 revision_view: crate::input::RevisionView::Accepted,
15610 automatic_hyphenation: false,
15611 document: doc,
15612 styles: CT_Styles::new_default(),
15613 numbering: None,
15614 headers: HashMap::new(),
15615 footers: HashMap::new(),
15616 images,
15617 charts: HashMap::new(),
15618 chart_theme: oxml_drawing::theme::CT_OfficeStyleSheet::office_default(),
15619 chart_color_map: oxml_drawing::color::ColorMap::default(),
15620 core_properties: None,
15621 hyperlink_urls: HashMap::new(),
15622 footnotes: None,
15623 endnotes: None,
15624 theme: None,
15625 fonts: Vec::new(),
15626 }
15627 }
15628
15629 fn body_line_count(output: &LayoutResult) -> usize {
15631 output
15632 .pages
15633 .iter()
15634 .map(|page| text_extents(page).len())
15635 .sum()
15636 }
15637
15638 #[test]
15639 fn a_paragraph_relative_wrapping_drawing_pushes_earlier_text_aside() {
15640 use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
15641
15642 let wrapping = Engine::new()
15647 .layout(&make_lookahead_document(
15648 ST_RelativeFromV::Paragraph,
15649 WrapType::Square,
15650 -120.0,
15651 ))
15652 .expect("layout succeeds");
15653 let ignoring = Engine::new()
15654 .layout(&make_lookahead_document(
15655 ST_RelativeFromV::Paragraph,
15656 WrapType::None,
15657 -120.0,
15658 ))
15659 .expect("layout succeeds");
15660
15661 assert!(
15662 body_line_count(&wrapping) > body_line_count(&ignoring),
15663 "the earlier paragraph took {} lines against {}, so it flowed \
15664 through the drawing rather than around it",
15665 body_line_count(&wrapping),
15666 body_line_count(&ignoring)
15667 );
15668 }
15669
15670 #[test]
15671 fn a_page_relative_drawing_in_a_later_block_still_wraps() {
15672 use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
15673
15674 let wrapping = Engine::new()
15677 .layout(&make_lookahead_document(
15678 ST_RelativeFromV::Page,
15679 WrapType::Square,
15680 150.0,
15681 ))
15682 .expect("layout succeeds");
15683 let ignoring = Engine::new()
15684 .layout(&make_lookahead_document(
15685 ST_RelativeFromV::Page,
15686 WrapType::None,
15687 150.0,
15688 ))
15689 .expect("layout succeeds");
15690
15691 assert!(body_line_count(&wrapping) > body_line_count(&ignoring));
15692 }
15693
15694 #[test]
15695 fn a_second_pass_is_stable_for_the_document_that_earns_it() {
15696 use rdocx_oxml::drawing::{ST_RelativeFromV, WrapType};
15697
15698 let build = || {
15701 Engine::new()
15702 .layout(&make_lookahead_document(
15703 ST_RelativeFromV::Paragraph,
15704 WrapType::Square,
15705 -120.0,
15706 ))
15707 .expect("layout succeeds")
15708 };
15709 let first = build();
15710 let second = build();
15711
15712 assert_eq!(first.pages.len(), second.pages.len());
15713 for (index, page) in first.pages.iter().enumerate() {
15714 assert_eq!(
15715 page.elements,
15716 second.pages[index].elements,
15717 "page {} differs between two runs",
15718 index + 1
15719 );
15720 }
15721 }
15722
15723 fn cross_reference_run(instruction: &str, display: &str) -> rdocx_oxml::text::CT_R {
15724 let mut run = rdocx_oxml::text::CT_R::new("");
15725 run.content = vec![RunContent::Field(Field::new(instruction, display))];
15726 run
15727 }
15728
15729 fn target_paragraph(targets: &[(i32, &str, usize, usize)], text: &str, hidden: bool) -> CT_P {
15730 let mut paragraph = CT_P::new();
15731 paragraph.properties = Some(CT_PPr {
15732 page_break_before: Some(true),
15733 ..Default::default()
15734 });
15735 let mut run = rdocx_oxml::text::CT_R::new(text);
15736 if hidden {
15737 run.properties = Some(rdocx_oxml::properties::CT_RPr {
15738 vanish: Some(true),
15739 ..Default::default()
15740 });
15741 }
15742 paragraph.runs.push(run);
15743 for (id, name, start, end) in targets {
15744 assert!(paragraph.insert_bookmark_start(*start, *id, name));
15745 assert!(paragraph.insert_bookmark_end(*end, *id));
15746 }
15747 paragraph
15748 }
15749
15750 fn output_text(output: &LayoutResult) -> Vec<String> {
15751 let mut text = Vec::new();
15752 for page in &output.pages {
15753 oxml_layout::walk(&page.elements, &mut |element, _| {
15754 if let PositionedElement::Text(run) = element {
15755 text.push(run.text.clone());
15756 }
15757 });
15758 }
15759 text
15760 }
15761
15762 fn deterministic_layout(input: &LayoutInput) -> LayoutResult {
15763 Engine::new_deterministic()
15764 .expect("bundled fonts")
15765 .layout(input)
15766 .expect("layout succeeds")
15767 }
15768
15769 #[test]
15770 fn an_unsupported_complex_field_keeps_its_cached_display() {
15771 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>DATE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>17 August 2026</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
15772 let mut input = make_input_with_text("");
15773 input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
15774
15775 let text = output_text(&deterministic_layout(&input));
15776 assert!(text.concat().contains("17 August 2026"), "{text:?}");
15777 }
15778
15779 #[test]
15780 fn a_complex_field_keeps_each_cached_result_runs_formatting() {
15781 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>DATE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>bold</w:t></w:r><w:r><w:rPr><w:i/></w:rPr><w:t>italic</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
15782 let mut input = make_input_with_text("");
15783 input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
15784
15785 let output = deterministic_layout(&input);
15786 let mut displays = Vec::new();
15787 for page in &output.pages {
15788 oxml_layout::walk(&page.elements, &mut |element, _| {
15789 if let PositionedElement::Text(run) = element
15790 && matches!(run.text.as_str(), "bold" | "italic")
15791 {
15792 displays.push((run.text.clone(), run.bold, run.italic));
15793 }
15794 });
15795 }
15796 assert_eq!(
15797 displays,
15798 vec![
15799 ("bold".to_owned(), true, false),
15800 ("italic".to_owned(), false, true)
15801 ]
15802 );
15803 }
15804
15805 #[test]
15806 fn a_computed_complex_field_keeps_its_cached_result_run_formatting() {
15807 let xml = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>PAGE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr><w:b/><w:i/></w:rPr><w:t>99</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
15808 let mut input = make_input_with_text("");
15809 input.document = rdocx_oxml::CT_Document::from_xml(xml).expect("field document parses");
15810 let BodyContent::Paragraph(paragraph) = &mut input.document.body.content[0] else {
15811 panic!("expected paragraph")
15812 };
15813 let RunContent::Field(field) = &mut paragraph.runs[0].content[0] else {
15814 panic!("expected field")
15815 };
15816 field.cached_result = "edited stored value".to_owned();
15817
15818 let output = deterministic_layout(&input);
15819 let mut displays = Vec::new();
15820 for page in &output.pages {
15821 oxml_layout::walk(&page.elements, &mut |element, _| {
15822 if let PositionedElement::Text(run) = element
15823 && run.text == "1"
15824 {
15825 displays.push((run.bold, run.italic));
15826 }
15827 });
15828 }
15829 assert_eq!(displays, vec![(true, true)]);
15830 }
15831
15832 #[test]
15833 fn a_pageref_inside_a_table_uses_the_final_target_page() {
15834 use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent};
15835
15836 let mut input = make_input_with_text("");
15837 input.document.body.content.clear();
15838 let mut field = CT_P::new();
15839 field
15840 .runs
15841 .push(cross_reference_run("PAGEREF destination", "cached"));
15842 let mut cell = CT_Tc::new();
15843 cell.content = vec![CellContent::Paragraph(field)];
15844 let mut row = CT_Row::new();
15845 row.cells.push(cell);
15846 let mut table = CT_Tbl::new();
15847 table.rows.push(row);
15848 input.document.body.content.push(BodyContent::Table(table));
15849 input.document.body.add_paragraph(target_paragraph(
15850 &[(4, "destination", 0, 1)],
15851 "target",
15852 false,
15853 ));
15854
15855 let output = deterministic_layout(&input);
15856 let text = output_text(&output);
15857 assert!(text.iter().any(|value| value == "2"), "{text:?}");
15858 assert!(!text.iter().any(|value| value == "cached"), "{text:?}");
15859 }
15860
15861 #[test]
15862 fn a_resolved_pageref_uses_a_fixed_pagination_placeholder() {
15863 let build = |display: &str| {
15864 let mut input = make_input_with_text("");
15865 input.document.body.content.clear();
15866 let mut field = CT_P::new();
15867 field
15868 .runs
15869 .push(cross_reference_run("PAGEREF destination", display));
15870 input.document.body.add_paragraph(field);
15871 input.document.body.add_paragraph(target_paragraph(
15872 &[(4, "destination", 0, 1)],
15873 "target",
15874 false,
15875 ));
15876 deterministic_layout(&input)
15877 };
15878 let short = build("7");
15879 let long = build(&"stale display ".repeat(1000));
15880
15881 assert_eq!(short.pages.len(), long.pages.len());
15882 assert_eq!(output_text(&short), output_text(&long));
15883 }
15884
15885 #[test]
15886 fn every_target_at_a_paragraph_end_is_retained() {
15887 let mut input = make_input_with_text("");
15888 input.document.body.content.clear();
15889 let mut fields = CT_P::new();
15890 for name in ["first", "second"] {
15891 fields
15892 .runs
15893 .push(cross_reference_run(&format!("PAGEREF {name}"), "cached"));
15894 }
15895 input.document.body.add_paragraph(fields);
15896 input.document.body.add_paragraph(target_paragraph(
15897 &[(4, "first", 1, 1), (5, "second", 1, 1)],
15898 "target",
15899 false,
15900 ));
15901
15902 let text = output_text(&deterministic_layout(&input));
15903 assert_eq!(
15904 text.iter().filter(|value| value.as_str() == "2").count(),
15905 2,
15906 "{text:?}"
15907 );
15908 }
15909
15910 #[test]
15911 fn a_target_before_hidden_text_is_retained() {
15912 let mut input = make_input_with_text("");
15913 input.document.body.content.clear();
15914 let mut field = CT_P::new();
15915 field
15916 .runs
15917 .push(cross_reference_run("PAGEREF destination", "cached"));
15918 input.document.body.add_paragraph(field);
15919 input.document.body.add_paragraph(target_paragraph(
15920 &[(4, "destination", 0, 1)],
15921 "hidden target",
15922 true,
15923 ));
15924
15925 let text = output_text(&deterministic_layout(&input));
15926 assert!(text.iter().any(|value| value == "2"), "{text:?}");
15927 assert!(!text.iter().any(|value| value == "cached"), "{text:?}");
15928 }
15929}