Skip to main content

rdocx_layout/
engine.rs

1//! Layout engine orchestrator: ties all phases together.
2
3use 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::error::Result;
12use crate::font::FontManager;
13use crate::input::LayoutInput;
14use crate::line::{self, InlineItem, LineBreakParams, LineItem, TextSegment};
15use crate::output::{
16    Color, DocumentMetadata, FieldKind, GlyphRun, LayoutResult, PageFrame, Point,
17    PositionedElement, Rect,
18};
19use crate::paginator::{self, HeaderFooterContent, PageGeometry};
20use crate::style_resolver::{self, NumberingState};
21use crate::table;
22
23/// The layout engine.
24pub 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    /// Create an engine that resolves fonts without system font discovery.
42    pub fn new_deterministic() -> Result<Self> {
43        Ok(Engine {
44            font_manager: FontManager::new_deterministic()?,
45        })
46    }
47
48    /// Lay out the entire document.
49    pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
50        // Load user-provided / DOCX-embedded fonts (highest priority)
51        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
58        // Get final section properties (body-level sectPr)
59        let final_sect_pr = input
60            .document
61            .body
62            .sect_pr
63            .as_ref()
64            .cloned()
65            .unwrap_or_else(CT_SectPr::default_letter);
66
67        // Build sections: each section has blocks + geometry + header/footer
68        let mut sections: Vec<paginator::Section> = Vec::new();
69        let mut current_blocks: Vec<LayoutBlock> = Vec::new();
70        let mut current_sect_pr: Option<CT_SectPr> = None; // Will be set from paragraph sect_pr
71
72        for content in &input.document.body.content {
73            match content {
74                BodyContent::Paragraph(para) => {
75                    // Check if this paragraph ends a section (has sect_pr)
76                    let para_sect_pr = para.properties.as_ref().and_then(|p| p.sect_pr.clone());
77
78                    let sect_pr_for_layout = para_sect_pr
79                        .as_ref()
80                        .or(current_sect_pr.as_ref())
81                        .unwrap_or(&final_sect_pr);
82                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
83
84                    let mut para_block = layout_paragraph(
85                        para,
86                        geometry.content_width(),
87                        styles,
88                        input,
89                        &mut self.font_manager,
90                        &mut num_state,
91                    )?;
92
93                    // Detect heading style for outline generation
94                    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 this paragraph has sect_pr, it ends a section
102                    if let Some(sect_pr) = para_sect_pr {
103                        let geometry = sect_pr_to_geometry(&sect_pr);
104                        let header_footer = layout_header_footer(
105                            &sect_pr,
106                            input,
107                            styles,
108                            &mut self.font_manager,
109                            &mut num_state,
110                        )?;
111                        let title_pg = sect_pr.title_pg.unwrap_or(false);
112                        sections.push(paginator::Section {
113                            blocks: std::mem::take(&mut current_blocks),
114                            geometry,
115                            header_footer,
116                            title_pg,
117                        });
118                        current_sect_pr = Some(sect_pr);
119                    }
120                }
121                BodyContent::Table(tbl) => {
122                    let sect_pr_for_layout = current_sect_pr.as_ref().unwrap_or(&final_sect_pr);
123                    let geometry = sect_pr_to_geometry(sect_pr_for_layout);
124
125                    let table_block = table::layout_table(
126                        tbl,
127                        geometry.content_width(),
128                        styles,
129                        input,
130                        &mut self.font_manager,
131                        &mut num_state,
132                    )?;
133                    current_blocks.push(LayoutBlock::Table(table_block));
134                }
135                _ => {} // Skip RawXml elements during layout
136            }
137        }
138
139        // Remaining blocks belong to the final section
140        let final_geometry = sect_pr_to_geometry(&final_sect_pr);
141        let final_hf = layout_header_footer(
142            &final_sect_pr,
143            input,
144            styles,
145            &mut self.font_manager,
146            &mut num_state,
147        )?;
148        let final_title_pg = final_sect_pr.title_pg.unwrap_or(false);
149        sections.push(paginator::Section {
150            blocks: current_blocks,
151            geometry: final_geometry,
152            header_footer: final_hf,
153            title_pg: final_title_pg,
154        });
155
156        // Paginate across all sections
157        let (mut pages, outlines) = paginator::paginate_sections(&sections, &self.font_manager);
158
159        // Post-pagination pass: substitute field placeholders
160        let total_pages = pages.len();
161        for page in &mut pages {
162            let page_num = page.page_number;
163            substitute_fields(
164                &mut page.elements,
165                page_num,
166                total_pages,
167                &mut self.font_manager,
168            );
169        }
170
171        // Post-pagination pass: apply page background color
172        apply_page_background(&mut pages, input);
173
174        // Post-pagination pass: resolve inline image data
175        resolve_inline_images(&mut pages, input);
176
177        // Post-pagination pass: render footnotes at page bottoms
178        if input.footnotes.is_some() || input.endnotes.is_some() {
179            render_page_footnotes(
180                &mut pages,
181                input,
182                styles,
183                &final_geometry,
184                &mut self.font_manager,
185                &mut num_state,
186            )?;
187        }
188
189        // Collect font data
190        let fonts = self.font_manager.all_font_data();
191
192        // Convert core properties to document metadata
193        let metadata = input.core_properties.as_ref().map(|cp| DocumentMetadata {
194            title: cp.title.clone(),
195            author: cp.creator.clone(),
196            subject: cp.subject.clone(),
197            keywords: cp.keywords.clone(),
198            creator: Some("rdocx".to_string()),
199        });
200
201        Ok(LayoutResult {
202            pages,
203            fonts,
204            metadata,
205            outlines,
206        })
207    }
208}
209
210/// Apply page background color from `w:background` element to all pages.
211fn apply_page_background(pages: &mut [PageFrame], input: &LayoutInput) {
212    let bg_xml = match &input.document.background_xml {
213        Some(xml) => xml,
214        None => return,
215    };
216
217    // Parse w:color attribute from background XML
218    let xml_str = std::str::from_utf8(bg_xml).unwrap_or("");
219    let color = extract_background_color(xml_str);
220    let color = match color {
221        Some(c) => c,
222        None => return,
223    };
224
225    // Insert a full-page FilledRect at position 0 on every page (renders underneath everything)
226    for page in pages.iter_mut() {
227        page.elements.insert(
228            0,
229            PositionedElement::FilledRect {
230                rect: Rect {
231                    x: 0.0,
232                    y: 0.0,
233                    width: page.width,
234                    height: page.height,
235                },
236                color,
237            },
238        );
239    }
240}
241
242/// Extract the background color hex from w:background XML.
243fn extract_background_color(xml: &str) -> Option<Color> {
244    // Look for w:color="RRGGBB" or color="RRGGBB"
245    for attr in ["w:color=\"", "color=\""] {
246        if let Some(start) = xml.find(attr) {
247            let val_start = start + attr.len();
248            if let Some(end) = xml[val_start..].find('"') {
249                let hex = &xml[val_start..val_start + end];
250                if hex.len() == 6 && hex != "auto" {
251                    return Some(Color::from_hex(hex));
252                }
253            }
254        }
255    }
256    None
257}
258
259/// Resolve inline image data from input.images by embed_id.
260///
261/// During pagination, inline images are created with empty data and an embed_id.
262/// This pass fills in the actual image bytes and content type.
263fn resolve_inline_images(pages: &mut [PageFrame], input: &LayoutInput) {
264    for page in pages.iter_mut() {
265        for element in &mut page.elements {
266            if let PositionedElement::Image {
267                data,
268                content_type,
269                embed_id: Some(eid),
270                ..
271            } = element
272                && data.is_empty()
273                && let Some(img) = input.images.get(eid.as_str())
274            {
275                *data = img.data.clone();
276                *content_type = img.content_type.clone();
277            }
278        }
279    }
280}
281
282/// Replace field placeholder GlyphRuns with actual values.
283fn substitute_fields(
284    elements: &mut [PositionedElement],
285    page_number: usize,
286    total_pages: usize,
287    fm: &mut crate::font::FontManager,
288) {
289    for element in elements.iter_mut() {
290        if let PositionedElement::Text(run) = element
291            && let Some(fk) = run.field_kind
292        {
293            let value = match fk {
294                FieldKind::Page => page_number.to_string(),
295                FieldKind::NumPages => total_pages.to_string(),
296            };
297            // Re-shape the text with the actual value
298            if let Ok(shaped) = fm.shape_text(run.font_id, &value, run.font_size) {
299                run.text = value;
300                run.glyph_ids = shaped.glyph_ids;
301                run.advances = shaped.advances;
302            }
303        }
304    }
305}
306
307/// Render footnote/endnote content at the bottom of each page.
308///
309/// For each page, collects footnote IDs from glyph runs, then
310/// renders a separator line and the footnote text in a smaller font.
311fn render_page_footnotes(
312    pages: &mut [PageFrame],
313    input: &LayoutInput,
314    styles: &CT_Styles,
315    geometry: &paginator::PageGeometry,
316    fm: &mut FontManager,
317    num_state: &mut NumberingState,
318) -> Result<()> {
319    let footnote_font_size = 8.0; // Standard footnote font size
320    let separator_offset = 6.0; // Space above separator
321    let separator_width_frac = 0.33; // Separator is 1/3 of content width
322
323    for page in pages.iter_mut() {
324        // Collect footnote IDs referenced on this page (in order, deduplicated)
325        let mut footnote_ids: Vec<i32> = Vec::new();
326        for element in &page.elements {
327            if let PositionedElement::Text(run) = element
328                && let Some(fn_id) = run.footnote_id
329                && !footnote_ids.contains(&fn_id)
330            {
331                footnote_ids.push(fn_id);
332            }
333        }
334
335        if footnote_ids.is_empty() {
336            continue;
337        }
338
339        // Find the footnote paragraphs to render
340        let mut footnote_blocks: Vec<(i32, Vec<block::ParagraphBlock>)> = Vec::new();
341        for &fn_id in &footnote_ids {
342            // Check footnotes first, then endnotes
343            let paragraphs = input
344                .footnotes
345                .as_ref()
346                .and_then(|fns| fns.get_by_id(fn_id))
347                .or_else(|| input.endnotes.as_ref().and_then(|ens| ens.get_by_id(fn_id)));
348
349            if let Some(footnote) = paragraphs {
350                let mut fn_blocks = Vec::new();
351                for para in &footnote.paragraphs {
352                    if let Ok(pb) = layout_paragraph(
353                        para,
354                        geometry.content_width(),
355                        styles,
356                        input,
357                        fm,
358                        num_state,
359                    ) {
360                        fn_blocks.push(pb);
361                    }
362                }
363                footnote_blocks.push((fn_id, fn_blocks));
364            }
365        }
366
367        if footnote_blocks.is_empty() {
368            continue;
369        }
370
371        // Calculate total footnote height
372        let total_fn_height: f64 = footnote_blocks
373            .iter()
374            .flat_map(|(_, blocks)| blocks.iter())
375            .map(|b| b.content_height())
376            .sum();
377
378        // Position footnotes at page bottom, above bottom margin
379        let footnote_area_top =
380            page.height - geometry.margin_bottom - total_fn_height - separator_offset;
381
382        // Draw separator line
383        let sep_y = footnote_area_top;
384        let sep_width = geometry.content_width() * separator_width_frac;
385        page.elements.push(PositionedElement::Line {
386            start: Point {
387                x: geometry.margin_left,
388                y: sep_y,
389            },
390            end: Point {
391                x: geometry.margin_left + sep_width,
392                y: sep_y,
393            },
394            width: 0.5,
395            color: Color::BLACK,
396            dash_pattern: None,
397        });
398
399        // Render each footnote
400        let mut cursor_y = sep_y + separator_offset;
401        for (fn_id, blocks) in &footnote_blocks {
402            for pb in blocks {
403                let baseline_y = cursor_y + pb.lines.first().map(|l| l.ascent).unwrap_or(0.0);
404
405                // Render the footnote number marker as superscript
406                let marker_text = fn_id.to_string();
407                let marker_size = footnote_font_size * 0.58;
408                if let Ok(font_id) = fm.resolve_font(Some("serif"), false, false)
409                    && let Ok(shaped) = fm.shape_text(font_id, &marker_text, marker_size)
410                {
411                    page.elements.push(PositionedElement::Text(GlyphRun {
412                        origin: Point {
413                            x: geometry.margin_left,
414                            y: baseline_y - footnote_font_size * 0.33,
415                        },
416                        font_id,
417                        font_size: marker_size,
418                        glyph_ids: shaped.glyph_ids,
419                        advances: shaped.advances,
420                        text: marker_text,
421                        color: Color::BLACK,
422                        bold: false,
423                        italic: false,
424                        field_kind: None,
425                        footnote_id: None,
426                    }));
427                }
428
429                // Render footnote paragraph lines
430                let indent = 12.0; // Indent after marker
431                for line in &pb.lines {
432                    let line_baseline = cursor_y + line.ascent;
433                    for item in &line.items {
434                        if let LineItem::Text(seg) | LineItem::Marker(seg) = item {
435                            page.elements.push(PositionedElement::Text(GlyphRun {
436                                origin: Point {
437                                    x: geometry.margin_left + indent,
438                                    y: line_baseline - seg.baseline_offset,
439                                },
440                                font_id: seg.font_id,
441                                font_size: seg.font_size,
442                                glyph_ids: seg.glyph_ids.clone(),
443                                advances: seg.advances.clone(),
444                                text: seg.text.clone(),
445                                color: seg.color,
446                                bold: seg.bold,
447                                italic: seg.italic,
448                                field_kind: None,
449                                footnote_id: None,
450                            }));
451                        }
452                    }
453                    cursor_y += line.height;
454                }
455            }
456        }
457    }
458
459    Ok(())
460}
461
462/// Detect if a paragraph has a heading style, returning the level (1-9).
463fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
464    let style_id = para.properties.as_ref()?.style_id.as_deref()?;
465    // Check if style ID matches "Heading1" .. "Heading9"
466    if let Some(rest) = style_id.strip_prefix("Heading") {
467        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
468    }
469    // Also check style name in the styles definitions
470    if let Some(style_def) = styles.get_by_id(style_id)
471        && let Some(ref name) = style_def.name
472        && let Some(rest) = name.strip_prefix("heading ")
473    {
474        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
475    }
476    None
477}
478
479/// Lay out a single paragraph into a ParagraphBlock.
480pub fn layout_paragraph(
481    para: &CT_P,
482    available_width: f64,
483    styles: &CT_Styles,
484    input: &LayoutInput,
485    fm: &mut FontManager,
486    num_state: &mut NumberingState,
487) -> Result<ParagraphBlock> {
488    // Resolve paragraph properties
489    let para_style_id = para.properties.as_ref().and_then(|p| p.style_id.as_deref());
490
491    let resolved_ppr = style_resolver::resolve_paragraph_properties(para_style_id, styles);
492
493    let mut effective_ppr = resolved_ppr;
494
495    // A numbering level carries paragraph properties of its own, mainly the
496    // indentation for that level. They sit between the style and direct
497    // formatting, so merge them before the direct properties rather than
498    // after. Without this every level of a list draws at the same indent.
499    let direct_ppr = para.properties.as_ref();
500    let list_num_id = direct_ppr.and_then(|p| p.num_id).or(effective_ppr.num_id);
501    let list_ilvl = direct_ppr
502        .and_then(|p| p.num_ilvl)
503        .or(effective_ppr.num_ilvl)
504        .unwrap_or(0);
505    if let (Some(num_id), Some(numbering)) = (list_num_id, input.numbering.as_ref())
506        && let Some(lvl_ppr) =
507            style_resolver::level_paragraph_properties(num_id, list_ilvl, numbering)
508    {
509        merge_direct_ppr(&mut effective_ppr, lvl_ppr);
510    }
511
512    // Merge direct paragraph properties
513    if let Some(direct_ppr) = direct_ppr {
514        merge_direct_ppr(&mut effective_ppr, direct_ppr);
515    }
516
517    // Convert paragraph properties to layout values
518    let space_before = effective_ppr.space_before.map(|t| t.to_pt()).unwrap_or(0.0);
519    let space_after = effective_ppr.space_after.map(|t| t.to_pt()).unwrap_or(0.0);
520    let ind_left = effective_ppr.ind_left.map(|t| t.to_pt()).unwrap_or(0.0);
521    let ind_right = effective_ppr.ind_right.map(|t| t.to_pt()).unwrap_or(0.0);
522    let ind_first_line = effective_ppr
523        .ind_first_line
524        .map(|t| t.to_pt())
525        .unwrap_or(0.0);
526    let ind_hanging = effective_ppr.ind_hanging.map(|t| t.to_pt()).unwrap_or(0.0);
527
528    let keep_next = effective_ppr.keep_next.unwrap_or(false);
529    let keep_lines = effective_ppr.keep_lines.unwrap_or(false);
530    let page_break_before = effective_ppr.page_break_before.unwrap_or(false);
531    let widow_control = effective_ppr.widow_control.unwrap_or(true);
532    let jc = effective_ppr.jc;
533
534    // Collect tab stops
535    let tab_stops = effective_ppr
536        .tabs
537        .as_ref()
538        .map(|t| t.tabs.clone())
539        .unwrap_or_default();
540
541    // Parse shading color
542    let shading = effective_ppr
543        .shading
544        .as_ref()
545        .and_then(|shd| shd.fill.as_ref())
546        .filter(|f| f != &"auto")
547        .map(|f| Color::from_hex(f));
548
549    // Convert runs to inline items
550    let mut inline_items = Vec::new();
551
552    // Handle numbering marker
553    if let (Some(num_id), Some(numbering)) = (effective_ppr.num_id, input.numbering.as_ref()) {
554        let ilvl = effective_ppr.num_ilvl.unwrap_or(0);
555        if let Some(marker) = style_resolver::generate_marker(num_id, ilvl, numbering, num_state) {
556            // Shape the marker text
557            let marker_rpr = marker.marker_rpr;
558            let marker_font_size = marker_rpr.sz.map(|hp| hp.to_pt()).unwrap_or_else(|| {
559                style_resolver::resolve_run_properties(para_style_id, None, styles)
560                    .sz
561                    .map(|hp| hp.to_pt())
562                    .unwrap_or(11.0)
563            });
564            let marker_bold = marker_rpr.bold.unwrap_or(false);
565            let marker_italic = marker_rpr.italic.unwrap_or(false);
566            let marker_font_family = marker_rpr.font_ascii.as_deref();
567
568            // Bullet glyphs are not in every font either, so the marker gets
569            // the same coverage check as body text.
570            if let Ok(font_id) = fm.resolve_font_for_text(
571                marker_font_family,
572                marker_bold,
573                marker_italic,
574                &marker.marker_text,
575            ) && let Ok(shaped) = fm.shape_text(font_id, &marker.marker_text, marker_font_size)
576            {
577                let metrics = fm.metrics(font_id, marker_font_size)?;
578                let color = marker_rpr
579                    .color
580                    .as_ref()
581                    .map(|c| Color::from_hex(c))
582                    .unwrap_or(Color::BLACK);
583
584                inline_items.push(InlineItem::Marker(TextSegment {
585                    text: marker.marker_text,
586                    font_id,
587                    font_size: marker_font_size,
588                    glyph_ids: shaped.glyph_ids,
589                    advances: shaped.advances,
590                    width: shaped.width,
591                    ascent: metrics.ascent,
592                    descent: metrics.descent,
593                    color,
594                    bold: marker_bold,
595                    italic: marker_italic,
596                    underline: None,
597                    strike: false,
598                    dstrike: false,
599                    highlight: None,
600                    baseline_offset: 0.0,
601                    hyperlink_url: None,
602                    field_kind: None,
603                    footnote_id: None,
604                }));
605
606                // Add a space/tab after the marker
607                inline_items.push(InlineItem::Tab);
608            }
609        }
610    }
611
612    // Build hyperlink URL map: run index → URL
613    let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
614        std::collections::HashMap::new();
615    for hl in &para.hyperlinks {
616        if let Some(ref rel_id) = hl.rel_id
617            && let Some(url) = input.hyperlink_urls.get(rel_id)
618        {
619            for run_idx in hl.run_start..hl.run_end {
620                run_hyperlink_url.insert(run_idx, url.clone());
621            }
622        }
623    }
624
625    // Process runs
626    for (run_idx, run) in para.runs.iter().enumerate() {
627        let current_hyperlink_url = run_hyperlink_url.get(&run_idx).cloned();
628
629        let run_style_id = run.properties.as_ref().and_then(|p| p.style_id.as_deref());
630
631        let resolved_rpr =
632            style_resolver::resolve_run_properties(para_style_id, run_style_id, styles);
633
634        // Merge direct run properties
635        let mut effective_rpr = resolved_rpr;
636        if let Some(ref direct_rpr) = run.properties {
637            effective_rpr.merge_from(direct_rpr);
638        }
639
640        // Skip hidden text
641        if effective_rpr.vanish == Some(true) {
642            continue;
643        }
644
645        let mut font_size = effective_rpr.sz.map(|hp| hp.to_pt()).unwrap_or(11.0);
646        let bold = effective_rpr.bold.unwrap_or(false);
647        let italic = effective_rpr.italic.unwrap_or(false);
648
649        // Resolve font family: theme font takes priority when no explicit font is set
650        let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
651
652        // Resolve color: theme color takes priority over literal color value
653        let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
654
655        // Decoration properties
656        let underline = effective_rpr.underline;
657        let strike = effective_rpr.strike.unwrap_or(false);
658        let dstrike = effective_rpr.dstrike.unwrap_or(false);
659        let highlight = effective_rpr.highlight.and_then(highlight_to_color);
660
661        // Superscript/subscript handling
662        let mut baseline_offset = 0.0;
663        if let Some(ref va) = effective_rpr.vert_align {
664            match va.as_str() {
665                "superscript" => {
666                    // Reduce font size to ~58% and raise baseline
667                    let original_size = font_size;
668                    font_size *= 0.58;
669                    baseline_offset = original_size * 0.33; // raise by 1/3 of original size
670                }
671                "subscript" => {
672                    // Reduce font size to ~58% and lower baseline
673                    let original_size = font_size;
674                    font_size *= 0.58;
675                    baseline_offset = -(original_size * 0.14); // lower
676                }
677                _ => {}
678            }
679        }
680
681        // Position offset (in half-points, positive=raise)
682        if let Some(pos) = effective_rpr.position {
683            baseline_offset += pos as f64 / 2.0; // half-points to points
684        }
685
686        // Resolved against the run's own text, so a family without glyphs for
687        // this script is replaced by one that has them.
688        let font_id =
689            fm.resolve_font_for_text(font_family.as_deref(), bold, italic, &run.text())?;
690        let metrics = fm.metrics(font_id, font_size)?;
691
692        for content in &run.content {
693            match content {
694                RunContent::Text(ct_text) => {
695                    let text = if effective_rpr.caps == Some(true) {
696                        ct_text.text.to_uppercase()
697                    } else {
698                        ct_text.text.clone()
699                    };
700
701                    if text.is_empty() {
702                        continue;
703                    }
704
705                    let mut shaped = fm.shape_text(font_id, &text, font_size)?;
706
707                    // Apply character spacing from run properties (in twips)
708                    if let Some(spacing) = effective_rpr.spacing {
709                        let extra = spacing.to_pt();
710                        for advance in &mut shaped.advances {
711                            *advance += extra;
712                        }
713                        shaped.width += extra * shaped.advances.len() as f64;
714                    }
715
716                    inline_items.push(InlineItem::Text(TextSegment {
717                        text,
718                        font_id,
719                        font_size,
720                        glyph_ids: shaped.glyph_ids,
721                        advances: shaped.advances,
722                        width: shaped.width,
723                        ascent: metrics.ascent,
724                        descent: metrics.descent,
725                        color,
726                        bold,
727                        italic,
728                        underline,
729                        strike,
730                        dstrike,
731                        highlight,
732                        baseline_offset,
733                        hyperlink_url: current_hyperlink_url.clone(),
734                        field_kind: None,
735                        footnote_id: None,
736                    }));
737                }
738                RunContent::Tab => {
739                    inline_items.push(InlineItem::Tab);
740                }
741                RunContent::Break(bt) => match bt {
742                    BreakType::Line => inline_items.push(InlineItem::LineBreak),
743                    BreakType::Page => inline_items.push(InlineItem::PageBreak),
744                    BreakType::Column => inline_items.push(InlineItem::ColumnBreak),
745                },
746                RunContent::Drawing(drawing) => {
747                    if let Some(ref inline) = drawing.inline {
748                        let width = inline.extent_cx.to_pt();
749                        let height = inline.extent_cy.to_pt();
750                        inline_items.push(InlineItem::Image {
751                            width,
752                            height,
753                            embed_id: inline.embed_id.clone(),
754                        });
755                    }
756                }
757                RunContent::Field { field_type } => {
758                    // Shape a placeholder ("99") for estimated width
759                    let placeholder = "99";
760                    let fk = match field_type {
761                        FieldType::Page => FieldKind::Page,
762                        FieldType::NumPages => FieldKind::NumPages,
763                        FieldType::Other(_) => continue, // skip unsupported fields
764                    };
765                    let shaped = fm.shape_text(font_id, placeholder, font_size)?;
766                    inline_items.push(InlineItem::Text(TextSegment {
767                        text: placeholder.to_string(),
768                        font_id,
769                        font_size,
770                        glyph_ids: shaped.glyph_ids,
771                        advances: shaped.advances,
772                        width: shaped.width,
773                        ascent: metrics.ascent,
774                        descent: metrics.descent,
775                        color,
776                        bold,
777                        italic,
778                        underline: None,
779                        strike: false,
780                        dstrike: false,
781                        highlight: None,
782                        baseline_offset,
783                        hyperlink_url: None,
784                        field_kind: Some(fk),
785                        footnote_id: None,
786                    }));
787                }
788                RunContent::FootnoteRef { id } | RunContent::EndnoteRef { id } => {
789                    // Render as superscript number
790                    let marker = id.to_string();
791                    let sup_size = font_size * 0.58;
792                    let sup_offset = font_size * 0.33; // raise baseline
793                    let shaped = fm.shape_text(font_id, &marker, sup_size)?;
794                    let sup_metrics = fm.metrics(font_id, sup_size)?;
795                    inline_items.push(InlineItem::Text(TextSegment {
796                        text: marker,
797                        font_id,
798                        font_size: sup_size,
799                        glyph_ids: shaped.glyph_ids,
800                        advances: shaped.advances,
801                        width: shaped.width,
802                        ascent: sup_metrics.ascent,
803                        descent: sup_metrics.descent,
804                        color,
805                        bold,
806                        italic,
807                        underline: None,
808                        strike: false,
809                        dstrike: false,
810                        highlight: None,
811                        baseline_offset: sup_offset,
812                        hyperlink_url: None,
813                        field_kind: None,
814                        footnote_id: Some(*id),
815                    }));
816                }
817            }
818        }
819    }
820
821    // Line breaking
822    let line_params = LineBreakParams {
823        available_width,
824        ind_left,
825        ind_right,
826        ind_first_line,
827        ind_hanging,
828        tab_stops,
829        line_spacing: effective_ppr.line_spacing,
830        line_rule: effective_ppr.line_rule,
831        jc,
832    };
833
834    let lines = line::break_into_lines(&inline_items, &line_params, fm)?;
835
836    let mut result = block::build_paragraph_block(
837        lines,
838        space_before,
839        space_after,
840        effective_ppr.borders,
841        shading,
842        ind_left,
843        ind_right,
844        jc,
845        keep_next,
846        keep_lines,
847        page_break_before,
848        widow_control,
849    );
850    result.anchored = collect_anchored_drawings(para, styles, input, fm, num_state)?;
851    Ok(result)
852}
853
854/// Collect the floating drawings anchored to a paragraph.
855///
856/// The offsets stay paired with the frame they are measured from. Resolving
857/// them here is not possible: a paragraph-relative offset needs the laid-out
858/// position of the paragraph, which only the paginator knows.
859///
860/// A shape's text box is laid out here rather than later, because breaking it
861/// into lines needs the font manager.
862fn collect_anchored_drawings(
863    para: &CT_P,
864    styles: &CT_Styles,
865    input: &LayoutInput,
866    fm: &mut FontManager,
867    num_state: &mut NumberingState,
868) -> Result<Vec<block::AnchoredDrawing>> {
869    let mut out = Vec::new();
870
871    // Drawings written plainly, and drawings recovered from an
872    // mc:AlternateContent block, are both anchored the same way.
873    for run in &para.runs {
874        let plain = run.content.iter().filter_map(|rc| match rc {
875            RunContent::Drawing(d) => Some(d),
876            _ => None,
877        });
878        for drawing in plain.chain(run.alt_drawings.iter()) {
879            let Some(anchor) = drawing.anchor.as_ref() else {
880                continue;
881            };
882
883            // A picture also carries a pic:spPr, so a parsed shape alone does
884            // not mean this is a shape. An embed id is what makes it a
885            // picture, and that takes precedence.
886            let shape = if anchor.embed_id.is_empty() {
887                anchor.shape.as_ref()
888            } else {
889                None
890            };
891
892            let content = match shape {
893                Some(shape) => {
894                    // A shape's text box wraps at the shape width.
895                    let mut text = Vec::new();
896                    for p in &shape.text {
897                        text.push(layout_paragraph(
898                            p,
899                            anchor.extent_cx.to_pt(),
900                            styles,
901                            input,
902                            fm,
903                            num_state,
904                        )?);
905                    }
906                    block::AnchoredContent::Shape {
907                        preset: block::ShapePreset::from_prst(shape.preset.as_deref()),
908                        fill: shape.solid_fill.as_deref().map(Color::from_hex),
909                        text,
910                    }
911                }
912                None => block::AnchoredContent::Image {
913                    embed_id: anchor.embed_id.clone(),
914                },
915            };
916
917            out.push(block::AnchoredDrawing {
918                behind_doc: anchor.behind_doc,
919                rel_h: anchor.pos_h_relative_from,
920                off_h: anchor.pos_h_offset.to_pt(),
921                rel_v: anchor.pos_v_relative_from,
922                off_v: anchor.pos_v_offset.to_pt(),
923                width: anchor.extent_cx.to_pt(),
924                height: anchor.extent_cy.to_pt(),
925                content,
926            });
927        }
928    }
929    Ok(out)
930}
931
932/// Merge direct paragraph properties (only fields explicitly set in the XML).
933fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
934    // Don't merge style_id — that was already used for resolution
935    if direct.jc.is_some() {
936        effective.jc = direct.jc;
937    }
938    if direct.space_before.is_some() {
939        effective.space_before = direct.space_before;
940    }
941    if direct.space_after.is_some() {
942        effective.space_after = direct.space_after;
943    }
944    if direct.line_spacing.is_some() {
945        effective.line_spacing = direct.line_spacing;
946    }
947    if direct.line_rule.is_some() {
948        effective.line_rule = direct.line_rule.clone();
949    }
950    if direct.ind_left.is_some() {
951        effective.ind_left = direct.ind_left;
952    }
953    if direct.ind_right.is_some() {
954        effective.ind_right = direct.ind_right;
955    }
956    if direct.ind_first_line.is_some() {
957        effective.ind_first_line = direct.ind_first_line;
958    }
959    if direct.ind_hanging.is_some() {
960        effective.ind_hanging = direct.ind_hanging;
961    }
962    if direct.keep_next.is_some() {
963        effective.keep_next = direct.keep_next;
964    }
965    if direct.keep_lines.is_some() {
966        effective.keep_lines = direct.keep_lines;
967    }
968    if direct.page_break_before.is_some() {
969        effective.page_break_before = direct.page_break_before;
970    }
971    if direct.widow_control.is_some() {
972        effective.widow_control = direct.widow_control;
973    }
974    if direct.borders.is_some() {
975        effective.borders = direct.borders.clone();
976    }
977    if direct.tabs.is_some() {
978        effective.tabs = direct.tabs.clone();
979    }
980    if direct.shading.is_some() {
981        effective.shading = direct.shading.clone();
982    }
983    if direct.num_id.is_some() {
984        effective.num_id = direct.num_id;
985    }
986    if direct.num_ilvl.is_some() {
987        effective.num_ilvl = direct.num_ilvl;
988    }
989}
990
991/// Convert section properties to page geometry.
992fn sect_pr_to_geometry(sect_pr: &CT_SectPr) -> PageGeometry {
993    PageGeometry {
994        page_width: sect_pr.page_width.map(|t| t.to_pt()).unwrap_or(612.0),
995        page_height: sect_pr.page_height.map(|t| t.to_pt()).unwrap_or(792.0),
996        margin_top: sect_pr.margin_top.map(|t| t.to_pt()).unwrap_or(72.0),
997        margin_right: sect_pr.margin_right.map(|t| t.to_pt()).unwrap_or(72.0),
998        margin_bottom: sect_pr.margin_bottom.map(|t| t.to_pt()).unwrap_or(72.0),
999        margin_left: sect_pr.margin_left.map(|t| t.to_pt()).unwrap_or(72.0),
1000        header_distance: sect_pr.header_distance.map(|t| t.to_pt()).unwrap_or(36.0),
1001        footer_distance: sect_pr.footer_distance.map(|t| t.to_pt()).unwrap_or(36.0),
1002    }
1003}
1004
1005/// Lay out header and footer content (both Default and First-page).
1006fn layout_header_footer(
1007    sect_pr: &CT_SectPr,
1008    input: &LayoutInput,
1009    styles: &CT_Styles,
1010    fm: &mut FontManager,
1011    num_state: &mut NumberingState,
1012) -> Result<Option<HeaderFooterContent>> {
1013    let mut has_content = false;
1014    let mut header_blocks = Vec::new();
1015    let mut footer_blocks = Vec::new();
1016    let mut first_header_blocks = Vec::new();
1017    let mut first_footer_blocks = Vec::new();
1018
1019    let geometry = sect_pr_to_geometry(sect_pr);
1020    let width = geometry.content_width();
1021
1022    for href in &sect_pr.header_refs {
1023        let target_blocks = match href.hdr_ftr_type {
1024            HdrFtrType::Default => &mut header_blocks,
1025            HdrFtrType::First => &mut first_header_blocks,
1026            _ => continue, // skip Even for now
1027        };
1028        if let Some(hdr) = input.headers.get(&href.rel_id) {
1029            for para in &hdr.paragraphs {
1030                let block = layout_paragraph(para, width, styles, input, fm, num_state)?;
1031                target_blocks.push(block);
1032            }
1033            has_content = true;
1034        }
1035    }
1036
1037    for fref in &sect_pr.footer_refs {
1038        let target_blocks = match fref.hdr_ftr_type {
1039            HdrFtrType::Default => &mut footer_blocks,
1040            HdrFtrType::First => &mut first_footer_blocks,
1041            _ => continue, // skip Even for now
1042        };
1043        if let Some(ftr) = input.footers.get(&fref.rel_id) {
1044            for para in &ftr.paragraphs {
1045                let block = layout_paragraph(para, width, styles, input, fm, num_state)?;
1046                target_blocks.push(block);
1047            }
1048            has_content = true;
1049        }
1050    }
1051
1052    if has_content {
1053        Ok(Some(HeaderFooterContent {
1054            header_blocks,
1055            footer_blocks,
1056            first_header_blocks,
1057            first_footer_blocks,
1058        }))
1059    } else {
1060        Ok(None)
1061    }
1062}
1063
1064/// Resolve the effective font family for a run, considering theme fonts.
1065///
1066/// Priority: explicit font_ascii > theme font > None (use default).
1067fn resolve_font_family(
1068    rpr: &rdocx_oxml::properties::CT_RPr,
1069    theme: Option<&rdocx_oxml::theme::Theme>,
1070) -> Option<String> {
1071    // Explicit font name takes priority
1072    if rpr.font_ascii.is_some() {
1073        return rpr.font_ascii.clone();
1074    }
1075
1076    // Resolve theme font reference
1077    if let (Some(theme_ref), Some(theme)) = (&rpr.font_ascii_theme, theme) {
1078        let font = match theme_ref.as_str() {
1079            "majorAscii" | "majorHAnsi" | "majorBidi" | "majorEastAsia" => {
1080                theme.major_font.as_deref()
1081            }
1082            "minorAscii" | "minorHAnsi" | "minorBidi" | "minorEastAsia" => {
1083                theme.minor_font.as_deref()
1084            }
1085            _ => None,
1086        };
1087        if let Some(f) = font {
1088            return Some(f.to_string());
1089        }
1090    }
1091
1092    None
1093}
1094
1095/// Resolve the effective color for a run, considering theme colors.
1096///
1097/// Priority: literal color (non-auto) > theme color > black.
1098fn resolve_run_color(
1099    rpr: &rdocx_oxml::properties::CT_RPr,
1100    theme: Option<&rdocx_oxml::theme::Theme>,
1101) -> Color {
1102    // If theme color is specified, resolve it from the theme
1103    if let Some(ref theme_name) = rpr.color_theme
1104        && let Some(theme) = theme
1105        && let Some(hex) = theme.colors.get(theme_name)
1106    {
1107        return Color::from_hex(hex);
1108    }
1109
1110    // Fall back to literal color value
1111    rpr.color
1112        .as_ref()
1113        .filter(|c| c.as_str() != "auto")
1114        .map(|c| Color::from_hex(c))
1115        .unwrap_or(Color::BLACK)
1116}
1117
1118/// Convert a highlight color enum to an RGBA Color.
1119fn highlight_to_color(h: ST_HighlightColor) -> Option<Color> {
1120    match h {
1121        ST_HighlightColor::None => None,
1122        ST_HighlightColor::Black => Some(Color {
1123            r: 0.0,
1124            g: 0.0,
1125            b: 0.0,
1126            a: 1.0,
1127        }),
1128        ST_HighlightColor::Blue => Some(Color {
1129            r: 0.0,
1130            g: 0.0,
1131            b: 1.0,
1132            a: 1.0,
1133        }),
1134        ST_HighlightColor::Cyan => Some(Color {
1135            r: 0.0,
1136            g: 1.0,
1137            b: 1.0,
1138            a: 1.0,
1139        }),
1140        ST_HighlightColor::DarkBlue => Some(Color {
1141            r: 0.0,
1142            g: 0.0,
1143            b: 0.545,
1144            a: 1.0,
1145        }),
1146        ST_HighlightColor::DarkCyan => Some(Color {
1147            r: 0.0,
1148            g: 0.545,
1149            b: 0.545,
1150            a: 1.0,
1151        }),
1152        ST_HighlightColor::DarkGray => Some(Color {
1153            r: 0.663,
1154            g: 0.663,
1155            b: 0.663,
1156            a: 1.0,
1157        }),
1158        ST_HighlightColor::DarkGreen => Some(Color {
1159            r: 0.0,
1160            g: 0.392,
1161            b: 0.0,
1162            a: 1.0,
1163        }),
1164        ST_HighlightColor::DarkMagenta => Some(Color {
1165            r: 0.545,
1166            g: 0.0,
1167            b: 0.545,
1168            a: 1.0,
1169        }),
1170        ST_HighlightColor::DarkRed => Some(Color {
1171            r: 0.545,
1172            g: 0.0,
1173            b: 0.0,
1174            a: 1.0,
1175        }),
1176        ST_HighlightColor::DarkYellow => Some(Color {
1177            r: 0.545,
1178            g: 0.545,
1179            b: 0.0,
1180            a: 1.0,
1181        }),
1182        ST_HighlightColor::Green => Some(Color {
1183            r: 0.0,
1184            g: 1.0,
1185            b: 0.0,
1186            a: 1.0,
1187        }),
1188        ST_HighlightColor::LightGray => Some(Color {
1189            r: 0.827,
1190            g: 0.827,
1191            b: 0.827,
1192            a: 1.0,
1193        }),
1194        ST_HighlightColor::Magenta => Some(Color {
1195            r: 1.0,
1196            g: 0.0,
1197            b: 1.0,
1198            a: 1.0,
1199        }),
1200        ST_HighlightColor::Red => Some(Color {
1201            r: 1.0,
1202            g: 0.0,
1203            b: 0.0,
1204            a: 1.0,
1205        }),
1206        ST_HighlightColor::White => Some(Color {
1207            r: 1.0,
1208            g: 1.0,
1209            b: 1.0,
1210            a: 1.0,
1211        }),
1212        ST_HighlightColor::Yellow => Some(Color {
1213            r: 1.0,
1214            g: 1.0,
1215            b: 0.0,
1216            a: 1.0,
1217        }),
1218    }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224    use std::collections::HashMap;
1225
1226    fn make_input_with_text(text: &str) -> LayoutInput {
1227        let mut doc = rdocx_oxml::document::CT_Document::new();
1228        let mut p = CT_P::new();
1229        p.add_run(text);
1230        doc.body.add_paragraph(p);
1231
1232        LayoutInput {
1233            document: doc,
1234            styles: CT_Styles::new_default(),
1235            numbering: None,
1236            headers: HashMap::new(),
1237            footers: HashMap::new(),
1238            images: HashMap::new(),
1239            core_properties: None,
1240            hyperlink_urls: HashMap::new(),
1241            footnotes: None,
1242            endnotes: None,
1243            theme: None,
1244            fonts: Vec::new(),
1245        }
1246    }
1247
1248    #[test]
1249    fn layout_simple_document() {
1250        let input = make_input_with_text("Hello World");
1251        let result = Engine::new().layout(&input);
1252        // On systems without fonts, this may fail — that's OK
1253        if let Ok(result) = result {
1254            assert!(!result.pages.is_empty());
1255            assert_eq!(result.pages[0].page_number, 1);
1256            assert!((result.pages[0].width - 612.0).abs() < 0.01);
1257        }
1258    }
1259
1260    #[test]
1261    fn layout_empty_document() {
1262        let mut doc = rdocx_oxml::document::CT_Document::new();
1263        doc.body.add_paragraph(CT_P::new());
1264
1265        let input = LayoutInput {
1266            document: doc,
1267            styles: CT_Styles::new_default(),
1268            numbering: None,
1269            headers: HashMap::new(),
1270            footers: HashMap::new(),
1271            images: HashMap::new(),
1272            core_properties: None,
1273            hyperlink_urls: HashMap::new(),
1274            footnotes: None,
1275            endnotes: None,
1276            theme: None,
1277            fonts: Vec::new(),
1278        };
1279
1280        let result = Engine::new().layout(&input);
1281        if let Ok(result) = result {
1282            assert_eq!(result.pages.len(), 1);
1283        }
1284    }
1285
1286    #[test]
1287    fn layout_with_heading_style() {
1288        let mut doc = rdocx_oxml::document::CT_Document::new();
1289        let mut p = CT_P::new();
1290        p.properties = Some(CT_PPr {
1291            style_id: Some("Heading1".to_string()),
1292            ..Default::default()
1293        });
1294        p.add_run("Chapter 1");
1295        doc.body.add_paragraph(p);
1296
1297        let input = LayoutInput {
1298            document: doc,
1299            styles: CT_Styles::new_default(),
1300            numbering: None,
1301            headers: HashMap::new(),
1302            footers: HashMap::new(),
1303            images: HashMap::new(),
1304            core_properties: None,
1305            hyperlink_urls: HashMap::new(),
1306            footnotes: None,
1307            endnotes: None,
1308            theme: None,
1309            fonts: Vec::new(),
1310        };
1311
1312        let result = Engine::new().layout(&input);
1313        if let Ok(result) = result {
1314            assert!(!result.pages.is_empty());
1315            // Should produce one outline entry for Heading1
1316            assert_eq!(result.outlines.len(), 1);
1317            assert_eq!(result.outlines[0].title, "Chapter 1");
1318            assert_eq!(result.outlines[0].level, 1);
1319            assert_eq!(result.outlines[0].page_index, 0);
1320        }
1321    }
1322
1323    #[test]
1324    fn layout_nested_headings_produce_outlines() {
1325        let mut doc = rdocx_oxml::document::CT_Document::new();
1326
1327        // H1
1328        let mut h1 = CT_P::new();
1329        h1.properties = Some(CT_PPr {
1330            style_id: Some("Heading1".to_string()),
1331            ..Default::default()
1332        });
1333        h1.add_run("Chapter 1");
1334        doc.body.add_paragraph(h1);
1335
1336        // H2 under H1
1337        let mut h2 = CT_P::new();
1338        h2.properties = Some(CT_PPr {
1339            style_id: Some("Heading2".to_string()),
1340            ..Default::default()
1341        });
1342        h2.add_run("Section 1.1");
1343        doc.body.add_paragraph(h2);
1344
1345        // Another H1
1346        let mut h1b = CT_P::new();
1347        h1b.properties = Some(CT_PPr {
1348            style_id: Some("Heading1".to_string()),
1349            ..Default::default()
1350        });
1351        h1b.add_run("Chapter 2");
1352        doc.body.add_paragraph(h1b);
1353
1354        let input = LayoutInput {
1355            document: doc,
1356            styles: CT_Styles::new_default(),
1357            numbering: None,
1358            headers: HashMap::new(),
1359            footers: HashMap::new(),
1360            images: HashMap::new(),
1361            core_properties: None,
1362            hyperlink_urls: HashMap::new(),
1363            footnotes: None,
1364            endnotes: None,
1365            theme: None,
1366            fonts: Vec::new(),
1367        };
1368
1369        let result = Engine::new().layout(&input);
1370        if let Ok(result) = result {
1371            assert_eq!(result.outlines.len(), 3);
1372            assert_eq!(result.outlines[0].level, 1);
1373            assert_eq!(result.outlines[0].title, "Chapter 1");
1374            assert_eq!(result.outlines[1].level, 2);
1375            assert_eq!(result.outlines[1].title, "Section 1.1");
1376            assert_eq!(result.outlines[2].level, 1);
1377            assert_eq!(result.outlines[2].title, "Chapter 2");
1378        }
1379    }
1380
1381    #[test]
1382    fn sect_pr_geometry_conversion() {
1383        let sect = CT_SectPr::default_letter();
1384        let geom = sect_pr_to_geometry(&sect);
1385        assert!((geom.page_width - 612.0).abs() < 0.01);
1386        assert!((geom.page_height - 792.0).abs() < 0.01);
1387        assert!((geom.margin_top - 72.0).abs() < 0.01);
1388        assert!((geom.content_width() - 468.0).abs() < 0.01);
1389    }
1390
1391    #[test]
1392    fn sect_pr_a4_geometry() {
1393        let sect = CT_SectPr::default_a4();
1394        let geom = sect_pr_to_geometry(&sect);
1395        // A4: 210mm = 595.3pt, 297mm = 841.9pt
1396        assert!((geom.page_width - 595.3).abs() < 0.5);
1397        assert!((geom.page_height - 841.9).abs() < 0.5);
1398    }
1399}