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