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::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
21/// The layout engine.
22pub 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    /// Create an engine that resolves fonts without system font discovery.
40    pub fn new_deterministic() -> Result<Self> {
41        Ok(Engine {
42            font_manager: FontManager::new_deterministic()?,
43        })
44    }
45
46    /// Lay out the entire document.
47    pub fn layout(&mut self, input: &LayoutInput) -> Result<LayoutResult> {
48        // Load user-provided / DOCX-embedded fonts (highest priority)
49        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        // Get final section properties (body-level sectPr)
58        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        // Build sections: each section has blocks + geometry + header/footer
67        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; // Will be set from paragraph sect_pr
70
71        for content in &input.document.body.content {
72            match content {
73                BodyContent::Paragraph(para) => {
74                    // Check if this paragraph ends a section (has sect_pr)
75                    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                    // 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                            &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                _ => {} // Skip RawXml elements during layout
138            }
139        }
140
141        // Remaining blocks belong to the final section
142        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        // Paginate across all sections
160        let (mut pages, outlines) =
161            paginator::paginate_sections(&sections, &self.font_manager, &media);
162
163        // Post-pagination pass: substitute field placeholders
164        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        // Post-pagination pass: apply page background color
176        apply_page_background(&mut pages, input);
177
178        // Post-pagination pass: render footnotes at page bottoms
179        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        // Collect font data
192        let fonts = self.font_manager.all_font_data();
193
194        // Convert core properties to document metadata
195        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
207/// Apply page background color from `w:background` element to all pages.
208fn 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    // Parse w:color attribute from background XML
215    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    // Insert a full-page FilledRect at position 0 on every page (renders underneath everything)
223    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
239/// Extract the background color hex from w:background XML.
240fn extract_background_color(xml: &str) -> Option<Color> {
241    // Look for w:color="RRGGBB" or color="RRGGBB"
242    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
256/// Replace field placeholder GlyphRuns with actual values.
257fn 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            // Re-shape the text with the actual value
272            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
281/// Render footnote/endnote content at the bottom of each page.
282///
283/// For each page, collects footnote IDs from glyph runs, then
284/// renders a separator line and the footnote text in a smaller font.
285fn 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; // Standard footnote font size
295    let separator_offset = 6.0; // Space above separator
296    let separator_width_frac = 0.33; // Separator is 1/3 of content width
297
298    for page in pages.iter_mut() {
299        // Collect footnote IDs referenced on this page (in order, deduplicated)
300        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        // Find the footnote paragraphs to render
315        let mut footnote_blocks: Vec<(i32, Vec<block::ParagraphBlock>)> = Vec::new();
316        for &fn_id in &footnote_ids {
317            // Check footnotes first, then endnotes
318            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        // Calculate total footnote height
348        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        // Position footnotes at page bottom, above bottom margin
355        let footnote_area_top =
356            page.height - geometry.margin_bottom - total_fn_height - separator_offset;
357
358        // Draw separator line
359        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        // Render each footnote
376        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                // Render the footnote number marker as superscript
382                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                // Render footnote paragraph lines
406                let indent = 12.0; // Indent after marker
407                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
438/// Detect if a paragraph has a heading style, returning the level (1-9).
439fn detect_heading_level(para: &CT_P, styles: &CT_Styles) -> Option<u32> {
440    let style_id = para.properties.as_ref()?.style_id.as_deref()?;
441    // Check if style ID matches "Heading1" .. "Heading9"
442    if let Some(rest) = style_id.strip_prefix("Heading") {
443        return rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n));
444    }
445    // Also check style name in the styles definitions
446    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
455/// Lay out a single paragraph into a ParagraphBlock.
456pub 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    // Resolve paragraph properties
466    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    // A numbering level carries paragraph properties of its own, mainly the
473    // indentation for that level. They sit between the style and direct
474    // formatting, so merge them before the direct properties rather than
475    // after. Without this every level of a list draws at the same indent.
476    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    // Merge direct paragraph properties
490    if let Some(direct_ppr) = direct_ppr {
491        merge_direct_ppr(&mut effective_ppr, direct_ppr);
492    }
493
494    // Convert paragraph properties to layout values
495    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    // Parse shading color
506    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    // Convert runs to inline items
514    let mut inline_items = Vec::new();
515
516    // Handle numbering marker
517    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            // Shape the marker text
521            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            // Bullet glyphs are not in every font either, so the marker gets
533            // the same coverage check as body text.
534            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                // Add a space/tab after the marker
572                inline_items.push(InlineItem::Tab);
573            }
574        }
575    }
576
577    // Build hyperlink URL map: run index → URL
578    let mut run_hyperlink_url: std::collections::HashMap<usize, String> =
579        std::collections::HashMap::new();
580    for hl in &para.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    // Process runs
591    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        // Merge direct run properties
600        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        // Skip hidden text
606        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        // Resolve font family: theme font takes priority when no explicit font is set
615        let font_family = resolve_font_family(&effective_rpr, input.theme.as_ref());
616
617        // Resolve color: theme color takes priority over literal color value
618        let color = resolve_run_color(&effective_rpr, input.theme.as_ref());
619
620        // Decoration properties
621        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        // Superscript/subscript handling
627        let mut baseline_offset = 0.0;
628        if let Some(ref va) = effective_rpr.vert_align {
629            match va.as_str() {
630                "superscript" => {
631                    // Reduce font size to ~58% and raise baseline
632                    let original_size = font_size;
633                    font_size *= 0.58;
634                    baseline_offset = original_size * 0.33; // raise by 1/3 of original size
635                }
636                "subscript" => {
637                    // Reduce font size to ~58% and lower baseline
638                    let original_size = font_size;
639                    font_size *= 0.58;
640                    baseline_offset = -(original_size * 0.14); // lower
641                }
642                _ => {}
643            }
644        }
645
646        // Position offset (in half-points, positive=raise)
647        if let Some(pos) = effective_rpr.position {
648            baseline_offset += pos as f64 / 2.0; // half-points to points
649        }
650
651        // Resolved against the run's own text, so a family without glyphs for
652        // this script is replaced by one that has them.
653        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                    // Apply character spacing from run properties (in twips)
673                    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                    // Shape a placeholder ("99") for estimated width
725                    let placeholder = "99";
726                    let fk = match field_type {
727                        FieldType::Page => FieldKind::Page,
728                        FieldType::NumPages => FieldKind::NumPages,
729                        FieldType::Other(_) => continue, // skip unsupported fields
730                    };
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                    // Render as superscript number
757                    let marker = id.to_string();
758                    let sup_size = font_size * 0.58;
759                    let sup_offset = font_size * 0.33; // raise baseline
760                    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    // Line breaking
790    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
813/// Collect the floating drawings anchored to a paragraph.
814///
815/// The offsets stay paired with the frame they are measured from. Resolving
816/// them here is not possible: a paragraph-relative offset needs the laid-out
817/// position of the paragraph, which only the paginator knows.
818///
819/// A shape's text box is laid out here rather than later, because breaking it
820/// into lines needs the font manager.
821fn 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    // Drawings written plainly, and drawings recovered from an
832    // mc:AlternateContent block, are both anchored the same way.
833    for run in &para.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            // A picture also carries a pic:spPr, so a parsed shape alone does
844            // not mean this is a shape. An embed id is what makes it a
845            // picture, and that takes precedence.
846            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                    // A shape's text box wraps at the shape width.
855                    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
894/// Merge direct paragraph properties (only fields explicitly set in the XML).
895fn merge_direct_ppr(effective: &mut CT_PPr, direct: &CT_PPr) {
896    // Don't merge style_id — that was already used for resolution
897    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
953/// Convert section properties to page geometry.
954fn 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
967/// Lay out header and footer content (both Default and First-page).
968fn 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 &sect_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, // skip Even for now
990        };
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 &sect_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, // skip Even for now
1005        };
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
1027/// Resolve the effective font family for a run, considering theme fonts.
1028///
1029/// Priority: explicit font_ascii > theme font > None (use default).
1030fn resolve_font_family(
1031    rpr: &rdocx_oxml::properties::CT_RPr,
1032    theme: Option<&rdocx_oxml::theme::Theme>,
1033) -> Option<String> {
1034    // Explicit font name takes priority
1035    if rpr.font_ascii.is_some() {
1036        return rpr.font_ascii.clone();
1037    }
1038
1039    // Resolve theme font reference
1040    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
1058/// Resolve the effective color for a run, considering theme colors.
1059///
1060/// Priority: literal color (non-auto) > theme color > black.
1061fn resolve_run_color(
1062    rpr: &rdocx_oxml::properties::CT_RPr,
1063    theme: Option<&rdocx_oxml::theme::Theme>,
1064) -> Color {
1065    // If theme color is specified, resolve it from the theme
1066    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    // Fall back to literal color value
1074    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
1081/// Convert a highlight color enum to an RGBA Color.
1082fn 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        // On systems without fonts, this may fail — that's OK
1218        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            &paragraph,
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(&sections, &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            // Should produce one outline entry for Heading1
1397            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        // H1
1409        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        // H2 under H1
1418        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        // Another H1
1427        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(&sect);
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(&sect);
1476        // A4: 210mm = 595.3pt, 297mm = 841.9pt
1477        assert!((geom.page_width - 595.3).abs() < 0.5);
1478        assert!((geom.page_height - 841.9).abs() < 0.5);
1479    }
1480}