1use rdocx_oxml::document::{BodyContent, CT_SectPr};
4use rdocx_oxml::drawing::WrapType;
5use rdocx_oxml::header_footer::HdrFtrType;
6use rdocx_oxml::properties::CT_PPr;
7use rdocx_oxml::shared::ST_HighlightColor;
8use rdocx_oxml::styles::CT_Styles;
9use rdocx_oxml::text::{BreakType, CT_P, FieldType, RunContent};
10
11use crate::block::{self, LayoutBlock, ParagraphBlock};
12use crate::convert;
13use crate::input::{LayoutInput, MediaRegistry};
14use crate::notes::NoteRegistry;
15use crate::paginator::{self, HeaderFooterContent, PageGeometry};
16use crate::style_resolver::{self, NumberingState};
17use crate::table;
18use oxml_layout::{
19 Color, DocumentMetadata, FieldKind, FontManager, InlineItem, LayoutResult, NoteRef, NoteStream,
20 PageFrame, PositionedElement, Rect, Result, TextSegment, break_into_lines,
21};
22
23pub struct Engine {
25 font_manager: FontManager,
26}
27
28impl Default for Engine {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl Engine {
35 pub fn new() -> Self {
36 Engine {
37 font_manager: FontManager::new(),
38 }
39 }
40
41 pub fn new_deterministic() -> Result<Self> {
43 Ok(Engine {
44 font_manager: FontManager::new_deterministic()?,
45 })
46 }
47
48 pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
50 if !input.fonts.is_empty() {
52 self.font_manager.load_additional_fonts(&input.fonts);
53 }
54
55 let styles = &input.styles;
56 let mut num_state = NumberingState::new();
57 let media = MediaRegistry::new(&input.images);
58
59 let document_wraps = document_has_wrapping_drawing(input);
63
64 let final_sect_pr = input
66 .document
67 .body
68 .sect_pr
69 .as_ref()
70 .cloned()
71 .unwrap_or_else(CT_SectPr::default_letter);
72
73 let mut sections: Vec<paginator::Section> = Vec::new();
75 let mut current_blocks: Vec<LayoutBlock> = Vec::new();
76 let mut current_sect_pr: Option<CT_SectPr> = None; for content in &input.document.body.content {
79 match content {
80 BodyContent::Paragraph(para) => {
81 let para_sect_pr = para.properties.as_ref().and_then(|p| p.sect_pr.clone());
83
84 let sect_pr_for_layout = para_sect_pr
85 .as_ref()
86 .or(current_sect_pr.as_ref())
87 .unwrap_or(&final_sect_pr);
88 let geometry = sect_pr_to_geometry(sect_pr_for_layout);
89
90 let mut para_block = layout_paragraph(
91 para,
92 geometry.content_width(),
93 styles,
94 input,
95 &media,
96 &mut self.font_manager,
97 &mut num_state,
98 )?;
99
100 if !document_wraps {
101 para_block.reflow = None;
102 }
103
104 if let Some(level) = detect_heading_level(para, styles) {
106 para_block.heading_level = Some(level);
107 para_block.heading_text = Some(para.text());
108 }
109
110 current_blocks.push(LayoutBlock::Paragraph(para_block));
111
112 if let Some(sect_pr) = para_sect_pr {
114 let geometry = sect_pr_to_geometry(§_pr);
115 let header_footer = layout_header_footer(
116 §_pr,
117 input,
118 styles,
119 &media,
120 &mut self.font_manager,
121 &mut num_state,
122 )?;
123 let title_pg = sect_pr.title_pg.unwrap_or(false);
124 sections.push(paginator::Section {
125 blocks: std::mem::take(&mut current_blocks),
126 geometry,
127 header_footer,
128 title_pg,
129 });
130 current_sect_pr = Some(sect_pr);
131 }
132 }
133 BodyContent::Table(tbl) => {
134 let sect_pr_for_layout = current_sect_pr.as_ref().unwrap_or(&final_sect_pr);
135 let geometry = sect_pr_to_geometry(sect_pr_for_layout);
136
137 let table_block = table::layout_table(
138 tbl,
139 geometry.content_width(),
140 styles,
141 input,
142 &media,
143 &mut self.font_manager,
144 &mut num_state,
145 )?;
146 current_blocks.push(LayoutBlock::Table(table_block));
147 }
148 _ => {} }
150 }
151
152 let final_geometry = sect_pr_to_geometry(&final_sect_pr);
154 let final_hf = layout_header_footer(
155 &final_sect_pr,
156 input,
157 styles,
158 &media,
159 &mut self.font_manager,
160 &mut num_state,
161 )?;
162 let final_title_pg = final_sect_pr.title_pg.unwrap_or(false);
163 sections.push(paginator::Section {
164 blocks: current_blocks,
165 geometry: final_geometry,
166 header_footer: final_hf,
167 title_pg: final_title_pg,
168 });
169
170 let notes = NoteRegistry::build(
173 input,
174 styles,
175 &media,
176 &mut self.font_manager,
177 &mut num_state,
178 final_geometry.content_width(),
179 )?;
180
181 let (mut pages, outlines) =
183 paginator::paginate_sections(§ions, &self.font_manager, &media, ¬es);
184
185 paginator::append_endnote_pages(&mut pages, ¬es, final_geometry);
188
189 let total_pages = pages.len();
191 for page in &mut pages {
192 let page_num = page.page_number;
193 substitute_fields(
194 &mut page.elements,
195 page_num,
196 total_pages,
197 &mut self.font_manager,
198 );
199 }
200
201 apply_page_background(&mut pages, input);
203
204 let fonts = self.font_manager.all_font_data();
206
207 let metadata = input.core_properties.as_ref().map(|cp| DocumentMetadata {
209 title: cp.title.clone(),
210 author: cp.creator.clone(),
211 subject: cp.subject.clone(),
212 keywords: cp.keywords.clone(),
213 creator: Some("rdocx".to_string()),
214 });
215
216 Ok(LayoutResult::new(pages, fonts, metadata, outlines))
217 }
218}
219
220fn apply_page_background(pages: &mut [PageFrame], input: &LayoutInput) {
222 let bg_xml = match &input.document.background_xml {
223 Some(xml) => xml,
224 None => return,
225 };
226
227 let xml_str = std::str::from_utf8(bg_xml).unwrap_or("");
229 let color = extract_background_color(xml_str);
230 let color = match color {
231 Some(c) => c,
232 None => return,
233 };
234
235 for page in pages.iter_mut() {
237 page.elements.insert(
238 0,
239 PositionedElement::FilledRect {
240 rect: Rect {
241 x: 0.0,
242 y: 0.0,
243 width: page.width,
244 height: page.height,
245 },
246 color,
247 },
248 );
249 }
250}
251
252fn extract_background_color(xml: &str) -> Option<Color> {
254 for attr in ["w:color=\"", "color=\""] {
256 if let Some(start) = xml.find(attr) {
257 let val_start = start + attr.len();
258 if let Some(end) = xml[val_start..].find('"') {
259 let hex = &xml[val_start..val_start + end];
260 if hex.len() == 6 && hex != "auto" {
261 return Some(Color::from_hex(hex));
262 }
263 }
264 }
265 }
266 None
267}
268
269fn substitute_fields(
271 elements: &mut [PositionedElement],
272 page_number: usize,
273 total_pages: usize,
274 fm: &mut FontManager,
275) {
276 for element in elements.iter_mut() {
277 if let PositionedElement::Text(run) = element
278 && let Some(fk) = run.field_kind
279 {
280 let value = match fk {
281 FieldKind::Page => page_number.to_string(),
282 FieldKind::NumPages => total_pages.to_string(),
283 };
284 if let Ok(shaped) = fm.shape_text(run.font_id, &value, run.font_size) {
286 run.text = value;
287 run.glyph_ids = shaped.glyph_ids;
288 run.advances = shaped.advances;
289 }
290 }
291 }
292}
293
294fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
296 let style_id = para.properties.as_ref()?.style_id.as_deref()?;
297 if let Some(rest) = style_id.strip_prefix("Heading") {
299 return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
300 }
301 if let Some(style_def) = styles.get_by_id(style_id)
303 && let Some(ref name) = style_def.name
304 && let Some(rest) = name.strip_prefix("heading ")
305 {
306 return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
307 }
308 None
309}
310
311pub fn layout_paragraph(
313 para: &CT_P,
314 available_width: f64,
315 styles: &CT_Styles,
316 input: &LayoutInput,
317 media: &MediaRegistry,
318 fm: &mut FontManager,
319 num_state: &mut NumberingState,
320) -> Result<ParagraphBlock> {
321 let para_style_id = para.properties.as_ref().and_then(|p| p.style_id.as_deref());
323
324 let resolved_ppr = style_resolver::resolve_paragraph_properties(para_style_id, styles);
325
326 let mut effective_ppr = resolved_ppr;
327
328 let direct_ppr = para.properties.as_ref();
333 let list_num_id = direct_ppr.and_then(|p| p.num_id).or(effective_ppr.num_id);
334 let list_ilvl = direct_ppr
335 .and_then(|p| p.num_ilvl)
336 .or(effective_ppr.num_ilvl)
337 .unwrap_or(0);
338 if let (Some(num_id), Some(numbering)) = (list_num_id, input.numbering.as_ref())
339 && let Some(lvl_ppr) =
340 style_resolver::level_paragraph_properties(num_id, list_ilvl, numbering)
341 {
342 merge_direct_ppr(&mut effective_ppr, lvl_ppr);
343 }
344
345 if let Some(direct_ppr) = direct_ppr {
347 merge_direct_ppr(&mut effective_ppr, direct_ppr);
348 }
349
350 let space_before = effective_ppr.space_before.map(|t| t.to_pt()).unwrap_or(0.0);
352 let space_after = effective_ppr.space_after.map(|t| t.to_pt()).unwrap_or(0.0);
353 let ind_left = effective_ppr.ind_left.map(|t| t.to_pt()).unwrap_or(0.0);
354 let ind_right = effective_ppr.ind_right.map(|t| t.to_pt()).unwrap_or(0.0);
355 let keep_next = effective_ppr.keep_next.unwrap_or(false);
356 let keep_lines = effective_ppr.keep_lines.unwrap_or(false);
357 let page_break_before = effective_ppr.page_break_before.unwrap_or(false);
358 let widow_control = effective_ppr.widow_control.unwrap_or(true);
359 let jc = convert::alignment(effective_ppr.jc);
360
361 let shading = effective_ppr
363 .shading
364 .as_ref()
365 .and_then(|shd| shd.fill.as_ref())
366 .filter(|f| f != &"auto")
367 .map(|f| Color::from_hex(f));
368
369 let mut inline_items = Vec::new();
371
372 if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) {
374 let ilvl = effective_ppr.num_ilvl.unwrap_or(0);
375 if let Some(marker) = style_resolver::generate_marker(num_id, ilvl, numbering, num_state) {
376 let marker_rpr = marker.marker_rpr;
378 let marker_font_size = marker_rpr.sz.map(|hp| hp.to_pt()).unwrap_or_else(|| {
379 style_resolver::resolve_run_properties(para_style_id, None, styles)
380 .sz
381 .map(|hp| hp.to_pt())
382 .unwrap_or(11.0)
383 });
384 let marker_bold = marker_rpr.bold.unwrap_or(false);
385 let marker_italic = marker_rpr.italic.unwrap_or(false);
386 let marker_font_family = marker_rpr.font_ascii.as_deref();
387
388 if let Ok(font_id) = fm.resolve_font_for_text(
391 marker_font_family,
392 marker_bold,
393 marker_italic,
394 &marker.marker_text,
395 ) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size)
396 {
397 let metrics = fm.metrics(font_id, marker_font_size)?;
398 let color = marker_rpr
399 .color
400 .as_ref()
401 .map(|c| Color::from_hex(c))
402 .unwrap_or(Color::BLACK);
403
404 inline_items.push(InlineItem::Marker(TextSegment {
405 text: marker.marker_text,
406 font_id,
407 font_size: marker_font_size,
408 glyph_ids: shaped.glyph_ids,
409 advances: shaped.advances,
410 width: shaped.width,
411 ascent: metrics.ascent,
412 descent: metrics.descent,
413 line_gap: 0.0,
414 color,
415 bold: marker_bold,
416 italic: marker_italic,
417 underline: None,
418 strike: false,
419 dstrike: false,
420 highlight: None,
421 baseline_offset: 0.0,
422 hyperlink_url: None,
423 field_kind: None,
424 note: None,
425 }));
426
427 inline_items.push(InlineItem::Tab);
429 }
430 }
431 }
432
433 let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
435 std::collections::HashMap::new();
436 for hl in ¶.hyperlinks {
437 if let Some(ref rel_id) = hl.rel_id
438 && let Some(url) = input.hyperlink_urls.get(rel_id)
439 {
440 for run_idx in hl.run_start..hl.run_end {
441 run_hyperlink_url.insert(run_idx, url.clone());
442 }
443 }
444 }
445
446 for (run_idx, run) in para.runs.iter().enumerate() {
448 let current_hyperlink_url = run_hyperlink_url.get(&run_idx).cloned();
449
450 let run_style_id = run.properties.as_ref().and_then(|p| p.style_id.as_deref());
451
452 let resolved_rpr =
453 style_resolver::resolve_run_properties(para_style_id, run_style_id, styles);
454
455 let mut effective_rpr = resolved_rpr;
457 if let Some(ref direct_rpr) = run.properties {
458 effective_rpr.merge_from(direct_rpr);
459 }
460
461 if effective_rpr.vanish == Some(true) {
463 continue;
464 }
465
466 let mut font_size = effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
467 let bold = effective_rpr.bold.unwrap_or(false);
468 let italic = effective_rpr.italic.unwrap_or(false);
469
470 let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
472
473 let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
475
476 let underline = convert::underline(effective_rpr.underline);
478 let strike = effective_rpr.strike.unwrap_or(false);
479 let dstrike = effective_rpr.dstrike.unwrap_or(false);
480 let highlight = effective_rpr.highlight.and_then(highlight_to_color);
481
482 let mut baseline_offset = 0.0;
484 if let Some(ref va) = effective_rpr.vert_align {
485 match va.as_str() {
486 "superscript" => {
487 let original_size = font_size;
489 font_size *= 0.58;
490 baseline_offset = original_size * 0.33; }
492 "subscript" => {
493 let original_size = font_size;
495 font_size *= 0.58;
496 baseline_offset = -(original_size * 0.14); }
498 _ => {}
499 }
500 }
501
502 if let Some(pos) = effective_rpr.position {
504 baseline_offset += pos as f64 / 2.0; }
506
507 let font_id =
510 fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?;
511 let metrics = fm.metrics(font_id, font_size)?;
512
513 for content in &run.content {
514 match content {
515 RunContent::Text(ct_text) => {
516 let text = if effective_rpr.caps == Some(true) {
517 ct_text.text.to_uppercase()
518 } else {
519 ct_text.text.clone()
520 };
521
522 if text.is_empty() {
523 continue;
524 }
525
526 let mut shaped = fm.shape_text(font_id, &text, font_size)?;
527
528 if let Some(spacing) = effective_rpr.spacing {
530 let extra = spacing.to_pt();
531 for advance in &mut shaped.advances {
532 *advance += extra;
533 }
534 shaped.width += extra * shaped.advances.len() as f64;
535 }
536
537 inline_items.extend(convert::text_segments(TextSegment {
538 text,
539 font_id,
540 font_size,
541 glyph_ids: shaped.glyph_ids,
542 advances: shaped.advances,
543 width: shaped.width,
544 ascent: metrics.ascent,
545 descent: metrics.descent,
546 line_gap: 0.0,
547 color,
548 bold,
549 italic,
550 underline,
551 strike,
552 dstrike,
553 highlight,
554 baseline_offset,
555 hyperlink_url: current_hyperlink_url.clone(),
556 field_kind: None,
557 note: None,
558 }));
559 }
560 RunContent::Tab => {
561 inline_items.push(InlineItem::Tab);
562 }
563 RunContent::Break(bt) => match bt {
564 BreakType::Line => inline_items.push(InlineItem::LineBreak),
565 BreakType::Page => inline_items.push(InlineItem::PageBreak),
566 BreakType::Column => inline_items.push(InlineItem::ColumnBreak),
567 },
568 RunContent::Drawing(drawing) => {
569 if let Some(ref inline) = drawing.inline {
570 let width = inline.extent_cx.to_pt();
571 let height = inline.extent_cy.to_pt();
572 inline_items.push(InlineItem::Image {
573 width,
574 height,
575 media_id: media.id_for_relationship(&inline.embed_id),
576 });
577 }
578 }
579 RunContent::Field { field_type } => {
580 let placeholder = "99";
582 let fk = match field_type {
583 FieldType::Page => FieldKind::Page,
584 FieldType::NumPages => FieldKind::NumPages,
585 FieldType::Other(_) => continue, };
587 let shaped = fm.shape_text(font_id, placeholder, font_size)?;
588 inline_items.push(InlineItem::Text(TextSegment {
589 text: placeholder.to_string(),
590 font_id,
591 font_size,
592 glyph_ids: shaped.glyph_ids,
593 advances: shaped.advances,
594 width: shaped.width,
595 ascent: metrics.ascent,
596 descent: metrics.descent,
597 line_gap: 0.0,
598 color,
599 bold,
600 italic,
601 underline: None,
602 strike: false,
603 dstrike: false,
604 highlight: None,
605 baseline_offset,
606 hyperlink_url: None,
607 field_kind: Some(fk),
608 note: None,
609 }));
610 }
611 RunContent::FootnoteRef { id } | RunContent::EndnoteRef { id } => {
612 let stream = match content {
615 RunContent::EndnoteRef { .. } => NoteStream::Endnote,
616 _ => NoteStream::Footnote,
617 };
618 let marker = id.to_string();
620 let sup_size = font_size * 0.58;
621 let sup_offset = font_size * 0.33; let shaped = fm.shape_text(font_id, &marker, sup_size)?;
623 let sup_metrics = fm.metrics(font_id, sup_size)?;
624 inline_items.push(InlineItem::Text(TextSegment {
625 text: marker,
626 font_id,
627 font_size: sup_size,
628 glyph_ids: shaped.glyph_ids,
629 advances: shaped.advances,
630 width: shaped.width,
631 ascent: sup_metrics.ascent,
632 descent: sup_metrics.descent,
633 line_gap: 0.0,
634 color,
635 bold,
636 italic,
637 underline: None,
638 strike: false,
639 dstrike: false,
640 highlight: None,
641 baseline_offset: sup_offset,
642 hyperlink_url: None,
643 field_kind: None,
644 note: Some(NoteRef { stream, id: *id }),
645 }));
646 }
647 }
648 }
649 }
650
651 let line_params = convert::line_break_params(&effective_ppr, available_width);
653
654 let mut lines = break_into_lines(&inline_items, &line_params, fm)?;
655 convert::restore_word_line_heights(&mut lines, &effective_ppr);
656
657 let mut result = block::build_paragraph_block(
658 lines,
659 space_before,
660 space_after,
661 effective_ppr.borders,
662 shading,
663 ind_left,
664 ind_right,
665 jc,
666 keep_next,
667 keep_lines,
668 page_break_before,
669 widow_control,
670 );
671 result.anchored = collect_anchored_drawings(para, styles, input, media, fm, num_state)?;
672 result.reflow = Some(Box::new(block::ParagraphReflow {
676 items: inline_items,
677 params: line_params,
678 }));
679 Ok(result)
680}
681
682fn document_has_wrapping_drawing(input: &LayoutInput) -> bool {
687 fn paragraph_wraps(para: &CT_P) -> bool {
688 para.runs.iter().any(|run| {
689 run.content
690 .iter()
691 .filter_map(|rc| match rc {
692 RunContent::Drawing(d) => Some(d),
693 _ => None,
694 })
695 .chain(run.alt_drawings.iter())
696 .any(|drawing| {
697 drawing
698 .anchor
699 .as_ref()
700 .is_some_and(|anchor| anchor.wrap != WrapType::None)
701 })
702 })
703 }
704
705 input
706 .document
707 .body
708 .content
709 .iter()
710 .any(|content| match content {
711 BodyContent::Paragraph(para) => paragraph_wraps(para),
712 BodyContent::Table(table) => table
713 .rows
714 .iter()
715 .flat_map(|row| row.cells.iter())
716 .flat_map(|cell| cell.content.iter())
717 .any(|content| match content {
718 rdocx_oxml::table::CellContent::Paragraph(para) => paragraph_wraps(para),
719 rdocx_oxml::table::CellContent::Table(_) => false,
722 }),
723 _ => false,
724 })
725}
726
727fn collect_anchored_drawings(
736 para: &CT_P,
737 styles: &CT_Styles,
738 input: &LayoutInput,
739 media: &MediaRegistry,
740 fm: &mut FontManager,
741 num_state: &mut NumberingState,
742) -> Result<Vec<block::AnchoredDrawing>> {
743 let mut out = Vec::new();
744
745 for run in ¶.runs {
748 let plain = run.content.iter().filter_map(|rc| match rc {
749 RunContent::Drawing(d) => Some(d),
750 _ => None,
751 });
752 for drawing in plain.chain(run.alt_drawings.iter()) {
753 let Some(anchor) = drawing.anchor.as_ref() else {
754 continue;
755 };
756
757 let shape = if anchor.embed_id.is_empty() {
761 anchor.shape.as_ref()
762 } else {
763 None
764 };
765
766 let content = match shape {
767 Some(shape) => {
768 let mut text = Vec::new();
770 for p in &shape.text {
771 text.push(layout_paragraph(
772 p,
773 anchor.extent_cx.to_pt(),
774 styles,
775 input,
776 media,
777 fm,
778 num_state,
779 )?);
780 }
781 block::AnchoredContent::Shape {
782 preset: block::ShapePreset::from_prst(shape.preset.as_deref()),
783 fill: shape.solid_fill.as_deref().map(Color::from_hex),
784 text,
785 }
786 }
787 None if anchor.embed_id.is_empty() => continue,
788 None => block::AnchoredContent::Image {
789 media_id: media.id_for_relationship(&anchor.embed_id),
790 },
791 };
792
793 out.push(block::AnchoredDrawing {
794 behind_doc: anchor.behind_doc,
795 rel_h: anchor.pos_h_relative_from,
796 off_h: anchor.pos_h_offset.to_pt(),
797 rel_v: anchor.pos_v_relative_from,
798 off_v: anchor.pos_v_offset.to_pt(),
799 width: anchor.extent_cx.to_pt(),
800 height: anchor.extent_cy.to_pt(),
801 wrap: anchor.wrap,
802 dist_top: anchor.dist_t.to_pt(),
803 dist_bottom: anchor.dist_b.to_pt(),
804 dist_left: anchor.dist_l.to_pt(),
805 dist_right: anchor.dist_r.to_pt(),
806 align_h: anchor.pos_h_align,
807 align_v: anchor.pos_v_align,
808 content,
809 });
810 }
811 }
812 Ok(out)
813}
814
815fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
817 if direct.jc.is_some() {
819 effective.jc = direct.jc;
820 }
821 if direct.space_before.is_some() {
822 effective.space_before = direct.space_before;
823 }
824 if direct.space_after.is_some() {
825 effective.space_after = direct.space_after;
826 }
827 if direct.line_spacing.is_some() {
828 effective.line_spacing = direct.line_spacing;
829 }
830 if direct.line_rule.is_some() {
831 effective.line_rule = direct.line_rule.clone();
832 }
833 if direct.ind_left.is_some() {
834 effective.ind_left = direct.ind_left;
835 }
836 if direct.ind_right.is_some() {
837 effective.ind_right = direct.ind_right;
838 }
839 if direct.ind_first_line.is_some() {
840 effective.ind_first_line = direct.ind_first_line;
841 }
842 if direct.ind_hanging.is_some() {
843 effective.ind_hanging = direct.ind_hanging;
844 }
845 if direct.keep_next.is_some() {
846 effective.keep_next = direct.keep_next;
847 }
848 if direct.keep_lines.is_some() {
849 effective.keep_lines = direct.keep_lines;
850 }
851 if direct.page_break_before.is_some() {
852 effective.page_break_before = direct.page_break_before;
853 }
854 if direct.widow_control.is_some() {
855 effective.widow_control = direct.widow_control;
856 }
857 if direct.borders.is_some() {
858 effective.borders = direct.borders.clone();
859 }
860 if direct.tabs.is_some() {
861 effective.tabs = direct.tabs.clone();
862 }
863 if direct.shading.is_some() {
864 effective.shading = direct.shading.clone();
865 }
866 if direct.num_id.is_some() {
867 effective.num_id = direct.num_id;
868 }
869 if direct.num_ilvl.is_some() {
870 effective.num_ilvl = direct.num_ilvl;
871 }
872}
873
874fn sect_pr_to_geometry(sect_pr: &CT_SectPr) -> PageGeometry {
876 PageGeometry {
877 page_width: sect_pr.page_width.map(|t| t.to_pt()).unwrap_or(612.0),
878 page_height: sect_pr.page_height.map(|t| t.to_pt()).unwrap_or(792.0),
879 margin_top: sect_pr.margin_top.map(|t| t.to_pt()).unwrap_or(72.0),
880 margin_right: sect_pr.margin_right.map(|t| t.to_pt()).unwrap_or(72.0),
881 margin_bottom: sect_pr.margin_bottom.map(|t| t.to_pt()).unwrap_or(72.0),
882 margin_left: sect_pr.margin_left.map(|t| t.to_pt()).unwrap_or(72.0),
883 header_distance: sect_pr.header_distance.map(|t| t.to_pt()).unwrap_or(36.0),
884 footer_distance: sect_pr.footer_distance.map(|t| t.to_pt()).unwrap_or(36.0),
885 }
886}
887
888fn layout_header_footer(
890 sect_pr: &CT_SectPr,
891 input: &LayoutInput,
892 styles: &CT_Styles,
893 media: &MediaRegistry,
894 fm: &mut FontManager,
895 num_state: &mut NumberingState,
896) -> Result<Option<HeaderFooterContent>> {
897 let mut has_content = false;
898 let mut header_blocks = Vec::new();
899 let mut footer_blocks = Vec::new();
900 let mut first_header_blocks = Vec::new();
901 let mut first_footer_blocks = Vec::new();
902
903 let geometry = sect_pr_to_geometry(sect_pr);
904 let width = geometry.content_width();
905
906 for href in §_pr.header_refs {
907 let target_blocks = match href.hdr_ftr_type {
908 HdrFtrType::Default => &mut header_blocks,
909 HdrFtrType::First => &mut first_header_blocks,
910 _ => continue, };
912 if let Some(hdr) = input.headers.get(&href.rel_id) {
913 for para in &hdr.paragraphs {
914 let block = layout_paragraph(para, width, styles, input, media, fm, num_state)?;
915 target_blocks.push(block);
916 }
917 has_content = true;
918 }
919 }
920
921 for fref in §_pr.footer_refs {
922 let target_blocks = match fref.hdr_ftr_type {
923 HdrFtrType::Default => &mut footer_blocks,
924 HdrFtrType::First => &mut first_footer_blocks,
925 _ => continue, };
927 if let Some(ftr) = input.footers.get(&fref.rel_id) {
928 for para in &ftr.paragraphs {
929 let block = layout_paragraph(para, width, styles, input, media, fm, num_state)?;
930 target_blocks.push(block);
931 }
932 has_content = true;
933 }
934 }
935
936 if has_content {
937 Ok(Some(HeaderFooterContent {
938 header_blocks,
939 footer_blocks,
940 first_header_blocks,
941 first_footer_blocks,
942 }))
943 } else {
944 Ok(None)
945 }
946}
947
948fn resolve_font_family(
952 rpr: &rdocx_oxml::properties::CT_RPr,
953 theme: Option<&rdocx_oxml::theme::Theme>,
954) -> Option<String> {
955 if rpr.font_ascii.is_some() {
957 return rpr.font_ascii.clone();
958 }
959
960 if let (Some(theme_ref), Some(theme)) = (&rpr.font_ascii_theme, theme) {
962 let font = match theme_ref.as_str() {
963 "majorAscii" | "majorHAnsi" | "majorBidi" | "majorEastAsia" => {
964 theme.major_font.as_deref()
965 }
966 "minorAscii" | "minorHAnsi" | "minorBidi" | "minorEastAsia" => {
967 theme.minor_font.as_deref()
968 }
969 _ => None,
970 };
971 if let Some(f) = font {
972 return Some(f.to_string());
973 }
974 }
975
976 None
977}
978
979fn resolve_run_color(
983 rpr: &rdocx_oxml::properties::CT_RPr,
984 theme: Option<&rdocx_oxml::theme::Theme>,
985) -> Color {
986 if let Some(ref theme_name) = rpr.color_theme
988 && let Some(theme) = theme
989 && let Some(hex) = theme.colors.get(theme_name)
990 {
991 return Color::from_hex(hex);
992 }
993
994 rpr.color
996 .as_ref()
997 .filter(|c| c.as_str() != "auto")
998 .map(|c| Color::from_hex(c))
999 .unwrap_or(Color::BLACK)
1000}
1001
1002fn highlight_to_color(h: ST_HighlightColor) -> Option<Color> {
1004 match h {
1005 ST_HighlightColor::None => None,
1006 ST_HighlightColor::Black => Some(Color {
1007 r: 0.0,
1008 g: 0.0,
1009 b: 0.0,
1010 a: 1.0,
1011 }),
1012 ST_HighlightColor::Blue => Some(Color {
1013 r: 0.0,
1014 g: 0.0,
1015 b: 1.0,
1016 a: 1.0,
1017 }),
1018 ST_HighlightColor::Cyan => Some(Color {
1019 r: 0.0,
1020 g: 1.0,
1021 b: 1.0,
1022 a: 1.0,
1023 }),
1024 ST_HighlightColor::DarkBlue => Some(Color {
1025 r: 0.0,
1026 g: 0.0,
1027 b: 0.545,
1028 a: 1.0,
1029 }),
1030 ST_HighlightColor::DarkCyan => Some(Color {
1031 r: 0.0,
1032 g: 0.545,
1033 b: 0.545,
1034 a: 1.0,
1035 }),
1036 ST_HighlightColor::DarkGray => Some(Color {
1037 r: 0.663,
1038 g: 0.663,
1039 b: 0.663,
1040 a: 1.0,
1041 }),
1042 ST_HighlightColor::DarkGreen => Some(Color {
1043 r: 0.0,
1044 g: 0.392,
1045 b: 0.0,
1046 a: 1.0,
1047 }),
1048 ST_HighlightColor::DarkMagenta => Some(Color {
1049 r: 0.545,
1050 g: 0.0,
1051 b: 0.545,
1052 a: 1.0,
1053 }),
1054 ST_HighlightColor::DarkRed => Some(Color {
1055 r: 0.545,
1056 g: 0.0,
1057 b: 0.0,
1058 a: 1.0,
1059 }),
1060 ST_HighlightColor::DarkYellow => Some(Color {
1061 r: 0.545,
1062 g: 0.545,
1063 b: 0.0,
1064 a: 1.0,
1065 }),
1066 ST_HighlightColor::Green => Some(Color {
1067 r: 0.0,
1068 g: 1.0,
1069 b: 0.0,
1070 a: 1.0,
1071 }),
1072 ST_HighlightColor::LightGray => Some(Color {
1073 r: 0.827,
1074 g: 0.827,
1075 b: 0.827,
1076 a: 1.0,
1077 }),
1078 ST_HighlightColor::Magenta => Some(Color {
1079 r: 1.0,
1080 g: 0.0,
1081 b: 1.0,
1082 a: 1.0,
1083 }),
1084 ST_HighlightColor::Red => Some(Color {
1085 r: 1.0,
1086 g: 0.0,
1087 b: 0.0,
1088 a: 1.0,
1089 }),
1090 ST_HighlightColor::White => Some(Color {
1091 r: 1.0,
1092 g: 1.0,
1093 b: 1.0,
1094 a: 1.0,
1095 }),
1096 ST_HighlightColor::Yellow => Some(Color {
1097 r: 1.0,
1098 g: 1.0,
1099 b: 0.0,
1100 a: 1.0,
1101 }),
1102 }
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108 use crate::input::ImageData;
1109 use oxml_layout::MediaId;
1110 use std::collections::HashMap;
1111
1112 fn make_input_with_text(text: &str) -> LayoutInput {
1113 let mut doc = rdocx_oxml::document::CT_Document::new();
1114 let mut p = CT_P::new();
1115 p.add_run(text);
1116 doc.body.add_paragraph(p);
1117
1118 LayoutInput {
1119 document: doc,
1120 styles: CT_Styles::new_default(),
1121 numbering: None,
1122 headers: HashMap::new(),
1123 footers: HashMap::new(),
1124 images: HashMap::new(),
1125 core_properties: None,
1126 hyperlink_urls: HashMap::new(),
1127 footnotes: None,
1128 endnotes: None,
1129 theme: None,
1130 fonts: Vec::new(),
1131 }
1132 }
1133
1134 #[test]
1135 fn layout_simple_document() {
1136 let input = make_input_with_text("Hello World");
1137 let result = Engine::new().layout(&input);
1138 if let Ok(result) = result {
1140 assert!(!result.pages.is_empty());
1141 assert_eq!(result.pages[0].page_number, 1);
1142 assert!((result.pages[0].width - 612.0).abs() < 0.01);
1143 }
1144 }
1145
1146 #[test]
1147 fn layout_empty_document() {
1148 let mut doc = rdocx_oxml::document::CT_Document::new();
1149 doc.body.add_paragraph(CT_P::new());
1150
1151 let input = LayoutInput {
1152 document: doc,
1153 styles: CT_Styles::new_default(),
1154 numbering: None,
1155 headers: HashMap::new(),
1156 footers: HashMap::new(),
1157 images: HashMap::new(),
1158 core_properties: None,
1159 hyperlink_urls: HashMap::new(),
1160 footnotes: None,
1161 endnotes: None,
1162 theme: None,
1163 fonts: Vec::new(),
1164 };
1165
1166 let result = Engine::new().layout(&input);
1167 if let Ok(result) = result {
1168 assert_eq!(result.pages.len(), 1);
1169 }
1170 }
1171
1172 #[test]
1173 fn empty_shapeless_anchor_keeps_the_pre_cutover_omission() {
1174 let input = make_input_with_text("");
1175 let mut paragraph = CT_P::new();
1176 paragraph.add_run("").content = vec![RunContent::Drawing(
1177 rdocx_oxml::drawing::CT_Drawing::anchor(rdocx_oxml::drawing::CT_Anchor::background(
1178 "", 914_400, 914_400,
1179 )),
1180 )];
1181 let mut font_manager = FontManager::new();
1182 let mut numbering_state = NumberingState::new();
1183 let media = MediaRegistry::new(&input.images);
1184
1185 let anchored = collect_anchored_drawings(
1186 ¶graph,
1187 &input.styles,
1188 &input,
1189 &media,
1190 &mut font_manager,
1191 &mut numbering_state,
1192 )
1193 .expect("empty shapeless anchor collection should succeed");
1194
1195 assert!(anchored.is_empty());
1196 }
1197
1198 #[test]
1199 fn colliding_media_ids_keep_inline_and_anchored_image_bytes_distinct() {
1200 let mut input = make_input_with_text("");
1201 input.images.insert(
1202 "rIdInline".to_string(),
1203 ImageData {
1204 data: vec![1, 2, 3],
1205 content_type: "image/png".to_string(),
1206 },
1207 );
1208 input.images.insert(
1209 "rIdAnchor".to_string(),
1210 ImageData {
1211 data: vec![4, 5, 6],
1212 content_type: "image/jpeg".to_string(),
1213 },
1214 );
1215
1216 let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
1217 let inline_id = media.id_for_relationship("rIdInline");
1218 let anchor_id = media.id_for_relationship("rIdAnchor");
1219 assert_ne!(inline_id, anchor_id);
1220
1221 let line = oxml_layout::LayoutLine {
1222 items: vec![oxml_layout::LineItem::Image {
1223 width: 12.0,
1224 height: 10.0,
1225 media_id: inline_id,
1226 }],
1227 width: 12.0,
1228 ascent: 10.0,
1229 descent: 0.0,
1230 line_gap: 0.0,
1231 height: 10.0,
1232 indent_left: 0.0,
1233 available_width: 468.0,
1234 is_last: true,
1235 };
1236 let mut paragraph = block::build_paragraph_block(
1237 vec![line],
1238 0.0,
1239 0.0,
1240 None,
1241 None,
1242 0.0,
1243 0.0,
1244 None,
1245 false,
1246 false,
1247 false,
1248 true,
1249 );
1250 paragraph.anchored.push(block::AnchoredDrawing {
1251 behind_doc: false,
1252 rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
1253 off_h: 20.0,
1254 rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
1255 off_v: 20.0,
1256 width: 12.0,
1257 height: 10.0,
1258 wrap: rdocx_oxml::drawing::WrapType::None,
1259 dist_top: 0.0,
1260 dist_bottom: 0.0,
1261 dist_left: 0.0,
1262 dist_right: 0.0,
1263 align_h: None,
1264 align_v: None,
1265 content: block::AnchoredContent::Image {
1266 media_id: anchor_id,
1267 },
1268 });
1269 let sections = [paginator::Section {
1270 blocks: vec![LayoutBlock::Paragraph(paragraph)],
1271 geometry: PageGeometry::default(),
1272 header_footer: None,
1273 title_pg: false,
1274 }];
1275
1276 let (pages, _) = paginator::paginate_sections(
1277 §ions,
1278 &FontManager::new(),
1279 &media,
1280 &NoteRegistry::default(),
1281 );
1282 let images = pages[0]
1283 .elements
1284 .iter()
1285 .filter_map(|element| match element {
1286 PositionedElement::Image {
1287 data,
1288 content_type,
1289 media_id,
1290 ..
1291 } => Some((data.as_slice(), content_type.as_str(), *media_id)),
1292 _ => None,
1293 })
1294 .collect::<Vec<_>>();
1295
1296 assert!(images.contains(&(b"\x01\x02\x03".as_slice(), "image/png", inline_id)));
1297 assert!(images.contains(&(b"\x04\x05\x06".as_slice(), "image/jpeg", anchor_id)));
1298 }
1299
1300 #[test]
1301 fn layout_with_heading_style() {
1302 let mut doc = rdocx_oxml::document::CT_Document::new();
1303 let mut p = CT_P::new();
1304 p.properties = Some(CT_PPr {
1305 style_id: Some("Heading1".to_string()),
1306 ..Default::default()
1307 });
1308 p.add_run("Chapter 1");
1309 doc.body.add_paragraph(p);
1310
1311 let input = LayoutInput {
1312 document: doc,
1313 styles: CT_Styles::new_default(),
1314 numbering: None,
1315 headers: HashMap::new(),
1316 footers: HashMap::new(),
1317 images: HashMap::new(),
1318 core_properties: None,
1319 hyperlink_urls: HashMap::new(),
1320 footnotes: None,
1321 endnotes: None,
1322 theme: None,
1323 fonts: Vec::new(),
1324 };
1325
1326 let result = Engine::new().layout(&input);
1327 if let Ok(result) = result {
1328 assert!(!result.pages.is_empty());
1329 assert_eq!(result.outlines.len(), 1);
1331 assert_eq!(result.outlines[0].title, "Chapter 1");
1332 assert_eq!(result.outlines[0].level, 1);
1333 assert_eq!(result.outlines[0].page_index, 0);
1334 }
1335 }
1336
1337 #[test]
1338 fn layout_nested_headings_produce_outlines() {
1339 let mut doc = rdocx_oxml::document::CT_Document::new();
1340
1341 let mut h1 = CT_P::new();
1343 h1.properties = Some(CT_PPr {
1344 style_id: Some("Heading1".to_string()),
1345 ..Default::default()
1346 });
1347 h1.add_run("Chapter 1");
1348 doc.body.add_paragraph(h1);
1349
1350 let mut h2 = CT_P::new();
1352 h2.properties = Some(CT_PPr {
1353 style_id: Some("Heading2".to_string()),
1354 ..Default::default()
1355 });
1356 h2.add_run("Section 1.1");
1357 doc.body.add_paragraph(h2);
1358
1359 let mut h1b = CT_P::new();
1361 h1b.properties = Some(CT_PPr {
1362 style_id: Some("Heading1".to_string()),
1363 ..Default::default()
1364 });
1365 h1b.add_run("Chapter 2");
1366 doc.body.add_paragraph(h1b);
1367
1368 let input = LayoutInput {
1369 document: doc,
1370 styles: CT_Styles::new_default(),
1371 numbering: None,
1372 headers: HashMap::new(),
1373 footers: HashMap::new(),
1374 images: HashMap::new(),
1375 core_properties: None,
1376 hyperlink_urls: HashMap::new(),
1377 footnotes: None,
1378 endnotes: None,
1379 theme: None,
1380 fonts: Vec::new(),
1381 };
1382
1383 let result = Engine::new().layout(&input);
1384 if let Ok(result) = result {
1385 assert_eq!(result.outlines.len(), 3);
1386 assert_eq!(result.outlines[0].level, 1);
1387 assert_eq!(result.outlines[0].title, "Chapter 1");
1388 assert_eq!(result.outlines[1].level, 2);
1389 assert_eq!(result.outlines[1].title, "Section 1.1");
1390 assert_eq!(result.outlines[2].level, 1);
1391 assert_eq!(result.outlines[2].title, "Chapter 2");
1392 }
1393 }
1394
1395 #[test]
1396 fn sect_pr_geometry_conversion() {
1397 let sect = CT_SectPr::default_letter();
1398 let geom = sect_pr_to_geometry(§);
1399 assert!((geom.page_width - 612.0).abs() < 0.01);
1400 assert!((geom.page_height - 792.0).abs() < 0.01);
1401 assert!((geom.margin_top - 72.0).abs() < 0.01);
1402 assert!((geom.content_width() - 468.0).abs() < 0.01);
1403 }
1404
1405 #[test]
1406 fn sect_pr_a4_geometry() {
1407 let sect = CT_SectPr::default_a4();
1408 let geom = sect_pr_to_geometry(§);
1409 assert!((geom.page_width - 595.3).abs() < 0.5);
1411 assert!((geom.page_height - 841.9).abs() < 0.5);
1412 }
1413
1414 fn make_input_with_footnote(note_runs: &[&str]) -> LayoutInput {
1419 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
1420 use rdocx_oxml::text::CT_R;
1421
1422 let mut doc = rdocx_oxml::document::CT_Document::new();
1423 let mut body = CT_P::new();
1424 body.add_run("Body text carrying a note");
1425 let mut marker_run = CT_R::new("");
1426 marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
1427 body.runs.push(marker_run);
1428 doc.body.add_paragraph(body);
1429
1430 let mut note = CT_P::new();
1431 for text in note_runs {
1432 note.add_run(text);
1433 }
1434
1435 LayoutInput {
1436 document: doc,
1437 styles: CT_Styles::new_default(),
1438 numbering: None,
1439 headers: HashMap::new(),
1440 footers: HashMap::new(),
1441 images: HashMap::new(),
1442 core_properties: None,
1443 hyperlink_urls: HashMap::new(),
1444 footnotes: Some(CT_Footnotes {
1445 footnotes: vec![CT_Footnote {
1446 id: 1,
1447 note_type: NoteType::Normal,
1448 paragraphs: vec![note],
1449 }],
1450 }),
1451 endnotes: None,
1452 theme: None,
1453 fonts: Vec::new(),
1454 }
1455 }
1456
1457 fn footnote_glyph_x(page: &oxml_layout::output::PageFrame) -> Vec<f64> {
1460 let separator_y = page
1461 .elements
1462 .iter()
1463 .find_map(|element| match element {
1464 PositionedElement::Line { start, .. } => Some(start.y),
1465 _ => None,
1466 })
1467 .expect("a page with a footnote draws a separator line");
1468
1469 page.elements
1470 .iter()
1471 .filter_map(|element| match element {
1472 PositionedElement::Text(run) if run.origin.y > separator_y => Some(run.origin.x),
1473 _ => None,
1474 })
1475 .collect()
1476 }
1477
1478 #[test]
1479 fn a_multi_segment_footnote_does_not_stack_its_segments_at_one_x() {
1480 let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
1481 let mut engine = Engine::new();
1482 let output = engine.layout(&input).expect("layout succeeds");
1483 let xs = footnote_glyph_x(&output.pages[0]);
1484
1485 assert!(
1486 xs.len() >= 4,
1487 "expected a marker and three note segments, got {xs:?}"
1488 );
1489 for pair in xs.windows(2) {
1490 assert!(
1491 pair[1] > pair[0],
1492 "footnote segments must advance, got {xs:?}"
1493 );
1494 }
1495 }
1496
1497 #[test]
1498 fn a_single_segment_footnote_keeps_its_original_position() {
1499 let input = make_input_with_footnote(&["Solitary"]);
1500 let mut engine = Engine::new();
1501 let output = engine.layout(&input).expect("layout succeeds");
1502 let xs = footnote_glyph_x(&output.pages[0]);
1503 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
1504
1505 assert_eq!(xs.len(), 2, "expected a marker and one segment, got {xs:?}");
1507 assert!(
1508 (xs[0] - geometry.margin_left).abs() < 0.01,
1509 "marker at {xs:?}"
1510 );
1511 assert!(
1512 (xs[1] - (geometry.margin_left + 12.0)).abs() < 0.01,
1513 "segment at {xs:?}"
1514 );
1515 }
1516
1517 #[test]
1518 fn a_long_footnote_does_not_overrun_the_right_margin() {
1519 let long = "In paged media, footnotes are usually displayed at the \
1522 bottom of the text. However, in ebooks, a better paradigm \
1523 is to make them clickable endnotes that the reader can \
1524 browse at leisure, which this sentence exists to force.";
1525 let input = make_input_with_footnote(&[long]);
1526 let mut engine = Engine::new();
1527 let output = engine.layout(&input).expect("layout succeeds");
1528 let page = &output.pages[0];
1529 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
1530 let right_margin = geometry.page_width - geometry.margin_right;
1531
1532 let separator_y = page
1533 .elements
1534 .iter()
1535 .find_map(|element| match element {
1536 PositionedElement::Line { start, .. } => Some(start.y),
1537 _ => None,
1538 })
1539 .expect("a page with a footnote draws a separator line");
1540
1541 let mut wrapped = false;
1542 let mut first_y = None;
1543 for element in &page.elements {
1544 let PositionedElement::Text(run) = element else {
1545 continue;
1546 };
1547 if run.origin.y <= separator_y {
1548 continue;
1549 }
1550 let first = *first_y.get_or_insert(run.origin.y);
1551 if run.origin.y > first + 0.01 {
1552 wrapped = true;
1553 }
1554 let right_edge = run.origin.x + run.advances.iter().sum::<f64>();
1555 assert!(
1556 right_edge <= right_margin + 0.01,
1557 "note text reaches {right_edge}, past the right margin {right_margin}"
1558 );
1559 }
1560 assert!(wrapped, "the note must wrap for this test to mean anything");
1561 }
1562
1563 #[test]
1564 fn a_tab_inside_a_footnote_still_advances_the_text_after_it() {
1565 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
1566 use rdocx_oxml::text::CT_R;
1567
1568 let build = |with_tab: bool| {
1571 let mut doc = rdocx_oxml::document::CT_Document::new();
1572 let mut body = CT_P::new();
1573 body.add_run("Body");
1574 let mut marker_run = CT_R::new("");
1575 marker_run.content = vec![RunContent::FootnoteRef { id: 1 }];
1576 body.runs.push(marker_run);
1577 doc.body.add_paragraph(body);
1578
1579 let mut note = CT_P::new();
1580 note.add_run("Alpha");
1581 if with_tab {
1582 let mut tab_run = CT_R::new("");
1583 tab_run.content = vec![RunContent::Tab];
1584 note.runs.push(tab_run);
1585 }
1586 note.add_run("Beta");
1587
1588 LayoutInput {
1589 document: doc,
1590 styles: CT_Styles::new_default(),
1591 numbering: None,
1592 headers: HashMap::new(),
1593 footers: HashMap::new(),
1594 images: HashMap::new(),
1595 core_properties: None,
1596 hyperlink_urls: HashMap::new(),
1597 footnotes: Some(CT_Footnotes {
1598 footnotes: vec![CT_Footnote {
1599 id: 1,
1600 note_type: NoteType::Normal,
1601 paragraphs: vec![note],
1602 }],
1603 }),
1604 endnotes: None,
1605 theme: None,
1606 fonts: Vec::new(),
1607 }
1608 };
1609
1610 let mut engine = Engine::new();
1611 let plain = engine.layout(&build(false)).expect("layout succeeds");
1612 let tabbed = engine.layout(&build(true)).expect("layout succeeds");
1613
1614 let plain_x = footnote_glyph_x(&plain.pages[0]);
1615 let tabbed_x = footnote_glyph_x(&tabbed.pages[0]);
1616
1617 assert_eq!(plain_x.len(), 3, "plain note glyphs {plain_x:?}");
1619 assert_eq!(tabbed_x.len(), 3, "tabbed note glyphs {tabbed_x:?}");
1620 assert!(
1621 tabbed_x[2] > plain_x[2] + 1.0,
1622 "the run after a tab must shift right, plain {plain_x:?} tabbed {tabbed_x:?}"
1623 );
1624 }
1625
1626 #[test]
1627 fn footnote_segment_advance_matches_body_segment_advance() {
1628 let input = make_input_with_footnote(&["Alpha", "Beta", "Gamma"]);
1629 let mut engine = Engine::new();
1630 let output = engine.layout(&input).expect("layout succeeds");
1631 let page = &output.pages[0];
1632
1633 let separator_y = page
1634 .elements
1635 .iter()
1636 .find_map(|element| match element {
1637 PositionedElement::Line { start, .. } => Some(start.y),
1638 _ => None,
1639 })
1640 .expect("a page with a footnote draws a separator line");
1641
1642 let notes: Vec<&oxml_layout::GlyphRun> = page
1645 .elements
1646 .iter()
1647 .filter_map(|element| match element {
1648 PositionedElement::Text(run) if run.origin.y > separator_y => Some(run),
1649 _ => None,
1650 })
1651 .skip(1) .collect();
1653
1654 assert_eq!(notes.len(), 3, "expected three note segments");
1655 for pair in notes.windows(2) {
1656 let advance: f64 = pair[0].advances.iter().sum();
1657 let gap = pair[1].origin.x - pair[0].origin.x;
1658 assert!(
1659 (gap - advance).abs() < 0.01,
1660 "gap {gap} should equal preceding segment advance {advance}"
1661 );
1662 }
1663 }
1664
1665 fn make_noted_document(
1671 body_paras: usize,
1672 ref_positions: &[usize],
1673 note_paras: usize,
1674 note_text: &str,
1675 continuation_separator: bool,
1676 ) -> LayoutInput {
1677 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
1678 use rdocx_oxml::text::CT_R;
1679
1680 let mut doc = rdocx_oxml::document::CT_Document::new();
1681 for index in 0..body_paras {
1682 let mut para = CT_P::new();
1683 para.add_run("Body paragraph text that occupies a line of the page.");
1684 if ref_positions.contains(&index) {
1685 let mut marker = CT_R::new("");
1686 marker.content = vec![RunContent::FootnoteRef { id: 1 }];
1687 para.runs.push(marker);
1688 }
1689 doc.body.add_paragraph(para);
1690 }
1691
1692 let mut entries = Vec::new();
1693 if continuation_separator {
1694 entries.push(CT_Footnote {
1695 id: 0,
1696 note_type: NoteType::ContinuationSeparator,
1697 paragraphs: vec![CT_P::new()],
1698 });
1699 }
1700 entries.push(CT_Footnote {
1701 id: 1,
1702 note_type: NoteType::Normal,
1703 paragraphs: (0..note_paras)
1704 .map(|_| {
1705 let mut p = CT_P::new();
1706 p.add_run(note_text);
1707 p
1708 })
1709 .collect(),
1710 });
1711
1712 LayoutInput {
1713 document: doc,
1714 styles: CT_Styles::new_default(),
1715 numbering: None,
1716 headers: HashMap::new(),
1717 footers: HashMap::new(),
1718 images: HashMap::new(),
1719 core_properties: None,
1720 hyperlink_urls: HashMap::new(),
1721 footnotes: Some(CT_Footnotes { footnotes: entries }),
1722 endnotes: None,
1723 theme: None,
1724 fonts: Vec::new(),
1725 }
1726 }
1727
1728 fn split_at_separator(
1732 page: &oxml_layout::output::PageFrame,
1733 ) -> Option<(f64, Vec<f64>, Vec<String>)> {
1734 let separator_index = page.elements.iter().position(|element| {
1735 matches!(element, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001)
1736 })?;
1737 let PositionedElement::Line { start, end, .. } = &page.elements[separator_index] else {
1738 return None;
1739 };
1740 let separator_y = start.y;
1741 let separator_width = end.x - start.x;
1742
1743 let body_ys: Vec<f64> = page.elements[..separator_index]
1744 .iter()
1745 .filter_map(|element| match element {
1746 PositionedElement::Text(run) => Some(run.origin.y),
1747 _ => None,
1748 })
1749 .collect();
1750 let note_text: Vec<String> = page.elements[separator_index + 1..]
1751 .iter()
1752 .filter_map(|element| match element {
1753 PositionedElement::Text(run) => Some(run.text.clone()),
1754 _ => None,
1755 })
1756 .collect();
1757
1758 let _ = separator_y;
1759 Some((separator_width, body_ys, note_text))
1760 }
1761
1762 fn separator_y_of(page: &oxml_layout::output::PageFrame) -> Option<f64> {
1763 page.elements.iter().find_map(|element| match element {
1764 PositionedElement::Line { start, width, .. } if (*width - 0.5).abs() < 0.001 => {
1765 Some(start.y)
1766 }
1767 _ => None,
1768 })
1769 }
1770
1771 #[test]
1772 fn a_page_whose_body_fills_the_text_area_does_not_overlap_its_notes() {
1773 let input = make_noted_document(
1776 60,
1777 &[0],
1778 2,
1779 "A note long enough to wrap onto a second line of the note area.",
1780 false,
1781 );
1782 let mut engine = Engine::new();
1783 let output = engine.layout(&input).expect("layout succeeds");
1784 let page = &output.pages[0];
1785
1786 let separator_y = separator_y_of(page).expect("the page draws a separator");
1787 let (_, body_ys, note_text) = split_at_separator(page).unwrap();
1788
1789 assert!(!note_text.is_empty(), "the note must be drawn");
1790 let lowest_body = body_ys.iter().cloned().fold(f64::MIN, f64::max);
1791 assert!(
1792 lowest_body < separator_y,
1793 "body text reaches {lowest_body}, at or below the separator at {separator_y}"
1794 );
1795 }
1796
1797 #[test]
1798 fn a_page_referencing_one_note_twice_reserves_it_once() {
1799 let input = make_noted_document(4, &[0, 1], 1, "Referenced twice from one page.", false);
1800 let mut engine = Engine::new();
1801 let output = engine.layout(&input).expect("layout succeeds");
1802 let page = &output.pages[0];
1803
1804 let separators = page
1805 .elements
1806 .iter()
1807 .filter(|e| matches!(e, PositionedElement::Line { width, .. } if (*width - 0.5).abs() < 0.001))
1808 .count();
1809 assert_eq!(separators, 1, "one note area, so one separator");
1810
1811 let (_, _, note_text) = split_at_separator(page).unwrap();
1812 let markers = note_text.iter().filter(|t| t.as_str() == "1").count();
1813 assert_eq!(markers, 1, "the note is drawn once, got {note_text:?}");
1814 }
1815
1816 #[test]
1817 fn a_note_taller_than_its_remaining_space_continues_on_the_next_page() {
1818 let input = make_noted_document(30, &[25], 120, "Note paragraph line.", true);
1820 let mut engine = Engine::new();
1821 let output = engine.layout(&input).expect("layout succeeds");
1822
1823 let note_pages: Vec<usize> = output
1824 .pages
1825 .iter()
1826 .enumerate()
1827 .filter(|(_, page)| separator_y_of(page).is_some())
1828 .map(|(index, _)| index)
1829 .collect();
1830
1831 assert!(
1832 note_pages.len() >= 2,
1833 "a note taller than a page must span pages, got {note_pages:?}"
1834 );
1835
1836 let first = split_at_separator(&output.pages[note_pages[0]]).unwrap().2;
1837 let second = split_at_separator(&output.pages[note_pages[1]]).unwrap().2;
1838
1839 assert!(!first.is_empty(), "the first page draws part of the note");
1840 assert!(!second.is_empty(), "the next page draws the rest");
1841 assert_eq!(
1842 first.iter().filter(|t| t.as_str() == "1").count(),
1843 1,
1844 "the marker is drawn on the page the note starts on"
1845 );
1846 assert_eq!(
1847 second.iter().filter(|t| t.as_str() == "1").count(),
1848 0,
1849 "a continuation does not repeat the marker, got {second:?}"
1850 );
1851 }
1852
1853 #[test]
1854 fn a_continued_note_draws_the_continuation_separator() {
1855 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
1856
1857 let widths = |continuation: bool| {
1858 let input = make_noted_document(30, &[25], 120, "Note paragraph line.", continuation);
1859 let mut engine = Engine::new();
1860 let output = engine.layout(&input).expect("layout succeeds");
1861 let pages: Vec<usize> = output
1862 .pages
1863 .iter()
1864 .enumerate()
1865 .filter(|(_, page)| separator_y_of(page).is_some())
1866 .map(|(index, _)| index)
1867 .collect();
1868 assert!(pages.len() >= 2, "the note must span pages");
1869 (
1870 split_at_separator(&output.pages[pages[0]]).unwrap().0,
1871 split_at_separator(&output.pages[pages[1]]).unwrap().0,
1872 )
1873 };
1874
1875 let (first, second) = widths(true);
1876 assert!(
1877 (first - geometry.content_width() * 0.33).abs() < 0.5,
1878 "a note starting on its page gets the short rule, got {first}"
1879 );
1880 assert!(
1881 (second - geometry.content_width()).abs() < 0.5,
1882 "a continued note gets the full-width rule, got {second}"
1883 );
1884
1885 let (_, second) = widths(false);
1887 assert!(
1888 (second - geometry.content_width() * 0.33).abs() < 0.5,
1889 "without a continuation separator the short rule is kept, got {second}"
1890 );
1891 }
1892
1893 #[test]
1894 fn an_oversized_note_still_leaves_room_for_body_text() {
1895 let input = make_noted_document(3, &[0], 200, "A line of an enormous note.", true);
1897 let mut engine = Engine::new();
1898 let output = engine.layout(&input).expect("layout terminates");
1899
1900 let (_, body_ys, _) = split_at_separator(&output.pages[0]).unwrap();
1901 assert!(
1902 !body_ys.is_empty(),
1903 "an oversized note must not starve the page of body text"
1904 );
1905 assert!(
1906 output.pages.len() > 1 && output.pages.len() < 100,
1907 "the note spills over a bounded number of pages, got {}",
1908 output.pages.len()
1909 );
1910
1911 for (index, page) in output.pages.iter().enumerate() {
1914 let Some(separator_y) = separator_y_of(page) else {
1915 continue;
1916 };
1917 assert!(
1918 separator_y >= 0.0,
1919 "page {} draws its separator at {separator_y}, off the sheet",
1920 index + 1
1921 );
1922 }
1923 }
1924
1925 #[test]
1926 fn a_note_is_drawn_on_the_page_that_carries_its_reference() {
1927 let mut mismatches = Vec::new();
1932 for position in 0..60 {
1933 let input = make_noted_document(60, &[position], 1, "Note text.", false);
1934 let mut engine = Engine::new();
1935 let output = engine.layout(&input).expect("layout succeeds");
1936
1937 let reference_page = output.pages.iter().position(|page| {
1938 page.elements.iter().any(|element| {
1939 matches!(element, PositionedElement::Text(run)
1940 if run.note == Some(oxml_layout::NoteRef {
1941 stream: oxml_layout::NoteStream::Footnote,
1942 id: 1,
1943 }))
1944 })
1945 });
1946 let note_page = output
1947 .pages
1948 .iter()
1949 .position(|page| separator_y_of(page).is_some());
1950
1951 if reference_page != note_page {
1952 mismatches.push((position, reference_page, note_page));
1953 }
1954 }
1955
1956 assert!(
1957 mismatches.is_empty(),
1958 "note and reference landed on different pages for (position, ref, note): {mismatches:?}"
1959 );
1960 }
1961
1962 fn make_document_with_both_streams(id: i32, body_paras: usize) -> LayoutInput {
1967 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
1968 use rdocx_oxml::text::CT_R;
1969
1970 let mut doc = rdocx_oxml::document::CT_Document::new();
1971 for index in 0..body_paras {
1972 let mut para = CT_P::new();
1973 para.add_run("Body paragraph text that occupies a line of the page.");
1974 if index == 0 {
1975 let mut foot = CT_R::new("");
1976 foot.content = vec![RunContent::FootnoteRef { id }];
1977 para.runs.push(foot);
1978 let mut end = CT_R::new("");
1979 end.content = vec![RunContent::EndnoteRef { id }];
1980 para.runs.push(end);
1981 }
1982 doc.body.add_paragraph(para);
1983 }
1984
1985 let note = |text: &str| {
1986 let mut p = CT_P::new();
1987 p.add_run(text);
1988 CT_Footnote {
1989 id,
1990 note_type: NoteType::Normal,
1991 paragraphs: vec![p],
1992 }
1993 };
1994
1995 LayoutInput {
1996 document: doc,
1997 styles: CT_Styles::new_default(),
1998 numbering: None,
1999 headers: HashMap::new(),
2000 footers: HashMap::new(),
2001 images: HashMap::new(),
2002 core_properties: None,
2003 hyperlink_urls: HashMap::new(),
2004 footnotes: Some(CT_Footnotes {
2005 footnotes: vec![note("FOOTNOTETEXT")],
2006 }),
2007 endnotes: Some(CT_Footnotes {
2008 footnotes: vec![note("ENDNOTETEXT")],
2009 }),
2010 theme: None,
2011 fonts: Vec::new(),
2012 }
2013 }
2014
2015 fn page_text(page: &oxml_layout::output::PageFrame) -> String {
2016 page.elements
2017 .iter()
2018 .filter_map(|element| match element {
2019 PositionedElement::Text(run) => Some(run.text.as_str()),
2020 _ => None,
2021 })
2022 .collect::<Vec<_>>()
2023 .join(" ")
2024 }
2025
2026 #[test]
2027 fn a_footnote_and_an_endnote_sharing_a_number_render_their_own_text() {
2028 let input = make_document_with_both_streams(2, 3);
2029 let mut engine = Engine::new();
2030 let output = engine.layout(&input).expect("layout succeeds");
2031
2032 let all: String = output
2033 .pages
2034 .iter()
2035 .map(page_text)
2036 .collect::<Vec<_>>()
2037 .join(" | ");
2038 assert!(
2039 all.contains("FOOTNOTETEXT"),
2040 "the footnote must render its own text, got {all}"
2041 );
2042 assert!(
2043 all.contains("ENDNOTETEXT"),
2044 "the endnote must render its own text, got {all}"
2045 );
2046 }
2047
2048 #[test]
2049 fn endnotes_render_after_the_last_body_page() {
2050 let input = make_document_with_both_streams(2, 3);
2051 let mut engine = Engine::new();
2052 let output = engine.layout(&input).expect("layout succeeds");
2053
2054 let endnote_page = output
2055 .pages
2056 .iter()
2057 .position(|page| page_text(page).contains("ENDNOTETEXT"))
2058 .expect("the endnote is rendered somewhere");
2059 let last_body_page = output
2060 .pages
2061 .iter()
2062 .rposition(|page| page_text(page).contains("occupies"))
2063 .expect("the body is rendered somewhere");
2064
2065 assert!(
2066 endnote_page > last_body_page,
2067 "endnotes come after every body page, endnote on {endnote_page} and body to {last_body_page}"
2068 );
2069 assert!(
2070 !page_text(&output.pages[endnote_page]).contains("occupies"),
2071 "an endnote page carries no body text"
2072 );
2073 }
2074
2075 #[test]
2076 fn footnotes_and_endnotes_keep_their_own_regions() {
2077 let input = make_document_with_both_streams(2, 3);
2078 let mut engine = Engine::new();
2079 let output = engine.layout(&input).expect("layout succeeds");
2080
2081 let footnote_page = output
2082 .pages
2083 .iter()
2084 .position(|page| page_text(page).contains("FOOTNOTETEXT"))
2085 .expect("the footnote is rendered");
2086
2087 assert!(
2089 page_text(&output.pages[footnote_page]).contains("occupies"),
2090 "a footnote sits on the page carrying its reference"
2091 );
2092 assert!(
2093 separator_y_of(&output.pages[footnote_page]).is_some(),
2094 "the footnote page draws a separator"
2095 );
2096
2097 let endnote_page = output
2100 .pages
2101 .iter()
2102 .position(|page| page_text(page).contains("ENDNOTETEXT"))
2103 .expect("the endnote is rendered");
2104 assert_ne!(footnote_page, endnote_page, "the two regions are distinct");
2105 assert!(
2106 separator_y_of(&output.pages[endnote_page]).is_none(),
2107 "an endnote page draws no separator rule"
2108 );
2109 }
2110
2111 #[test]
2112 fn an_endnote_reference_does_not_reserve_space_at_the_page_foot() {
2113 use rdocx_oxml::footnotes::{CT_Footnote, CT_Footnotes, NoteType};
2114 use rdocx_oxml::text::CT_R;
2115
2116 let build = |with_endnote: bool| {
2120 let mut doc = rdocx_oxml::document::CT_Document::new();
2121 for index in 0..60 {
2122 let mut para = CT_P::new();
2123 para.add_run("Body paragraph text that occupies a line of the page.");
2124 if index == 0 && with_endnote {
2125 let mut end = CT_R::new("");
2126 end.content = vec![RunContent::EndnoteRef { id: 1 }];
2127 para.runs.push(end);
2128 }
2129 doc.body.add_paragraph(para);
2130 }
2131 let mut note = CT_P::new();
2132 note.add_run("An endnote that would be tall in the margin.");
2133 LayoutInput {
2134 document: doc,
2135 styles: CT_Styles::new_default(),
2136 numbering: None,
2137 headers: HashMap::new(),
2138 footers: HashMap::new(),
2139 images: HashMap::new(),
2140 core_properties: None,
2141 hyperlink_urls: HashMap::new(),
2142 footnotes: None,
2143 endnotes: Some(CT_Footnotes {
2144 footnotes: vec![CT_Footnote {
2145 id: 1,
2146 note_type: NoteType::Normal,
2147 paragraphs: vec![note],
2148 }],
2149 }),
2150 theme: None,
2151 fonts: Vec::new(),
2152 }
2153 };
2154
2155 let mut engine = Engine::new();
2156 let plain = engine.layout(&build(false)).expect("layout succeeds");
2157 let noted = engine.layout(&build(true)).expect("layout succeeds");
2158
2159 assert_eq!(
2161 noted.pages.len(),
2162 plain.pages.len() + 1,
2163 "an endnote adds its own page and takes none from the body"
2164 );
2165 for (index, page) in noted.pages.iter().enumerate() {
2166 if index < plain.pages.len() {
2167 assert!(
2168 separator_y_of(page).is_none(),
2169 "page {} reserved foot space for an endnote",
2170 index + 1
2171 );
2172 }
2173 }
2174
2175 for (index, plain_page) in plain.pages.iter().enumerate() {
2177 let body_lines = |page: &oxml_layout::output::PageFrame| {
2178 page.elements
2179 .iter()
2180 .filter(|element| {
2181 matches!(element, PositionedElement::Text(run)
2182 if run.text.starts_with("occupies"))
2183 })
2184 .count()
2185 };
2186 assert_eq!(
2187 body_lines(plain_page),
2188 body_lines(¬ed.pages[index]),
2189 "page {} holds a different amount of body text",
2190 index + 1
2191 );
2192 }
2193 }
2194
2195 fn make_wrapping_document(
2200 wrap: rdocx_oxml::drawing::WrapType,
2201 align: Option<rdocx_oxml::drawing::AnchorAlignH>,
2202 width_pt: f64,
2203 height_pt: f64,
2204 dist_pt: f64,
2205 ) -> LayoutInput {
2206 use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV};
2207 use rdocx_oxml::text::CT_R;
2208 use rdocx_oxml::units::Emu;
2209
2210 let emu = |pt: f64| Emu((pt * 12700.0) as i64);
2211
2212 let mut doc = rdocx_oxml::document::CT_Document::new();
2213 let mut para = CT_P::new();
2214 let mut body = String::new();
2217 for index in 0..40 {
2218 body.push_str(&format!(
2219 "Sentence {index} of running text that fills the paragraph out. "
2220 ));
2221 }
2222 para.add_run(&body);
2223
2224 let mut anchor = CT_Anchor::background("rId1", 0, 0);
2225 anchor.extent_cx = emu(width_pt);
2226 anchor.extent_cy = emu(height_pt);
2227 anchor.behind_doc = false;
2228 anchor.wrap = wrap;
2229 anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
2230 anchor.pos_h_align = align;
2231 anchor.pos_v_relative_from = ST_RelativeFromV::Paragraph;
2232 anchor.pos_v_offset = Emu(0);
2233 anchor.dist_t = emu(dist_pt);
2234 anchor.dist_b = emu(dist_pt);
2235 anchor.dist_l = emu(dist_pt);
2236 anchor.dist_r = emu(dist_pt);
2237
2238 let mut drawing_run = CT_R::new("");
2239 drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
2240 inline: None,
2241 anchor: Some(anchor),
2242 })];
2243 para.runs.push(drawing_run);
2244 doc.body.add_paragraph(para);
2245
2246 let mut images = HashMap::new();
2247 images.insert(
2248 "rId1".to_string(),
2249 ImageData {
2250 data: vec![0u8; 8],
2251 content_type: "image/png".to_string(),
2252 },
2253 );
2254
2255 LayoutInput {
2256 document: doc,
2257 styles: CT_Styles::new_default(),
2258 numbering: None,
2259 headers: HashMap::new(),
2260 footers: HashMap::new(),
2261 images,
2262 core_properties: None,
2263 hyperlink_urls: HashMap::new(),
2264 footnotes: None,
2265 endnotes: None,
2266 theme: None,
2267 fonts: Vec::new(),
2268 }
2269 }
2270
2271 fn text_extents(page: &oxml_layout::output::PageFrame) -> Vec<(f64, f64)> {
2273 let mut by_line: Vec<(f64, f64, f64)> = Vec::new();
2274 for element in &page.elements {
2275 let PositionedElement::Text(run) = element else {
2276 continue;
2277 };
2278 let right = run.origin.x + run.advances.iter().sum::<f64>();
2279 if let Some(entry) = by_line
2280 .iter_mut()
2281 .find(|(y, _, _)| (*y - run.origin.y).abs() < 0.01)
2282 {
2283 entry.1 = entry.1.min(run.origin.x);
2284 entry.2 = entry.2.max(right);
2285 } else {
2286 by_line.push((run.origin.y, run.origin.x, right));
2287 }
2288 }
2289 by_line.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
2290 by_line.into_iter().map(|(_, l, r)| (l, r)).collect()
2291 }
2292
2293 #[test]
2294 fn text_wraps_beside_a_left_aligned_square_drawing() {
2295 use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
2296
2297 let input =
2298 make_wrapping_document(WrapType::Square, Some(AnchorAlignH::Left), 100.0, 40.0, 5.0);
2299 let mut engine = Engine::new();
2300 let output = engine.layout(&input).expect("layout succeeds");
2301 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2302 let extents = text_extents(&output.pages[0]);
2303
2304 assert!(
2305 extents.len() > 2,
2306 "the paragraph must wrap, got {extents:?}"
2307 );
2308
2309 let expected_left = geometry.margin_left + 100.0 + 5.0;
2311 assert!(
2312 (extents[0].0 - expected_left).abs() < 1.0,
2313 "first line should start at {expected_left}, got {:?}",
2314 extents[0]
2315 );
2316
2317 let last = extents.last().unwrap();
2319 assert!(
2320 (last.0 - geometry.margin_left).abs() < 1.0,
2321 "the last line should return to the margin, got {last:?}"
2322 );
2323 }
2324
2325 #[test]
2326 fn text_wraps_beside_a_right_aligned_square_drawing() {
2327 use rdocx_oxml::drawing::{AnchorAlignH, WrapType};
2328
2329 let input = make_wrapping_document(
2330 WrapType::Square,
2331 Some(AnchorAlignH::Right),
2332 100.0,
2333 40.0,
2334 5.0,
2335 );
2336 let mut engine = Engine::new();
2337 let output = engine.layout(&input).expect("layout succeeds");
2338 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2339 let extents = text_extents(&output.pages[0]);
2340
2341 assert!(
2342 extents.len() > 2,
2343 "the paragraph must wrap, got {extents:?}"
2344 );
2345
2346 let text_right = geometry.page_width - geometry.margin_right;
2348 let drawing_left = text_right - 100.0;
2349 assert!(
2350 (extents[0].0 - geometry.margin_left).abs() < 1.0,
2351 "a right-aligned drawing does not move the line start, got {:?}",
2352 extents[0]
2353 );
2354 assert!(
2355 extents[0].1 <= drawing_left - 5.0 + 1.0,
2356 "the first line should stop before the drawing at {}, got {:?}",
2357 drawing_left - 5.0,
2358 extents[0]
2359 );
2360
2361 let widest = extents
2365 .iter()
2366 .map(|(_, right)| *right)
2367 .fold(f64::MIN, f64::max);
2368 assert!(
2369 widest > drawing_left,
2370 "a line below the drawing should reach past {drawing_left}, got {extents:?}"
2371 );
2372 }
2373
2374 #[test]
2375 fn a_top_and_bottom_drawing_pushes_text_below_it() {
2376 use rdocx_oxml::drawing::WrapType;
2377
2378 let input = make_wrapping_document(WrapType::TopAndBottom, None, 100.0, 40.0, 5.0);
2379 let mut engine = Engine::new();
2380 let output = engine.layout(&input).expect("layout succeeds");
2381 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2382 let extents = text_extents(&output.pages[0]);
2383
2384 assert!(!extents.is_empty(), "the paragraph renders");
2385
2386 let first_baseline = output.pages[0]
2389 .elements
2390 .iter()
2391 .find_map(|element| match element {
2392 PositionedElement::Text(run) => Some(run.origin.y),
2393 _ => None,
2394 })
2395 .expect("text is rendered");
2396 let drawing_bottom = geometry.margin_top + 40.0 + 5.0;
2397 assert!(
2398 first_baseline >= drawing_bottom,
2399 "the first line at {first_baseline} should sit below {drawing_bottom}"
2400 );
2401 }
2402
2403 #[test]
2404 fn a_wrap_none_drawing_leaves_text_untouched() {
2405 use rdocx_oxml::drawing::WrapType;
2406
2407 let with = make_wrapping_document(WrapType::None, None, 100.0, 40.0, 5.0);
2410 let mut engine = Engine::new();
2411 let output = engine.layout(&with).expect("layout succeeds");
2412 let wrapped_extents = text_extents(&output.pages[0]);
2413
2414 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2415 for (left, _) in &wrapped_extents {
2416 assert!(
2417 (left - geometry.margin_left).abs() < 0.01,
2418 "a wrapNone drawing must not indent any line, got {wrapped_extents:?}"
2419 );
2420 }
2421 }
2422
2423 #[test]
2424 fn a_drawing_anchored_to_a_later_paragraph_still_pushes_text_aside() {
2425 use rdocx_oxml::drawing::{
2426 AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
2427 WrapType,
2428 };
2429 use rdocx_oxml::text::CT_R;
2430 use rdocx_oxml::units::Emu;
2431
2432 let emu = |pt: f64| Emu((pt * 12700.0) as i64);
2435 let mut doc = rdocx_oxml::document::CT_Document::new();
2436
2437 let mut first = CT_P::new();
2438 let mut body = String::new();
2439 for index in 0..40 {
2440 body.push_str(&format!("Sentence {index} of running text to fill lines. "));
2441 }
2442 first.add_run(&body);
2443 doc.body.add_paragraph(first);
2444
2445 let mut second = CT_P::new();
2446 second.add_run("A later paragraph that owns the drawing.");
2447 let mut anchor = CT_Anchor::background("rId1", 0, 0);
2448 anchor.extent_cx = emu(100.0);
2449 anchor.extent_cy = emu(40.0);
2450 anchor.behind_doc = false;
2451 anchor.wrap = WrapType::Square;
2452 anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
2453 anchor.pos_h_align = Some(AnchorAlignH::Left);
2454 anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
2457 anchor.pos_v_align = Some(AnchorAlignV::Top);
2458 let mut drawing_run = CT_R::new("");
2459 drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
2460 inline: None,
2461 anchor: Some(anchor),
2462 })];
2463 second.runs.push(drawing_run);
2464 doc.body.add_paragraph(second);
2465
2466 let mut images = HashMap::new();
2467 images.insert(
2468 "rId1".to_string(),
2469 ImageData {
2470 data: vec![0u8; 8],
2471 content_type: "image/png".to_string(),
2472 },
2473 );
2474
2475 let input = LayoutInput {
2476 document: doc,
2477 styles: CT_Styles::new_default(),
2478 numbering: None,
2479 headers: HashMap::new(),
2480 footers: HashMap::new(),
2481 images,
2482 core_properties: None,
2483 hyperlink_urls: HashMap::new(),
2484 footnotes: None,
2485 endnotes: None,
2486 theme: None,
2487 fonts: Vec::new(),
2488 };
2489
2490 let mut engine = Engine::new();
2491 let output = engine.layout(&input).expect("layout succeeds");
2492 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2493 let extents = text_extents(&output.pages[0]);
2494
2495 assert!(!extents.is_empty(), "text renders");
2496 let expected_left = geometry.margin_left + 100.0;
2497 assert!(
2498 extents[0].0 >= expected_left - 1.0,
2499 "the first line of the earlier paragraph should clear the drawing at \
2500 {expected_left}, got {:?}",
2501 extents[0]
2502 );
2503 }
2504
2505 #[test]
2506 fn a_split_paragraph_clearing_a_drawing_stays_inside_the_page() {
2507 use rdocx_oxml::drawing::{
2508 AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, ST_RelativeFromH, ST_RelativeFromV,
2509 WrapType,
2510 };
2511 use rdocx_oxml::text::CT_R;
2512 use rdocx_oxml::units::Emu;
2513
2514 let emu = |pt: f64| Emu((pt * 12700.0) as i64);
2518 let mut doc = rdocx_oxml::document::CT_Document::new();
2519 let mut para = CT_P::new();
2520 let mut body = String::new();
2521 for index in 0..300 {
2522 body.push_str(&format!("Sentence {index} of a very long paragraph. "));
2523 }
2524 para.add_run(&body);
2525
2526 let mut anchor = CT_Anchor::background("rId1", 0, 0);
2527 anchor.extent_cx = emu(200.0);
2528 anchor.extent_cy = emu(120.0);
2529 anchor.behind_doc = false;
2530 anchor.wrap = WrapType::TopAndBottom;
2531 anchor.pos_h_relative_from = ST_RelativeFromH::Margin;
2532 anchor.pos_h_align = Some(AnchorAlignH::Center);
2533 anchor.pos_v_relative_from = ST_RelativeFromV::Margin;
2534 anchor.pos_v_align = Some(AnchorAlignV::Top);
2535 anchor.dist_b = emu(10.0);
2536 let mut drawing_run = CT_R::new("");
2537 drawing_run.content = vec![RunContent::Drawing(CT_Drawing {
2538 inline: None,
2539 anchor: Some(anchor),
2540 })];
2541 para.runs.push(drawing_run);
2542 doc.body.add_paragraph(para);
2543
2544 let mut images = HashMap::new();
2545 images.insert(
2546 "rId1".to_string(),
2547 ImageData {
2548 data: vec![0u8; 8],
2549 content_type: "image/png".to_string(),
2550 },
2551 );
2552
2553 let input = LayoutInput {
2554 document: doc,
2555 styles: CT_Styles::new_default(),
2556 numbering: None,
2557 headers: HashMap::new(),
2558 footers: HashMap::new(),
2559 images,
2560 core_properties: None,
2561 hyperlink_urls: HashMap::new(),
2562 footnotes: None,
2563 endnotes: None,
2564 theme: None,
2565 fonts: Vec::new(),
2566 };
2567
2568 let mut engine = Engine::new();
2569 let output = engine.layout(&input).expect("layout succeeds");
2570 let geometry = sect_pr_to_geometry(&CT_SectPr::default_letter());
2571 let bottom = geometry.page_height - geometry.margin_bottom;
2572
2573 assert!(output.pages.len() > 1, "the paragraph must split");
2574 for (index, page) in output.pages.iter().enumerate() {
2575 for element in &page.elements {
2576 let PositionedElement::Text(run) = element else {
2577 continue;
2578 };
2579 assert!(
2580 run.origin.y <= bottom + 0.5,
2581 "page {} draws text at {}, past the bottom margin at {bottom}",
2582 index + 1,
2583 run.origin.y
2584 );
2585 }
2586 }
2587 }
2588}