1use rdocx_oxml::document::{BodyContent, CT_SectPr};
4use rdocx_oxml::header_footer::HdrFtrType;
5use rdocx_oxml::properties::CT_PPr;
6use rdocx_oxml::shared::ST_HighlightColor;
7use rdocx_oxml::styles::CT_Styles;
8use rdocx_oxml::text::{BreakType, CT_P, FieldType, RunContent};
9
10use crate::block::{self, LayoutBlock, ParagraphBlock};
11use crate::convert;
12use crate::input::{LayoutInput, MediaRegistry};
13use crate::paginator::{self, HeaderFooterContent, PageGeometry};
14use crate::style_resolver::{self, NumberingState};
15use crate::table;
16use oxml_layout::{
17 Color, DocumentMetadata, FieldKind, FontManager, GlyphRun, InlineItem, LayoutResult, LineItem,
18 PageFrame, Point, PositionedElement, Rect, Result, TextSegment, break_into_lines,
19};
20
21pub struct Engine {
23 font_manager: FontManager,
24}
25
26impl Default for Engine {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32impl Engine {
33 pub fn new() -> Self {
34 Engine {
35 font_manager: FontManager::new(),
36 }
37 }
38
39 pub fn new_deterministic() -> Result<Self> {
41 Ok(Engine {
42 font_manager: FontManager::new_deterministic()?,
43 })
44 }
45
46 pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
48 if !input.fonts.is_empty() {
50 self.font_manager.load_additional_fonts(&input.fonts);
51 }
52
53 let styles = &input.styles;
54 let mut num_state = NumberingState::new();
55 let media = MediaRegistry::new(&input.images);
56
57 let final_sect_pr = input
59 .document
60 .body
61 .sect_pr
62 .as_ref()
63 .cloned()
64 .unwrap_or_else(CT_SectPr::default_letter);
65
66 let mut sections: Vec<paginator::Section> = Vec::new();
68 let mut current_blocks: Vec<LayoutBlock> = Vec::new();
69 let mut current_sect_pr: Option<CT_SectPr> = None; for content in &input.document.body.content {
72 match content {
73 BodyContent::Paragraph(para) => {
74 let para_sect_pr = para.properties.as_ref().and_then(|p| p.sect_pr.clone());
76
77 let sect_pr_for_layout = para_sect_pr
78 .as_ref()
79 .or(current_sect_pr.as_ref())
80 .unwrap_or(&final_sect_pr);
81 let geometry = sect_pr_to_geometry(sect_pr_for_layout);
82
83 let mut para_block = layout_paragraph(
84 para,
85 geometry.content_width(),
86 styles,
87 input,
88 &media,
89 &mut self.font_manager,
90 &mut num_state,
91 )?;
92
93 if let Some(level) = detect_heading_level(para, styles) {
95 para_block.heading_level = Some(level);
96 para_block.heading_text = Some(para.text());
97 }
98
99 current_blocks.push(LayoutBlock::Paragraph(para_block));
100
101 if let Some(sect_pr) = para_sect_pr {
103 let geometry = sect_pr_to_geometry(§_pr);
104 let header_footer = layout_header_footer(
105 §_pr,
106 input,
107 styles,
108 &media,
109 &mut self.font_manager,
110 &mut num_state,
111 )?;
112 let title_pg = sect_pr.title_pg.unwrap_or(false);
113 sections.push(paginator::Section {
114 blocks: std::mem::take(&mut current_blocks),
115 geometry,
116 header_footer,
117 title_pg,
118 });
119 current_sect_pr = Some(sect_pr);
120 }
121 }
122 BodyContent::Table(tbl) => {
123 let sect_pr_for_layout = current_sect_pr.as_ref().unwrap_or(&final_sect_pr);
124 let geometry = sect_pr_to_geometry(sect_pr_for_layout);
125
126 let table_block = table::layout_table(
127 tbl,
128 geometry.content_width(),
129 styles,
130 input,
131 &media,
132 &mut self.font_manager,
133 &mut num_state,
134 )?;
135 current_blocks.push(LayoutBlock::Table(table_block));
136 }
137 _ => {} }
139 }
140
141 let final_geometry = sect_pr_to_geometry(&final_sect_pr);
143 let final_hf = layout_header_footer(
144 &final_sect_pr,
145 input,
146 styles,
147 &media,
148 &mut self.font_manager,
149 &mut num_state,
150 )?;
151 let final_title_pg = final_sect_pr.title_pg.unwrap_or(false);
152 sections.push(paginator::Section {
153 blocks: current_blocks,
154 geometry: final_geometry,
155 header_footer: final_hf,
156 title_pg: final_title_pg,
157 });
158
159 let (mut pages, outlines) =
161 paginator::paginate_sections(§ions, &self.font_manager, &media);
162
163 let total_pages = pages.len();
165 for page in &mut pages {
166 let page_num = page.page_number;
167 substitute_fields(
168 &mut page.elements,
169 page_num,
170 total_pages,
171 &mut self.font_manager,
172 );
173 }
174
175 apply_page_background(&mut pages, input);
177
178 if input.footnotes.is_some() || input.endnotes.is_some() {
180 render_page_footnotes(
181 &mut pages,
182 input,
183 styles,
184 &final_geometry,
185 &media,
186 &mut self.font_manager,
187 &mut num_state,
188 )?;
189 }
190
191 let fonts = self.font_manager.all_font_data();
193
194 let metadata = input.core_properties.as_ref().map(|cp| DocumentMetadata {
196 title: cp.title.clone(),
197 author: cp.creator.clone(),
198 subject: cp.subject.clone(),
199 keywords: cp.keywords.clone(),
200 creator: Some("rdocx".to_string()),
201 });
202
203 Ok(LayoutResult::new(pages, fonts, metadata, outlines))
204 }
205}
206
207fn apply_page_background(pages: &mut [PageFrame], input: &LayoutInput) {
209 let bg_xml = match &input.document.background_xml {
210 Some(xml) => xml,
211 None => return,
212 };
213
214 let xml_str = std::str::from_utf8(bg_xml).unwrap_or("");
216 let color = extract_background_color(xml_str);
217 let color = match color {
218 Some(c) => c,
219 None => return,
220 };
221
222 for page in pages.iter_mut() {
224 page.elements.insert(
225 0,
226 PositionedElement::FilledRect {
227 rect: Rect {
228 x: 0.0,
229 y: 0.0,
230 width: page.width,
231 height: page.height,
232 },
233 color,
234 },
235 );
236 }
237}
238
239fn extract_background_color(xml: &str) -> Option<Color> {
241 for attr in ["w:color=\"", "color=\""] {
243 if let Some(start) = xml.find(attr) {
244 let val_start = start + attr.len();
245 if let Some(end) = xml[val_start..].find('"') {
246 let hex = &xml[val_start..val_start + end];
247 if hex.len() == 6 && hex != "auto" {
248 return Some(Color::from_hex(hex));
249 }
250 }
251 }
252 }
253 None
254}
255
256fn substitute_fields(
258 elements: &mut [PositionedElement],
259 page_number: usize,
260 total_pages: usize,
261 fm: &mut FontManager,
262) {
263 for element in elements.iter_mut() {
264 if let PositionedElement::Text(run) = element
265 && let Some(fk) = run.field_kind
266 {
267 let value = match fk {
268 FieldKind::Page => page_number.to_string(),
269 FieldKind::NumPages => total_pages.to_string(),
270 };
271 if let Ok(shaped) = fm.shape_text(run.font_id, &value, run.font_size) {
273 run.text = value;
274 run.glyph_ids = shaped.glyph_ids;
275 run.advances = shaped.advances;
276 }
277 }
278 }
279}
280
281fn render_page_footnotes(
286 pages: &mut [PageFrame],
287 input: &LayoutInput,
288 styles: &CT_Styles,
289 geometry: &paginator::PageGeometry,
290 media: &MediaRegistry,
291 fm: &mut FontManager,
292 num_state: &mut NumberingState,
293) -> Result<()> {
294 let footnote_font_size = 8.0; let separator_offset = 6.0; let separator_width_frac = 0.33; for page in pages.iter_mut() {
299 let mut footnote_ids: Vec<i32> = Vec::new();
301 for element in &page.elements {
302 if let PositionedElement::Text(run) = element
303 && let Some(fn_id) = run.footnote_id
304 && !footnote_ids.contains(&fn_id)
305 {
306 footnote_ids.push(fn_id);
307 }
308 }
309
310 if footnote_ids.is_empty() {
311 continue;
312 }
313
314 let mut footnote_blocks: Vec<(i32, Vec<block::ParagraphBlock>)> = Vec::new();
316 for &fn_id in &footnote_ids {
317 let paragraphs = input
319 .footnotes
320 .as_ref()
321 .and_then(|fns| fns.get_by_id(fn_id))
322 .or_else(|| input.endnotes.as_ref().and_then(|ens| ens.get_by_id(fn_id)));
323
324 if let Some(footnote) = paragraphs {
325 let mut fn_blocks = Vec::new();
326 for para in &footnote.paragraphs {
327 if let Ok(pb) = layout_paragraph(
328 para,
329 geometry.content_width(),
330 styles,
331 input,
332 media,
333 fm,
334 num_state,
335 ) {
336 fn_blocks.push(pb);
337 }
338 }
339 footnote_blocks.push((fn_id, fn_blocks));
340 }
341 }
342
343 if footnote_blocks.is_empty() {
344 continue;
345 }
346
347 let total_fn_height: f64 = footnote_blocks
349 .iter()
350 .flat_map(|(_, blocks)| blocks.iter())
351 .map(|b| b.content_height())
352 .sum();
353
354 let footnote_area_top =
356 page.height - geometry.margin_bottom - total_fn_height - separator_offset;
357
358 let sep_y = footnote_area_top;
360 let sep_width = geometry.content_width() * separator_width_frac;
361 page.elements.push(PositionedElement::Line {
362 start: Point {
363 x: geometry.margin_left,
364 y: sep_y,
365 },
366 end: Point {
367 x: geometry.margin_left + sep_width,
368 y: sep_y,
369 },
370 width: 0.5,
371 color: Color::BLACK,
372 dash_pattern: None,
373 });
374
375 let mut cursor_y = sep_y + separator_offset;
377 for (fn_id, blocks) in &footnote_blocks {
378 for pb in blocks {
379 let baseline_y = cursor_y + pb.lines.first().map(|l| l.ascent).unwrap_or(0.0);
380
381 let marker_text = fn_id.to_string();
383 let marker_size = footnote_font_size * 0.58;
384 if let Ok(font_id) = fm.resolve_font(Some("serif"), false, false)
385 && let Ok(shaped) = fm.shape_text(font_id, &marker_text, marker_size)
386 {
387 page.elements.push(PositionedElement::Text(GlyphRun {
388 origin: Point {
389 x: geometry.margin_left,
390 y: baseline_y - footnote_font_size * 0.33,
391 },
392 font_id,
393 font_size: marker_size,
394 glyph_ids: shaped.glyph_ids,
395 advances: shaped.advances,
396 text: marker_text,
397 color: Color::BLACK,
398 bold: false,
399 italic: false,
400 field_kind: None,
401 footnote_id: None,
402 }));
403 }
404
405 let indent = 12.0; for line in &pb.lines {
408 let line_baseline = cursor_y + line.ascent;
409 for item in &line.items {
410 if let LineItem::Text(seg) | LineItem::Marker(seg) = item {
411 page.elements.push(PositionedElement::Text(GlyphRun {
412 origin: Point {
413 x: geometry.margin_left + indent,
414 y: line_baseline - seg.baseline_offset,
415 },
416 font_id: seg.font_id,
417 font_size: seg.font_size,
418 glyph_ids: seg.glyph_ids.clone(),
419 advances: seg.advances.clone(),
420 text: seg.text.clone(),
421 color: seg.color,
422 bold: seg.bold,
423 italic: seg.italic,
424 field_kind: None,
425 footnote_id: None,
426 }));
427 }
428 }
429 cursor_y += line.height;
430 }
431 }
432 }
433 }
434
435 Ok(())
436}
437
438fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
440 let style_id = para.properties.as_ref()?.style_id.as_deref()?;
441 if let Some(rest) = style_id.strip_prefix("Heading") {
443 return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
444 }
445 if let Some(style_def) = styles.get_by_id(style_id)
447 && let Some(ref name) = style_def.name
448 && let Some(rest) = name.strip_prefix("heading ")
449 {
450 return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
451 }
452 None
453}
454
455pub fn layout_paragraph(
457 para: &CT_P,
458 available_width: f64,
459 styles: &CT_Styles,
460 input: &LayoutInput,
461 media: &MediaRegistry,
462 fm: &mut FontManager,
463 num_state: &mut NumberingState,
464) -> Result<ParagraphBlock> {
465 let para_style_id = para.properties.as_ref().and_then(|p| p.style_id.as_deref());
467
468 let resolved_ppr = style_resolver::resolve_paragraph_properties(para_style_id, styles);
469
470 let mut effective_ppr = resolved_ppr;
471
472 let direct_ppr = para.properties.as_ref();
477 let list_num_id = direct_ppr.and_then(|p| p.num_id).or(effective_ppr.num_id);
478 let list_ilvl = direct_ppr
479 .and_then(|p| p.num_ilvl)
480 .or(effective_ppr.num_ilvl)
481 .unwrap_or(0);
482 if let (Some(num_id), Some(numbering)) = (list_num_id, input.numbering.as_ref())
483 && let Some(lvl_ppr) =
484 style_resolver::level_paragraph_properties(num_id, list_ilvl, numbering)
485 {
486 merge_direct_ppr(&mut effective_ppr, lvl_ppr);
487 }
488
489 if let Some(direct_ppr) = direct_ppr {
491 merge_direct_ppr(&mut effective_ppr, direct_ppr);
492 }
493
494 let space_before = effective_ppr.space_before.map(|t| t.to_pt()).unwrap_or(0.0);
496 let space_after = effective_ppr.space_after.map(|t| t.to_pt()).unwrap_or(0.0);
497 let ind_left = effective_ppr.ind_left.map(|t| t.to_pt()).unwrap_or(0.0);
498 let ind_right = effective_ppr.ind_right.map(|t| t.to_pt()).unwrap_or(0.0);
499 let keep_next = effective_ppr.keep_next.unwrap_or(false);
500 let keep_lines = effective_ppr.keep_lines.unwrap_or(false);
501 let page_break_before = effective_ppr.page_break_before.unwrap_or(false);
502 let widow_control = effective_ppr.widow_control.unwrap_or(true);
503 let jc = convert::alignment(effective_ppr.jc);
504
505 let shading = effective_ppr
507 .shading
508 .as_ref()
509 .and_then(|shd| shd.fill.as_ref())
510 .filter(|f| f != &"auto")
511 .map(|f| Color::from_hex(f));
512
513 let mut inline_items = Vec::new();
515
516 if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) {
518 let ilvl = effective_ppr.num_ilvl.unwrap_or(0);
519 if let Some(marker) = style_resolver::generate_marker(num_id, ilvl, numbering, num_state) {
520 let marker_rpr = marker.marker_rpr;
522 let marker_font_size = marker_rpr.sz.map(|hp| hp.to_pt()).unwrap_or_else(|| {
523 style_resolver::resolve_run_properties(para_style_id, None, styles)
524 .sz
525 .map(|hp| hp.to_pt())
526 .unwrap_or(11.0)
527 });
528 let marker_bold = marker_rpr.bold.unwrap_or(false);
529 let marker_italic = marker_rpr.italic.unwrap_or(false);
530 let marker_font_family = marker_rpr.font_ascii.as_deref();
531
532 if let Ok(font_id) = fm.resolve_font_for_text(
535 marker_font_family,
536 marker_bold,
537 marker_italic,
538 &marker.marker_text,
539 ) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size)
540 {
541 let metrics = fm.metrics(font_id, marker_font_size)?;
542 let color = marker_rpr
543 .color
544 .as_ref()
545 .map(|c| Color::from_hex(c))
546 .unwrap_or(Color::BLACK);
547
548 inline_items.push(InlineItem::Marker(TextSegment {
549 text: marker.marker_text,
550 font_id,
551 font_size: marker_font_size,
552 glyph_ids: shaped.glyph_ids,
553 advances: shaped.advances,
554 width: shaped.width,
555 ascent: metrics.ascent,
556 descent: metrics.descent,
557 line_gap: 0.0,
558 color,
559 bold: marker_bold,
560 italic: marker_italic,
561 underline: None,
562 strike: false,
563 dstrike: false,
564 highlight: None,
565 baseline_offset: 0.0,
566 hyperlink_url: None,
567 field_kind: None,
568 footnote_id: None,
569 }));
570
571 inline_items.push(InlineItem::Tab);
573 }
574 }
575 }
576
577 let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
579 std::collections::HashMap::new();
580 for hl in ¶.hyperlinks {
581 if let Some(ref rel_id) = hl.rel_id
582 && let Some(url) = input.hyperlink_urls.get(rel_id)
583 {
584 for run_idx in hl.run_start..hl.run_end {
585 run_hyperlink_url.insert(run_idx, url.clone());
586 }
587 }
588 }
589
590 for (run_idx, run) in para.runs.iter().enumerate() {
592 let current_hyperlink_url = run_hyperlink_url.get(&run_idx).cloned();
593
594 let run_style_id = run.properties.as_ref().and_then(|p| p.style_id.as_deref());
595
596 let resolved_rpr =
597 style_resolver::resolve_run_properties(para_style_id, run_style_id, styles);
598
599 let mut effective_rpr = resolved_rpr;
601 if let Some(ref direct_rpr) = run.properties {
602 effective_rpr.merge_from(direct_rpr);
603 }
604
605 if effective_rpr.vanish == Some(true) {
607 continue;
608 }
609
610 let mut font_size = effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
611 let bold = effective_rpr.bold.unwrap_or(false);
612 let italic = effective_rpr.italic.unwrap_or(false);
613
614 let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
616
617 let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
619
620 let underline = convert::underline(effective_rpr.underline);
622 let strike = effective_rpr.strike.unwrap_or(false);
623 let dstrike = effective_rpr.dstrike.unwrap_or(false);
624 let highlight = effective_rpr.highlight.and_then(highlight_to_color);
625
626 let mut baseline_offset = 0.0;
628 if let Some(ref va) = effective_rpr.vert_align {
629 match va.as_str() {
630 "superscript" => {
631 let original_size = font_size;
633 font_size *= 0.58;
634 baseline_offset = original_size * 0.33; }
636 "subscript" => {
637 let original_size = font_size;
639 font_size *= 0.58;
640 baseline_offset = -(original_size * 0.14); }
642 _ => {}
643 }
644 }
645
646 if let Some(pos) = effective_rpr.position {
648 baseline_offset += pos as f64 / 2.0; }
650
651 let font_id =
654 fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?;
655 let metrics = fm.metrics(font_id, font_size)?;
656
657 for content in &run.content {
658 match content {
659 RunContent::Text(ct_text) => {
660 let text = if effective_rpr.caps == Some(true) {
661 ct_text.text.to_uppercase()
662 } else {
663 ct_text.text.clone()
664 };
665
666 if text.is_empty() {
667 continue;
668 }
669
670 let mut shaped = fm.shape_text(font_id, &text, font_size)?;
671
672 if let Some(spacing) = effective_rpr.spacing {
674 let extra = spacing.to_pt();
675 for advance in &mut shaped.advances {
676 *advance += extra;
677 }
678 shaped.width += extra * shaped.advances.len() as f64;
679 }
680
681 inline_items.extend(convert::text_segments(TextSegment {
682 text,
683 font_id,
684 font_size,
685 glyph_ids: shaped.glyph_ids,
686 advances: shaped.advances,
687 width: shaped.width,
688 ascent: metrics.ascent,
689 descent: metrics.descent,
690 line_gap: 0.0,
691 color,
692 bold,
693 italic,
694 underline,
695 strike,
696 dstrike,
697 highlight,
698 baseline_offset,
699 hyperlink_url: current_hyperlink_url.clone(),
700 field_kind: None,
701 footnote_id: None,
702 }));
703 }
704 RunContent::Tab => {
705 inline_items.push(InlineItem::Tab);
706 }
707 RunContent::Break(bt) => match bt {
708 BreakType::Line => inline_items.push(InlineItem::LineBreak),
709 BreakType::Page => inline_items.push(InlineItem::PageBreak),
710 BreakType::Column => inline_items.push(InlineItem::ColumnBreak),
711 },
712 RunContent::Drawing(drawing) => {
713 if let Some(ref inline) = drawing.inline {
714 let width = inline.extent_cx.to_pt();
715 let height = inline.extent_cy.to_pt();
716 inline_items.push(InlineItem::Image {
717 width,
718 height,
719 media_id: media.id_for_relationship(&inline.embed_id),
720 });
721 }
722 }
723 RunContent::Field { field_type } => {
724 let placeholder = "99";
726 let fk = match field_type {
727 FieldType::Page => FieldKind::Page,
728 FieldType::NumPages => FieldKind::NumPages,
729 FieldType::Other(_) => continue, };
731 let shaped = fm.shape_text(font_id, placeholder, font_size)?;
732 inline_items.push(InlineItem::Text(TextSegment {
733 text: placeholder.to_string(),
734 font_id,
735 font_size,
736 glyph_ids: shaped.glyph_ids,
737 advances: shaped.advances,
738 width: shaped.width,
739 ascent: metrics.ascent,
740 descent: metrics.descent,
741 line_gap: 0.0,
742 color,
743 bold,
744 italic,
745 underline: None,
746 strike: false,
747 dstrike: false,
748 highlight: None,
749 baseline_offset,
750 hyperlink_url: None,
751 field_kind: Some(fk),
752 footnote_id: None,
753 }));
754 }
755 RunContent::FootnoteRef { id } | RunContent::EndnoteRef { id } => {
756 let marker = id.to_string();
758 let sup_size = font_size * 0.58;
759 let sup_offset = font_size * 0.33; let shaped = fm.shape_text(font_id, &marker, sup_size)?;
761 let sup_metrics = fm.metrics(font_id, sup_size)?;
762 inline_items.push(InlineItem::Text(TextSegment {
763 text: marker,
764 font_id,
765 font_size: sup_size,
766 glyph_ids: shaped.glyph_ids,
767 advances: shaped.advances,
768 width: shaped.width,
769 ascent: sup_metrics.ascent,
770 descent: sup_metrics.descent,
771 line_gap: 0.0,
772 color,
773 bold,
774 italic,
775 underline: None,
776 strike: false,
777 dstrike: false,
778 highlight: None,
779 baseline_offset: sup_offset,
780 hyperlink_url: None,
781 field_kind: None,
782 footnote_id: Some(*id),
783 }));
784 }
785 }
786 }
787 }
788
789 let line_params = convert::line_break_params(&effective_ppr, available_width);
791
792 let mut lines = break_into_lines(&inline_items, &line_params, fm)?;
793 convert::restore_word_line_heights(&mut lines, &effective_ppr);
794
795 let mut result = block::build_paragraph_block(
796 lines,
797 space_before,
798 space_after,
799 effective_ppr.borders,
800 shading,
801 ind_left,
802 ind_right,
803 jc,
804 keep_next,
805 keep_lines,
806 page_break_before,
807 widow_control,
808 );
809 result.anchored = collect_anchored_drawings(para, styles, input, media, fm, num_state)?;
810 Ok(result)
811}
812
813fn collect_anchored_drawings(
822 para: &CT_P,
823 styles: &CT_Styles,
824 input: &LayoutInput,
825 media: &MediaRegistry,
826 fm: &mut FontManager,
827 num_state: &mut NumberingState,
828) -> Result<Vec<block::AnchoredDrawing>> {
829 let mut out = Vec::new();
830
831 for run in ¶.runs {
834 let plain = run.content.iter().filter_map(|rc| match rc {
835 RunContent::Drawing(d) => Some(d),
836 _ => None,
837 });
838 for drawing in plain.chain(run.alt_drawings.iter()) {
839 let Some(anchor) = drawing.anchor.as_ref() else {
840 continue;
841 };
842
843 let shape = if anchor.embed_id.is_empty() {
847 anchor.shape.as_ref()
848 } else {
849 None
850 };
851
852 let content = match shape {
853 Some(shape) => {
854 let mut text = Vec::new();
856 for p in &shape.text {
857 text.push(layout_paragraph(
858 p,
859 anchor.extent_cx.to_pt(),
860 styles,
861 input,
862 media,
863 fm,
864 num_state,
865 )?);
866 }
867 block::AnchoredContent::Shape {
868 preset: block::ShapePreset::from_prst(shape.preset.as_deref()),
869 fill: shape.solid_fill.as_deref().map(Color::from_hex),
870 text,
871 }
872 }
873 None if anchor.embed_id.is_empty() => continue,
874 None => block::AnchoredContent::Image {
875 media_id: media.id_for_relationship(&anchor.embed_id),
876 },
877 };
878
879 out.push(block::AnchoredDrawing {
880 behind_doc: anchor.behind_doc,
881 rel_h: anchor.pos_h_relative_from,
882 off_h: anchor.pos_h_offset.to_pt(),
883 rel_v: anchor.pos_v_relative_from,
884 off_v: anchor.pos_v_offset.to_pt(),
885 width: anchor.extent_cx.to_pt(),
886 height: anchor.extent_cy.to_pt(),
887 content,
888 });
889 }
890 }
891 Ok(out)
892}
893
894fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
896 if direct.jc.is_some() {
898 effective.jc = direct.jc;
899 }
900 if direct.space_before.is_some() {
901 effective.space_before = direct.space_before;
902 }
903 if direct.space_after.is_some() {
904 effective.space_after = direct.space_after;
905 }
906 if direct.line_spacing.is_some() {
907 effective.line_spacing = direct.line_spacing;
908 }
909 if direct.line_rule.is_some() {
910 effective.line_rule = direct.line_rule.clone();
911 }
912 if direct.ind_left.is_some() {
913 effective.ind_left = direct.ind_left;
914 }
915 if direct.ind_right.is_some() {
916 effective.ind_right = direct.ind_right;
917 }
918 if direct.ind_first_line.is_some() {
919 effective.ind_first_line = direct.ind_first_line;
920 }
921 if direct.ind_hanging.is_some() {
922 effective.ind_hanging = direct.ind_hanging;
923 }
924 if direct.keep_next.is_some() {
925 effective.keep_next = direct.keep_next;
926 }
927 if direct.keep_lines.is_some() {
928 effective.keep_lines = direct.keep_lines;
929 }
930 if direct.page_break_before.is_some() {
931 effective.page_break_before = direct.page_break_before;
932 }
933 if direct.widow_control.is_some() {
934 effective.widow_control = direct.widow_control;
935 }
936 if direct.borders.is_some() {
937 effective.borders = direct.borders.clone();
938 }
939 if direct.tabs.is_some() {
940 effective.tabs = direct.tabs.clone();
941 }
942 if direct.shading.is_some() {
943 effective.shading = direct.shading.clone();
944 }
945 if direct.num_id.is_some() {
946 effective.num_id = direct.num_id;
947 }
948 if direct.num_ilvl.is_some() {
949 effective.num_ilvl = direct.num_ilvl;
950 }
951}
952
953fn sect_pr_to_geometry(sect_pr: &CT_SectPr) -> PageGeometry {
955 PageGeometry {
956 page_width: sect_pr.page_width.map(|t| t.to_pt()).unwrap_or(612.0),
957 page_height: sect_pr.page_height.map(|t| t.to_pt()).unwrap_or(792.0),
958 margin_top: sect_pr.margin_top.map(|t| t.to_pt()).unwrap_or(72.0),
959 margin_right: sect_pr.margin_right.map(|t| t.to_pt()).unwrap_or(72.0),
960 margin_bottom: sect_pr.margin_bottom.map(|t| t.to_pt()).unwrap_or(72.0),
961 margin_left: sect_pr.margin_left.map(|t| t.to_pt()).unwrap_or(72.0),
962 header_distance: sect_pr.header_distance.map(|t| t.to_pt()).unwrap_or(36.0),
963 footer_distance: sect_pr.footer_distance.map(|t| t.to_pt()).unwrap_or(36.0),
964 }
965}
966
967fn layout_header_footer(
969 sect_pr: &CT_SectPr,
970 input: &LayoutInput,
971 styles: &CT_Styles,
972 media: &MediaRegistry,
973 fm: &mut FontManager,
974 num_state: &mut NumberingState,
975) -> Result<Option<HeaderFooterContent>> {
976 let mut has_content = false;
977 let mut header_blocks = Vec::new();
978 let mut footer_blocks = Vec::new();
979 let mut first_header_blocks = Vec::new();
980 let mut first_footer_blocks = Vec::new();
981
982 let geometry = sect_pr_to_geometry(sect_pr);
983 let width = geometry.content_width();
984
985 for href in §_pr.header_refs {
986 let target_blocks = match href.hdr_ftr_type {
987 HdrFtrType::Default => &mut header_blocks,
988 HdrFtrType::First => &mut first_header_blocks,
989 _ => continue, };
991 if let Some(hdr) = input.headers.get(&href.rel_id) {
992 for para in &hdr.paragraphs {
993 let block = layout_paragraph(para, width, styles, input, media, fm, num_state)?;
994 target_blocks.push(block);
995 }
996 has_content = true;
997 }
998 }
999
1000 for fref in §_pr.footer_refs {
1001 let target_blocks = match fref.hdr_ftr_type {
1002 HdrFtrType::Default => &mut footer_blocks,
1003 HdrFtrType::First => &mut first_footer_blocks,
1004 _ => continue, };
1006 if let Some(ftr) = input.footers.get(&fref.rel_id) {
1007 for para in &ftr.paragraphs {
1008 let block = layout_paragraph(para, width, styles, input, media, fm, num_state)?;
1009 target_blocks.push(block);
1010 }
1011 has_content = true;
1012 }
1013 }
1014
1015 if has_content {
1016 Ok(Some(HeaderFooterContent {
1017 header_blocks,
1018 footer_blocks,
1019 first_header_blocks,
1020 first_footer_blocks,
1021 }))
1022 } else {
1023 Ok(None)
1024 }
1025}
1026
1027fn resolve_font_family(
1031 rpr: &rdocx_oxml::properties::CT_RPr,
1032 theme: Option<&rdocx_oxml::theme::Theme>,
1033) -> Option<String> {
1034 if rpr.font_ascii.is_some() {
1036 return rpr.font_ascii.clone();
1037 }
1038
1039 if let (Some(theme_ref), Some(theme)) = (&rpr.font_ascii_theme, theme) {
1041 let font = match theme_ref.as_str() {
1042 "majorAscii" | "majorHAnsi" | "majorBidi" | "majorEastAsia" => {
1043 theme.major_font.as_deref()
1044 }
1045 "minorAscii" | "minorHAnsi" | "minorBidi" | "minorEastAsia" => {
1046 theme.minor_font.as_deref()
1047 }
1048 _ => None,
1049 };
1050 if let Some(f) = font {
1051 return Some(f.to_string());
1052 }
1053 }
1054
1055 None
1056}
1057
1058fn resolve_run_color(
1062 rpr: &rdocx_oxml::properties::CT_RPr,
1063 theme: Option<&rdocx_oxml::theme::Theme>,
1064) -> Color {
1065 if let Some(ref theme_name) = rpr.color_theme
1067 && let Some(theme) = theme
1068 && let Some(hex) = theme.colors.get(theme_name)
1069 {
1070 return Color::from_hex(hex);
1071 }
1072
1073 rpr.color
1075 .as_ref()
1076 .filter(|c| c.as_str() != "auto")
1077 .map(|c| Color::from_hex(c))
1078 .unwrap_or(Color::BLACK)
1079}
1080
1081fn highlight_to_color(h: ST_HighlightColor) -> Option<Color> {
1083 match h {
1084 ST_HighlightColor::None => None,
1085 ST_HighlightColor::Black => Some(Color {
1086 r: 0.0,
1087 g: 0.0,
1088 b: 0.0,
1089 a: 1.0,
1090 }),
1091 ST_HighlightColor::Blue => Some(Color {
1092 r: 0.0,
1093 g: 0.0,
1094 b: 1.0,
1095 a: 1.0,
1096 }),
1097 ST_HighlightColor::Cyan => Some(Color {
1098 r: 0.0,
1099 g: 1.0,
1100 b: 1.0,
1101 a: 1.0,
1102 }),
1103 ST_HighlightColor::DarkBlue => Some(Color {
1104 r: 0.0,
1105 g: 0.0,
1106 b: 0.545,
1107 a: 1.0,
1108 }),
1109 ST_HighlightColor::DarkCyan => Some(Color {
1110 r: 0.0,
1111 g: 0.545,
1112 b: 0.545,
1113 a: 1.0,
1114 }),
1115 ST_HighlightColor::DarkGray => Some(Color {
1116 r: 0.663,
1117 g: 0.663,
1118 b: 0.663,
1119 a: 1.0,
1120 }),
1121 ST_HighlightColor::DarkGreen => Some(Color {
1122 r: 0.0,
1123 g: 0.392,
1124 b: 0.0,
1125 a: 1.0,
1126 }),
1127 ST_HighlightColor::DarkMagenta => Some(Color {
1128 r: 0.545,
1129 g: 0.0,
1130 b: 0.545,
1131 a: 1.0,
1132 }),
1133 ST_HighlightColor::DarkRed => Some(Color {
1134 r: 0.545,
1135 g: 0.0,
1136 b: 0.0,
1137 a: 1.0,
1138 }),
1139 ST_HighlightColor::DarkYellow => Some(Color {
1140 r: 0.545,
1141 g: 0.545,
1142 b: 0.0,
1143 a: 1.0,
1144 }),
1145 ST_HighlightColor::Green => Some(Color {
1146 r: 0.0,
1147 g: 1.0,
1148 b: 0.0,
1149 a: 1.0,
1150 }),
1151 ST_HighlightColor::LightGray => Some(Color {
1152 r: 0.827,
1153 g: 0.827,
1154 b: 0.827,
1155 a: 1.0,
1156 }),
1157 ST_HighlightColor::Magenta => Some(Color {
1158 r: 1.0,
1159 g: 0.0,
1160 b: 1.0,
1161 a: 1.0,
1162 }),
1163 ST_HighlightColor::Red => Some(Color {
1164 r: 1.0,
1165 g: 0.0,
1166 b: 0.0,
1167 a: 1.0,
1168 }),
1169 ST_HighlightColor::White => Some(Color {
1170 r: 1.0,
1171 g: 1.0,
1172 b: 1.0,
1173 a: 1.0,
1174 }),
1175 ST_HighlightColor::Yellow => Some(Color {
1176 r: 1.0,
1177 g: 1.0,
1178 b: 0.0,
1179 a: 1.0,
1180 }),
1181 }
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187 use crate::input::ImageData;
1188 use oxml_layout::MediaId;
1189 use std::collections::HashMap;
1190
1191 fn make_input_with_text(text: &str) -> LayoutInput {
1192 let mut doc = rdocx_oxml::document::CT_Document::new();
1193 let mut p = CT_P::new();
1194 p.add_run(text);
1195 doc.body.add_paragraph(p);
1196
1197 LayoutInput {
1198 document: doc,
1199 styles: CT_Styles::new_default(),
1200 numbering: None,
1201 headers: HashMap::new(),
1202 footers: HashMap::new(),
1203 images: HashMap::new(),
1204 core_properties: None,
1205 hyperlink_urls: HashMap::new(),
1206 footnotes: None,
1207 endnotes: None,
1208 theme: None,
1209 fonts: Vec::new(),
1210 }
1211 }
1212
1213 #[test]
1214 fn layout_simple_document() {
1215 let input = make_input_with_text("Hello World");
1216 let result = Engine::new().layout(&input);
1217 if let Ok(result) = result {
1219 assert!(!result.pages.is_empty());
1220 assert_eq!(result.pages[0].page_number, 1);
1221 assert!((result.pages[0].width - 612.0).abs() < 0.01);
1222 }
1223 }
1224
1225 #[test]
1226 fn layout_empty_document() {
1227 let mut doc = rdocx_oxml::document::CT_Document::new();
1228 doc.body.add_paragraph(CT_P::new());
1229
1230 let input = LayoutInput {
1231 document: doc,
1232 styles: CT_Styles::new_default(),
1233 numbering: None,
1234 headers: HashMap::new(),
1235 footers: HashMap::new(),
1236 images: HashMap::new(),
1237 core_properties: None,
1238 hyperlink_urls: HashMap::new(),
1239 footnotes: None,
1240 endnotes: None,
1241 theme: None,
1242 fonts: Vec::new(),
1243 };
1244
1245 let result = Engine::new().layout(&input);
1246 if let Ok(result) = result {
1247 assert_eq!(result.pages.len(), 1);
1248 }
1249 }
1250
1251 #[test]
1252 fn empty_shapeless_anchor_keeps_the_pre_cutover_omission() {
1253 let input = make_input_with_text("");
1254 let mut paragraph = CT_P::new();
1255 paragraph.add_run("").content = vec![RunContent::Drawing(
1256 rdocx_oxml::drawing::CT_Drawing::anchor(rdocx_oxml::drawing::CT_Anchor::background(
1257 "", 914_400, 914_400,
1258 )),
1259 )];
1260 let mut font_manager = FontManager::new();
1261 let mut numbering_state = NumberingState::new();
1262 let media = MediaRegistry::new(&input.images);
1263
1264 let anchored = collect_anchored_drawings(
1265 ¶graph,
1266 &input.styles,
1267 &input,
1268 &media,
1269 &mut font_manager,
1270 &mut numbering_state,
1271 )
1272 .expect("empty shapeless anchor collection should succeed");
1273
1274 assert!(anchored.is_empty());
1275 }
1276
1277 #[test]
1278 fn colliding_media_ids_keep_inline_and_anchored_image_bytes_distinct() {
1279 let mut input = make_input_with_text("");
1280 input.images.insert(
1281 "rIdInline".to_string(),
1282 ImageData {
1283 data: vec![1, 2, 3],
1284 content_type: "image/png".to_string(),
1285 },
1286 );
1287 input.images.insert(
1288 "rIdAnchor".to_string(),
1289 ImageData {
1290 data: vec![4, 5, 6],
1291 content_type: "image/jpeg".to_string(),
1292 },
1293 );
1294
1295 let media = MediaRegistry::with_hasher(&input.images, |_| MediaId(7));
1296 let inline_id = media.id_for_relationship("rIdInline");
1297 let anchor_id = media.id_for_relationship("rIdAnchor");
1298 assert_ne!(inline_id, anchor_id);
1299
1300 let line = oxml_layout::LayoutLine {
1301 items: vec![LineItem::Image {
1302 width: 12.0,
1303 height: 10.0,
1304 media_id: inline_id,
1305 }],
1306 width: 12.0,
1307 ascent: 10.0,
1308 descent: 0.0,
1309 line_gap: 0.0,
1310 height: 10.0,
1311 indent_left: 0.0,
1312 available_width: 468.0,
1313 is_last: true,
1314 };
1315 let mut paragraph = block::build_paragraph_block(
1316 vec![line],
1317 0.0,
1318 0.0,
1319 None,
1320 None,
1321 0.0,
1322 0.0,
1323 None,
1324 false,
1325 false,
1326 false,
1327 true,
1328 );
1329 paragraph.anchored.push(block::AnchoredDrawing {
1330 behind_doc: false,
1331 rel_h: rdocx_oxml::drawing::ST_RelativeFromH::Page,
1332 off_h: 20.0,
1333 rel_v: rdocx_oxml::drawing::ST_RelativeFromV::Page,
1334 off_v: 20.0,
1335 width: 12.0,
1336 height: 10.0,
1337 content: block::AnchoredContent::Image {
1338 media_id: anchor_id,
1339 },
1340 });
1341 let sections = [paginator::Section {
1342 blocks: vec![LayoutBlock::Paragraph(paragraph)],
1343 geometry: PageGeometry::default(),
1344 header_footer: None,
1345 title_pg: false,
1346 }];
1347
1348 let (pages, _) = paginator::paginate_sections(§ions, &FontManager::new(), &media);
1349 let images = pages[0]
1350 .elements
1351 .iter()
1352 .filter_map(|element| match element {
1353 PositionedElement::Image {
1354 data,
1355 content_type,
1356 media_id,
1357 ..
1358 } => Some((data.as_slice(), content_type.as_str(), *media_id)),
1359 _ => None,
1360 })
1361 .collect::<Vec<_>>();
1362
1363 assert!(images.contains(&(b"\x01\x02\x03".as_slice(), "image/png", inline_id)));
1364 assert!(images.contains(&(b"\x04\x05\x06".as_slice(), "image/jpeg", anchor_id)));
1365 }
1366
1367 #[test]
1368 fn layout_with_heading_style() {
1369 let mut doc = rdocx_oxml::document::CT_Document::new();
1370 let mut p = CT_P::new();
1371 p.properties = Some(CT_PPr {
1372 style_id: Some("Heading1".to_string()),
1373 ..Default::default()
1374 });
1375 p.add_run("Chapter 1");
1376 doc.body.add_paragraph(p);
1377
1378 let input = LayoutInput {
1379 document: doc,
1380 styles: CT_Styles::new_default(),
1381 numbering: None,
1382 headers: HashMap::new(),
1383 footers: HashMap::new(),
1384 images: HashMap::new(),
1385 core_properties: None,
1386 hyperlink_urls: HashMap::new(),
1387 footnotes: None,
1388 endnotes: None,
1389 theme: None,
1390 fonts: Vec::new(),
1391 };
1392
1393 let result = Engine::new().layout(&input);
1394 if let Ok(result) = result {
1395 assert!(!result.pages.is_empty());
1396 assert_eq!(result.outlines.len(), 1);
1398 assert_eq!(result.outlines[0].title, "Chapter 1");
1399 assert_eq!(result.outlines[0].level, 1);
1400 assert_eq!(result.outlines[0].page_index, 0);
1401 }
1402 }
1403
1404 #[test]
1405 fn layout_nested_headings_produce_outlines() {
1406 let mut doc = rdocx_oxml::document::CT_Document::new();
1407
1408 let mut h1 = CT_P::new();
1410 h1.properties = Some(CT_PPr {
1411 style_id: Some("Heading1".to_string()),
1412 ..Default::default()
1413 });
1414 h1.add_run("Chapter 1");
1415 doc.body.add_paragraph(h1);
1416
1417 let mut h2 = CT_P::new();
1419 h2.properties = Some(CT_PPr {
1420 style_id: Some("Heading2".to_string()),
1421 ..Default::default()
1422 });
1423 h2.add_run("Section 1.1");
1424 doc.body.add_paragraph(h2);
1425
1426 let mut h1b = CT_P::new();
1428 h1b.properties = Some(CT_PPr {
1429 style_id: Some("Heading1".to_string()),
1430 ..Default::default()
1431 });
1432 h1b.add_run("Chapter 2");
1433 doc.body.add_paragraph(h1b);
1434
1435 let input = 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: None,
1445 endnotes: None,
1446 theme: None,
1447 fonts: Vec::new(),
1448 };
1449
1450 let result = Engine::new().layout(&input);
1451 if let Ok(result) = result {
1452 assert_eq!(result.outlines.len(), 3);
1453 assert_eq!(result.outlines[0].level, 1);
1454 assert_eq!(result.outlines[0].title, "Chapter 1");
1455 assert_eq!(result.outlines[1].level, 2);
1456 assert_eq!(result.outlines[1].title, "Section 1.1");
1457 assert_eq!(result.outlines[2].level, 1);
1458 assert_eq!(result.outlines[2].title, "Chapter 2");
1459 }
1460 }
1461
1462 #[test]
1463 fn sect_pr_geometry_conversion() {
1464 let sect = CT_SectPr::default_letter();
1465 let geom = sect_pr_to_geometry(§);
1466 assert!((geom.page_width - 612.0).abs() < 0.01);
1467 assert!((geom.page_height - 792.0).abs() < 0.01);
1468 assert!((geom.margin_top - 72.0).abs() < 0.01);
1469 assert!((geom.content_width() - 468.0).abs() < 0.01);
1470 }
1471
1472 #[test]
1473 fn sect_pr_a4_geometry() {
1474 let sect = CT_SectPr::default_a4();
1475 let geom = sect_pr_to_geometry(§);
1476 assert!((geom.page_width - 595.3).abs() < 0.5);
1478 assert!((geom.page_height - 841.9).abs() < 0.5);
1479 }
1480}