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