Skip to main content

rdocx_layout/
paginator.rs

1//! Pagination: distribute blocks across pages with constraints.
2//!
3//! Handles page breaks, widow/orphan control, keep-with-next,
4//! keep-lines-together, and header/footer placement.
5
6use crate::block::{AnchoredContent, AnchoredDrawing, LayoutBlock, ParagraphBlock, ShapePreset};
7use std::collections::HashMap;
8
9use oxml_layout::{
10    Align, Color, FontManager, GlyphRun, LayoutLine, LineItem, MediaId, OutlineEntry, PageFrame,
11    Point, PositionedElement, Rect, Underline,
12};
13
14use rdocx_oxml::drawing::{ST_RelativeFromH, ST_RelativeFromV};
15use rdocx_oxml::shared::ST_Border;
16
17use crate::input::{ImageData, MediaRegistry};
18
19/// A resolved border edge: (thickness in pt, color, optional dash pattern as (dash, gap)).
20type BorderEdge = (f64, Color, Option<(f64, f64)>);
21
22/// Page geometry derived from section properties.
23#[derive(Debug, Clone, Copy)]
24pub struct PageGeometry {
25    pub page_width: f64,
26    pub page_height: f64,
27    pub margin_top: f64,
28    pub margin_right: f64,
29    pub margin_bottom: f64,
30    pub margin_left: f64,
31    pub header_distance: f64,
32    pub footer_distance: f64,
33}
34
35impl PageGeometry {
36    /// Content area width.
37    pub fn content_width(&self) -> f64 {
38        self.page_width - self.margin_left - self.margin_right
39    }
40
41    /// Content area height.
42    pub fn content_height(&self) -> f64 {
43        self.page_height - self.margin_top - self.margin_bottom
44    }
45}
46
47impl Default for PageGeometry {
48    fn default() -> Self {
49        // US Letter with 1" margins
50        PageGeometry {
51            page_width: 612.0,
52            page_height: 792.0,
53            margin_top: 72.0,
54            margin_right: 72.0,
55            margin_bottom: 72.0,
56            margin_left: 72.0,
57            header_distance: 36.0,
58            footer_distance: 36.0,
59        }
60    }
61}
62
63/// Header/footer content already laid out as paragraph blocks.
64pub struct HeaderFooterContent {
65    pub header_blocks: Vec<ParagraphBlock>,
66    pub footer_blocks: Vec<ParagraphBlock>,
67    /// First-page header blocks (used when title_pg is true).
68    pub first_header_blocks: Vec<ParagraphBlock>,
69    /// First-page footer blocks (used when title_pg is true).
70    pub first_footer_blocks: Vec<ParagraphBlock>,
71}
72
73/// A section with its blocks, geometry, and header/footer content.
74pub struct Section {
75    pub blocks: Vec<LayoutBlock>,
76    pub geometry: PageGeometry,
77    pub header_footer: Option<HeaderFooterContent>,
78    /// Whether this section uses a different first page header/footer.
79    pub title_pg: bool,
80}
81
82/// Paginate across multiple sections, each with its own geometry and header/footer.
83pub fn paginate_sections(
84    sections: &[Section],
85    fm: &FontManager,
86    media: &MediaRegistry,
87) -> (Vec<PageFrame>, Vec<OutlineEntry>) {
88    let media = media.media();
89    if sections.is_empty() {
90        return (
91            vec![PageFrame::new(1, 612.0, 792.0, Vec::new())],
92            Vec::new(),
93        );
94    }
95
96    // For a single section, delegate to the existing paginate function
97    if sections.len() == 1 {
98        let s = &sections[0];
99        return paginate_with_media(
100            &s.blocks,
101            s.geometry,
102            s.header_footer.as_ref(),
103            s.title_pg,
104            fm,
105            media,
106        );
107    }
108
109    // Multi-section pagination
110    let mut all_pages = Vec::new();
111    let mut all_outlines = Vec::new();
112    let mut page_offset = 0;
113
114    for section in sections {
115        let (mut pages, mut outlines) = paginate_with_media(
116            &section.blocks,
117            section.geometry,
118            section.header_footer.as_ref(),
119            section.title_pg,
120            fm,
121            media,
122        );
123
124        // Adjust page numbers and outline page indices
125        for page in &mut pages {
126            page.page_number += page_offset;
127        }
128        for outline in &mut outlines {
129            outline.page_index += page_offset;
130        }
131
132        page_offset += pages.len();
133        all_pages.append(&mut pages);
134        all_outlines.append(&mut outlines);
135    }
136
137    // If a section produced no pages (empty blocks), we might have duplicates
138    // Renumber pages sequentially
139    for (i, page) in all_pages.iter_mut().enumerate() {
140        page.page_number = i + 1;
141    }
142
143    (all_pages, all_outlines)
144}
145
146/// Paginate a sequence of blocks into pages.
147pub fn paginate(
148    blocks: &[LayoutBlock],
149    geometry: PageGeometry,
150    header_footer: Option<&HeaderFooterContent>,
151    title_pg: bool,
152    _fm: &FontManager,
153    media: &MediaRegistry,
154) -> (Vec<PageFrame>, Vec<OutlineEntry>) {
155    paginate_with_media(
156        blocks,
157        geometry,
158        header_footer,
159        title_pg,
160        _fm,
161        media.media(),
162    )
163}
164
165fn paginate_with_media(
166    blocks: &[LayoutBlock],
167    geometry: PageGeometry,
168    header_footer: Option<&HeaderFooterContent>,
169    title_pg: bool,
170    _fm: &FontManager,
171    media: &HashMap<MediaId, ImageData>,
172) -> (Vec<PageFrame>, Vec<OutlineEntry>) {
173    let mut pager = Pager::new(geometry, header_footer, title_pg, media);
174
175    for (block_idx, block) in blocks.iter().enumerate() {
176        // Check for page break before
177        if block.page_break_before() && pager.has_content() {
178            pager.finish_page();
179        }
180
181        match block {
182            LayoutBlock::Paragraph(para) => {
183                // Record heading outline entry before rendering
184                if let (Some(level), Some(title)) = (para.heading_level, &para.heading_text) {
185                    pager.outlines.push(OutlineEntry {
186                        title: title.clone(),
187                        level,
188                        page_index: pager.page_number - 1,
189                        y_position: pager.geometry.margin_top + pager.cursor_y,
190                    });
191                }
192                paginate_paragraph(para, block_idx, blocks, &mut pager);
193            }
194            LayoutBlock::Table(table) => {
195                let table_x = geometry.margin_left + table.table_indent;
196                let tbl_borders = table.borders.as_ref();
197
198                for (row_idx, row) in table.rows.iter().enumerate() {
199                    if pager.cursor_y + row.height > pager.content_height && pager.has_content() {
200                        pager.finish_page();
201
202                        // Repeat header rows
203                        for &hdr_idx in &table.header_row_indices {
204                            if hdr_idx < row_idx {
205                                let hdr_row = &table.rows[hdr_idx];
206                                render_table_row(
207                                    hdr_row,
208                                    &table.col_widths,
209                                    table_x,
210                                    pager.geometry.margin_top + pager.cursor_y,
211                                    &pager.geometry,
212                                    tbl_borders,
213                                    &mut pager.elements,
214                                    pager.media,
215                                );
216                                pager.cursor_y += hdr_row.height;
217                                pager.mark_content();
218                            }
219                        }
220                    }
221
222                    render_table_row(
223                        row,
224                        &table.col_widths,
225                        table_x,
226                        pager.geometry.margin_top + pager.cursor_y,
227                        &pager.geometry,
228                        tbl_borders,
229                        &mut pager.elements,
230                        pager.media,
231                    );
232                    pager.cursor_y += row.height;
233                    pager.mark_content();
234                }
235            }
236        }
237    }
238
239    pager.flush()
240}
241
242/// Helper struct to track page state during pagination.
243struct Pager<'a> {
244    pages: Vec<PageFrame>,
245    elements: Vec<PositionedElement>,
246    /// Anchored drawings marked behindDoc. Held apart from the normal element
247    /// list so they can be emitted before everything else on the page, which
248    /// is what puts them underneath the text.
249    behind_elements: Vec<PositionedElement>,
250    cursor_y: f64,
251    page_number: usize,
252    content_height: f64,
253    geometry: PageGeometry,
254    header_footer: Option<&'a HeaderFooterContent>,
255    has_content_flag: bool,
256    outlines: Vec<OutlineEntry>,
257    /// Whether the current page is the first page of the section.
258    is_first_page: bool,
259    /// Whether this section uses different first page header/footer.
260    title_pg: bool,
261    media: &'a HashMap<MediaId, ImageData>,
262}
263
264impl<'a> Pager<'a> {
265    fn new(
266        geometry: PageGeometry,
267        header_footer: Option<&'a HeaderFooterContent>,
268        title_pg: bool,
269        media: &'a HashMap<MediaId, ImageData>,
270    ) -> Self {
271        Pager {
272            pages: Vec::new(),
273            elements: Vec::new(),
274            behind_elements: Vec::new(),
275            cursor_y: 0.0,
276            page_number: 1,
277            content_height: geometry.content_height(),
278            geometry,
279            header_footer,
280            has_content_flag: false,
281            outlines: Vec::new(),
282            is_first_page: true,
283            title_pg,
284            media,
285        }
286    }
287
288    fn has_content(&self) -> bool {
289        self.has_content_flag
290    }
291
292    fn mark_content(&mut self) {
293        self.has_content_flag = true;
294    }
295
296    /// Place the drawings anchored to a paragraph whose top sits at `para_top`,
297    /// measured from the top of the content area.
298    fn place_anchored(&mut self, anchored: &[AnchoredDrawing], para_top: f64, indent_left: f64) {
299        for a in anchored {
300            let x = resolve_anchor_h(a.rel_h, a.off_h, &self.geometry, indent_left);
301            let y = resolve_anchor_v(a.rel_v, a.off_v, &self.geometry, para_top);
302            let rect = Rect {
303                x,
304                y,
305                width: a.width,
306                height: a.height,
307            };
308
309            let mut produced: Vec<PositionedElement> = Vec::new();
310
311            match &a.content {
312                AnchoredContent::Image { media_id } => {
313                    let image = self.media.get(media_id);
314                    produced.push(PositionedElement::Image {
315                        rect,
316                        data: image.map_or_else(Vec::new, |image| image.data.clone()),
317                        content_type: image
318                            .map_or_else(String::new, |image| image.content_type.clone()),
319                        media_id: *media_id,
320                    });
321                }
322                AnchoredContent::Shape { preset, fill, text } => {
323                    // A shape with no fill draws no body. That is not a gap:
324                    // Word uses unfilled rectangles as plain text boxes.
325                    match (preset, fill) {
326                        (ShapePreset::Rect, Some(color)) => {
327                            produced.push(PositionedElement::FilledRect {
328                                rect,
329                                color: *color,
330                            });
331                        }
332                        (ShapePreset::Line, Some(color)) => {
333                            // A line shape's extent describes its bounding box,
334                            // so the stroke runs corner to corner.
335                            produced.push(PositionedElement::Line {
336                                start: Point { x, y },
337                                end: Point {
338                                    x: x + a.width,
339                                    y: y + a.height,
340                                },
341                                width: 1.0,
342                                color: *color,
343                                dash_pattern: None,
344                            });
345                        }
346                        _ => {}
347                    }
348                    produced.extend(render_shape_text(text, &self.geometry, rect, self.media));
349                }
350            }
351
352            if a.behind_doc {
353                self.behind_elements.append(&mut produced);
354            } else {
355                self.elements.append(&mut produced);
356            }
357        }
358    }
359
360    fn finish_page(&mut self) {
361        let mut all_elements = Vec::new();
362
363        // behindDoc drawings render underneath everything else on the page.
364        all_elements.append(&mut self.behind_elements);
365
366        if let Some(hf) = self.header_footer {
367            // Choose header blocks: first-page or default
368            let header_blocks = if self.is_first_page && self.title_pg {
369                &hf.first_header_blocks
370            } else {
371                &hf.header_blocks
372            };
373            if !header_blocks.is_empty() {
374                let header_y = self.geometry.header_distance;
375                render_hf_blocks(
376                    header_blocks,
377                    &self.geometry,
378                    header_y,
379                    &mut all_elements,
380                    self.media,
381                );
382            }
383        }
384
385        all_elements.append(&mut self.elements);
386
387        if let Some(hf) = self.header_footer {
388            // Choose footer blocks: first-page or default
389            let footer_blocks = if self.is_first_page && self.title_pg {
390                &hf.first_footer_blocks
391            } else {
392                &hf.footer_blocks
393            };
394            if !footer_blocks.is_empty() {
395                let footer_height: f64 = footer_blocks.iter().map(|b| b.content_height()).sum();
396                let footer_y =
397                    self.geometry.page_height - self.geometry.footer_distance - footer_height;
398                render_hf_blocks(
399                    footer_blocks,
400                    &self.geometry,
401                    footer_y,
402                    &mut all_elements,
403                    self.media,
404                );
405            }
406        }
407
408        self.pages.push(PageFrame::new(
409            self.page_number,
410            self.geometry.page_width,
411            self.geometry.page_height,
412            all_elements,
413        ));
414        self.page_number += 1;
415        self.cursor_y = 0.0;
416        self.has_content_flag = false;
417        self.is_first_page = false;
418    }
419
420    fn flush(mut self) -> (Vec<PageFrame>, Vec<OutlineEntry>) {
421        // Always create at least one page
422        if self.has_content() || self.pages.is_empty() {
423            self.finish_page();
424        }
425        (self.pages, self.outlines)
426    }
427}
428
429/// Paginate a single paragraph, handling splitting across pages.
430/// Shift a positioned element by a fixed offset.
431///
432/// Paragraph rendering always lays out against the page margins, so a text box
433/// is rendered at the margin first and then moved to where the shape sits.
434fn translate_element(element: &mut PositionedElement, dx: f64, dy: f64) {
435    match element {
436        PositionedElement::Text(run) => {
437            run.origin.x += dx;
438            run.origin.y += dy;
439        }
440        PositionedElement::Line { start, end, .. } => {
441            start.x += dx;
442            start.y += dy;
443            end.x += dx;
444            end.y += dy;
445        }
446        PositionedElement::FilledRect { rect, .. }
447        | PositionedElement::Image { rect, .. }
448        | PositionedElement::LinkAnnotation { rect, .. } => {
449            rect.x += dx;
450            rect.y += dy;
451        }
452        _ => {}
453    }
454}
455
456/// Render a shape's text box inside `rect`.
457///
458/// The paragraphs arrive already laid out at the shape's width. They are
459/// rendered as if they sat at the left margin and then translated onto the
460/// shape, which keeps all the justification and indent handling in one place.
461fn render_shape_text(
462    text: &[ParagraphBlock],
463    geometry: &PageGeometry,
464    rect: Rect,
465    media: &HashMap<MediaId, ImageData>,
466) -> Vec<PositionedElement> {
467    if text.is_empty() {
468        return Vec::new();
469    }
470
471    let mut local = Vec::new();
472    let mut y = 0.0;
473    for para in text {
474        render_paragraph_lines(&para.lines, para, geometry, y, &mut local, media);
475        y += para.content_height();
476    }
477
478    // render_paragraph_lines works in content-area coordinates, so undo the
479    // margin it applied and then move onto the shape.
480    let dx = rect.x - geometry.margin_left;
481    let dy = rect.y - geometry.margin_top;
482    for element in &mut local {
483        translate_element(element, dx, dy);
484    }
485    local
486}
487
488/// Resolve a horizontal anchor offset against the frame it is measured from.
489///
490/// An offset says nothing on its own. The same number lands somewhere
491/// different depending on the frame, and treating every offset as a page
492/// coordinate put anchored drawings in the corner of the sheet.
493fn resolve_anchor_h(rel: ST_RelativeFromH, off: f64, g: &PageGeometry, indent_left: f64) -> f64 {
494    match rel {
495        ST_RelativeFromH::Page | ST_RelativeFromH::LeftMargin => off,
496        ST_RelativeFromH::RightMargin | ST_RelativeFromH::OutsideMargin => {
497            g.page_width - g.margin_right + off
498        }
499        ST_RelativeFromH::InsideMargin => g.margin_left + off,
500        // A character-relative offset starts where the text does on the line.
501        ST_RelativeFromH::Character => g.margin_left + indent_left + off,
502        // Margin and column both start at the left edge of the text area.
503        // Multiple columns are not laid out yet, so the two coincide.
504        ST_RelativeFromH::Margin | ST_RelativeFromH::Column => g.margin_left + off,
505    }
506}
507
508/// Resolve a vertical anchor offset against the frame it is measured from.
509///
510/// `para_top` is the top of the anchoring paragraph, measured from the top of
511/// the content area.
512fn resolve_anchor_v(rel: ST_RelativeFromV, off: f64, g: &PageGeometry, para_top: f64) -> f64 {
513    match rel {
514        ST_RelativeFromV::Page | ST_RelativeFromV::TopMargin => off,
515        ST_RelativeFromV::BottomMargin | ST_RelativeFromV::OutsideMargin => {
516            g.page_height - g.margin_bottom + off
517        }
518        ST_RelativeFromV::Margin | ST_RelativeFromV::InsideMargin => g.margin_top + off,
519        // Paragraph and line are both relative to where this paragraph landed.
520        // Per-line anchoring would need the line box, which is finer than we
521        // track here, so the paragraph top stands in for both.
522        ST_RelativeFromV::Paragraph | ST_RelativeFromV::Line => g.margin_top + para_top + off,
523    }
524}
525
526fn paginate_paragraph(
527    para: &ParagraphBlock,
528    block_idx: usize,
529    blocks: &[LayoutBlock],
530    pager: &mut Pager,
531) {
532    let space_before = if pager.cursor_y == 0.0 {
533        0.0
534    } else {
535        para.space_before
536    };
537
538    // Check if paragraph fits on current page
539    let total_needed = space_before + para.content_height();
540    let remaining = pager.content_height - pager.cursor_y;
541
542    if total_needed > remaining && pager.has_content() {
543        // Paragraph doesn't fit. Decide: move whole or split.
544        if para.keep_lines || para.lines.len() <= 2 {
545            pager.finish_page();
546            // Re-call with fresh page
547            paginate_paragraph(para, block_idx, blocks, pager);
548            return;
549        }
550
551        let available_for_lines = remaining - space_before;
552        let lines_that_fit = count_lines_that_fit(&para.lines, available_for_lines);
553
554        if para.widow_control && lines_that_fit < 2 {
555            // Can't fit enough lines — move whole paragraph
556            pager.finish_page();
557            paginate_paragraph(para, block_idx, blocks, pager);
558            return;
559        }
560
561        let lines_remaining = para.lines.len() - lines_that_fit;
562        if para.widow_control && lines_remaining < 2 && lines_that_fit >= 3 {
563            // Would leave orphan — move one line to next page
564            let split_at = lines_that_fit - 1;
565            render_para_split(para, split_at, space_before, pager);
566            return;
567        }
568
569        if lines_that_fit > 0 {
570            render_para_split(para, lines_that_fit, space_before, pager);
571            return;
572        }
573
574        // No lines fit (shouldn't happen since we checked has_content above)
575        pager.finish_page();
576        paginate_paragraph(para, block_idx, blocks, pager);
577        return;
578    }
579
580    // Paragraph fits OR we're at the top of a page
581    // If it doesn't fit and we're at the top, we must split line by line
582    if total_needed > pager.content_height && pager.cursor_y == 0.0 {
583        // Paragraph is taller than a page; split line by line
584        let lines_that_fit = count_lines_that_fit(&para.lines, pager.content_height);
585        if lines_that_fit > 0 && lines_that_fit < para.lines.len() {
586            render_para_split(para, lines_that_fit, 0.0, pager);
587            return;
588        }
589    }
590
591    // Check keep-with-next
592    if para.keep_next && block_idx + 1 < blocks.len() {
593        let next_first = match &blocks[block_idx + 1] {
594            LayoutBlock::Paragraph(p) => p.lines.first().map(|l| l.height).unwrap_or(0.0),
595            LayoutBlock::Table(t) => t.rows.first().map(|r| r.height).unwrap_or(0.0),
596        };
597        if pager.cursor_y + space_before + para.content_height() + next_first > pager.content_height
598            && pager.has_content()
599        {
600            pager.finish_page();
601        }
602    }
603
604    // Render the paragraph
605    let space = if pager.cursor_y == 0.0 {
606        0.0
607    } else {
608        para.space_before
609    };
610    pager.cursor_y += space;
611
612    if let Some(shading) = para.shading {
613        pager.elements.push(PositionedElement::FilledRect {
614            rect: Rect {
615                x: pager.geometry.margin_left + para.indent_left,
616                y: pager.geometry.margin_top + pager.cursor_y,
617                width: pager.geometry.content_width() - para.indent_left - para.indent_right,
618                height: para.content_height(),
619            },
620            color: shading,
621        });
622    }
623
624    // Render paragraph borders
625    if let Some(ref borders) = para.borders {
626        let border_x = pager.geometry.margin_left + para.indent_left;
627        let border_y = pager.geometry.margin_top + pager.cursor_y;
628        let border_w = pager.geometry.content_width() - para.indent_left - para.indent_right;
629        let border_h = para.content_height();
630        render_border_edges(
631            borders,
632            border_x,
633            border_y,
634            border_w,
635            border_h,
636            &mut pager.elements,
637        );
638    }
639
640    // Anchored drawings resolve against the paragraph's position, so place
641    // them now that the page and the cursor are settled.
642    pager.place_anchored(&para.anchored, pager.cursor_y, para.indent_left);
643
644    render_paragraph_lines(
645        &para.lines,
646        para,
647        &pager.geometry,
648        pager.cursor_y,
649        &mut pager.elements,
650        pager.media,
651    );
652    pager.cursor_y += para.content_height();
653    pager.cursor_y += para.space_after;
654    pager.mark_content();
655}
656
657/// Split a paragraph at the given line index, rendering first part on current page
658/// and continuing the rest on a new page (recursively if needed).
659fn render_para_split(para: &ParagraphBlock, split_at: usize, space_before: f64, pager: &mut Pager) {
660    // Render lines before split on current page
661    pager.cursor_y += space_before;
662    // A split paragraph anchors its drawings to where it starts.
663    pager.place_anchored(&para.anchored, pager.cursor_y, para.indent_left);
664    render_paragraph_lines(
665        &para.lines[..split_at],
666        para,
667        &pager.geometry,
668        pager.cursor_y,
669        &mut pager.elements,
670        pager.media,
671    );
672    pager.mark_content();
673    pager.finish_page();
674
675    // Handle remaining lines, which may themselves need splitting
676    let remaining_lines = &para.lines[split_at..];
677    let remaining_height: f64 = remaining_lines.iter().map(|l| l.height).sum();
678
679    if remaining_height > pager.content_height {
680        // Still too tall — split again
681        let lines_that_fit = count_lines_that_fit(remaining_lines, pager.content_height);
682        if lines_that_fit > 0 && lines_that_fit < remaining_lines.len() {
683            // Build a temporary para with remaining lines
684            let temp_para = ParagraphBlock {
685                // The anchors were placed with the first part of the
686                // paragraph, so the continuation must not place them again.
687                anchored: Vec::new(),
688                lines: remaining_lines.to_vec(),
689                space_before: 0.0,
690                space_after: para.space_after,
691                borders: para.borders.clone(),
692                shading: para.shading,
693                indent_left: para.indent_left,
694                indent_right: para.indent_right,
695                jc: para.jc,
696                keep_next: para.keep_next,
697                keep_lines: false,
698                page_break_before: false,
699                widow_control: para.widow_control,
700                heading_level: None,
701                heading_text: None,
702            };
703            render_para_split(&temp_para, lines_that_fit, 0.0, pager);
704            return;
705        }
706    }
707
708    // Remaining fits on the new page
709    render_paragraph_lines(
710        remaining_lines,
711        para,
712        &pager.geometry,
713        0.0,
714        &mut pager.elements,
715        pager.media,
716    );
717    pager.cursor_y = remaining_height + para.space_after;
718    pager.mark_content();
719}
720
721/// Count how many lines fit in the remaining space.
722fn count_lines_that_fit(lines: &[LayoutLine], available: f64) -> usize {
723    let mut used = 0.0;
724    for (i, line) in lines.iter().enumerate() {
725        used += line.height;
726        if used > available {
727            return i;
728        }
729    }
730    lines.len()
731}
732
733/// Render paragraph lines as positioned elements.
734fn render_paragraph_lines(
735    lines: &[LayoutLine],
736    para: &ParagraphBlock,
737    geometry: &PageGeometry,
738    start_y: f64,
739    elements: &mut Vec<PositionedElement>,
740    media: &HashMap<MediaId, ImageData>,
741) {
742    let mut y = start_y;
743    for line in lines {
744        let baseline_y = geometry.margin_top + y + line.ascent;
745
746        // Compute x offset based on justification
747        let text_width: f64 = line.items.iter().map(|item| item.width()).sum();
748        let remaining_width = line.available_width - text_width;
749
750        // For justified text (Both), compute extra space per gap
751        let justify_extra =
752            if para.jc == Some(Align::Justify) && !line.is_last && remaining_width > 0.0 {
753                // Count inter-word gaps: spaces between items + spaces within text segments
754                let gap_count = count_word_gaps(&line.items);
755                if gap_count > 0 {
756                    remaining_width / gap_count as f64
757                } else {
758                    0.0
759                }
760            } else {
761                0.0
762            };
763
764        let x_offset = match para.jc {
765            Some(Align::Center) => geometry.margin_left + line.indent_left + remaining_width / 2.0,
766            Some(Align::End) => geometry.margin_left + line.indent_left + remaining_width,
767            Some(Align::Justify) if !line.is_last && justify_extra > 0.0 => {
768                // Justified: start from left margin (extra space distributed in gaps)
769                geometry.margin_left + line.indent_left
770            }
771            _ => geometry.margin_left + line.indent_left,
772        };
773
774        let mut x = x_offset;
775        let mut _accumulated_extra = 0.0;
776
777        for item in &line.items {
778            match item {
779                LineItem::Text(seg) | LineItem::Marker(seg) => {
780                    let adjusted_baseline = baseline_y - seg.baseline_offset;
781
782                    // For justified text, compute the extra width from spaces in this segment
783                    let segment_spaces = if justify_extra > 0.0 {
784                        seg.text.chars().filter(|c| *c == ' ').count()
785                    } else {
786                        0
787                    };
788                    let segment_extra = segment_spaces as f64 * justify_extra;
789                    let effective_width = seg.width + segment_extra;
790
791                    // Render highlight background
792                    if let Some(hl_color) = seg.highlight {
793                        elements.push(PositionedElement::FilledRect {
794                            rect: Rect {
795                                x,
796                                y: geometry.margin_top + y,
797                                width: effective_width,
798                                height: line.height,
799                            },
800                            color: hl_color,
801                        });
802                    }
803
804                    // Render text, adjusting advances for justified text
805                    let advances = if justify_extra > 0.0 && segment_spaces > 0 {
806                        // Widen advances for space glyphs
807                        distribute_justify_advances(&seg.text, &seg.advances, justify_extra)
808                    } else {
809                        seg.advances.clone()
810                    };
811
812                    elements.push(PositionedElement::Text(GlyphRun {
813                        origin: Point {
814                            x,
815                            y: adjusted_baseline,
816                        },
817                        font_id: seg.font_id,
818                        font_size: seg.font_size,
819                        glyph_ids: seg.glyph_ids.clone(),
820                        advances,
821                        text: seg.text.clone(),
822                        color: seg.color,
823                        bold: seg.bold,
824                        italic: seg.italic,
825                        field_kind: seg.field_kind,
826                        footnote_id: seg.footnote_id,
827                    }));
828
829                    // Render underline
830                    if let Some(ul_style) = seg.underline {
831                        let ul_y = adjusted_baseline + seg.descent * 0.3;
832                        let ul_thickness = match ul_style {
833                            Underline::Thick => seg.font_size / 12.0,
834                            Underline::Double => seg.font_size / 24.0,
835                            _ => seg.font_size / 18.0,
836                        };
837                        elements.push(PositionedElement::Line {
838                            start: Point { x, y: ul_y },
839                            end: Point {
840                                x: x + effective_width,
841                                y: ul_y,
842                            },
843                            width: ul_thickness,
844                            color: seg.color,
845                            dash_pattern: None,
846                        });
847                        // Second line for double underline
848                        if ul_style == Underline::Double {
849                            let ul_y2 = ul_y + ul_thickness * 2.5;
850                            elements.push(PositionedElement::Line {
851                                start: Point { x, y: ul_y2 },
852                                end: Point {
853                                    x: x + effective_width,
854                                    y: ul_y2,
855                                },
856                                width: ul_thickness,
857                                color: seg.color,
858                                dash_pattern: None,
859                            });
860                        }
861                    }
862
863                    // Render strikethrough
864                    if seg.strike {
865                        let strike_y = adjusted_baseline - seg.ascent * 0.3;
866                        let strike_thickness = seg.font_size / 24.0;
867                        elements.push(PositionedElement::Line {
868                            start: Point { x, y: strike_y },
869                            end: Point {
870                                x: x + effective_width,
871                                y: strike_y,
872                            },
873                            width: strike_thickness,
874                            color: seg.color,
875                            dash_pattern: None,
876                        });
877                    }
878
879                    // Render double strikethrough
880                    if seg.dstrike {
881                        let strike_y = adjusted_baseline - seg.ascent * 0.3;
882                        let strike_thickness = seg.font_size / 24.0;
883                        let gap = strike_thickness * 2.0;
884                        elements.push(PositionedElement::Line {
885                            start: Point {
886                                x,
887                                y: strike_y - gap / 2.0,
888                            },
889                            end: Point {
890                                x: x + effective_width,
891                                y: strike_y - gap / 2.0,
892                            },
893                            width: strike_thickness,
894                            color: seg.color,
895                            dash_pattern: None,
896                        });
897                        elements.push(PositionedElement::Line {
898                            start: Point {
899                                x,
900                                y: strike_y + gap / 2.0,
901                            },
902                            end: Point {
903                                x: x + effective_width,
904                                y: strike_y + gap / 2.0,
905                            },
906                            width: strike_thickness,
907                            color: seg.color,
908                            dash_pattern: None,
909                        });
910                    }
911
912                    // Render hyperlink annotation
913                    if let Some(ref url) = seg.hyperlink_url {
914                        elements.push(PositionedElement::LinkAnnotation {
915                            rect: Rect {
916                                x,
917                                y: geometry.margin_top + y,
918                                width: effective_width,
919                                height: line.height,
920                            },
921                            url: url.clone(),
922                        });
923                    }
924
925                    _accumulated_extra += segment_extra;
926                    x += effective_width;
927                }
928                LineItem::Tab { width, leader } => {
929                    if let Some(leader_seg) = leader {
930                        // Render the pre-shaped leader text
931                        let baseline_y = geometry.margin_top + y + line.ascent;
932                        elements.push(PositionedElement::Text(GlyphRun {
933                            origin: Point { x, y: baseline_y },
934                            font_id: leader_seg.font_id,
935                            font_size: leader_seg.font_size,
936                            glyph_ids: leader_seg.glyph_ids.clone(),
937                            advances: leader_seg.advances.clone(),
938                            text: leader_seg.text.clone(),
939                            color: leader_seg.color,
940                            bold: leader_seg.bold,
941                            italic: leader_seg.italic,
942                            field_kind: None,
943                            footnote_id: None,
944                        }));
945                    }
946                    x += width;
947                }
948                LineItem::Image {
949                    width,
950                    height,
951                    media_id,
952                } => {
953                    let image = media.get(media_id);
954                    // Image positioned at current x, top-aligned with line
955                    elements.push(PositionedElement::Image {
956                        rect: Rect {
957                            x,
958                            y: geometry.margin_top + y,
959                            width: *width,
960                            height: *height,
961                        },
962                        data: image.map_or_else(Vec::new, |image| image.data.clone()),
963                        content_type: image
964                            .map_or_else(String::new, |image| image.content_type.clone()),
965                        media_id: *media_id,
966                    });
967                    x += width;
968                }
969            }
970        }
971
972        y += line.height;
973    }
974}
975
976/// Render header/footer blocks.
977fn render_hf_blocks(
978    blocks: &[ParagraphBlock],
979    geometry: &PageGeometry,
980    start_y: f64,
981    elements: &mut Vec<PositionedElement>,
982    media: &HashMap<MediaId, ImageData>,
983) {
984    let mut y = start_y - geometry.margin_top; // Convert to relative
985    for para in blocks {
986        render_paragraph_lines(&para.lines, para, geometry, y, elements, media);
987        y += para.content_height();
988    }
989}
990
991/// Render a table row.
992fn render_table_row(
993    row: &crate::table::TableRow,
994    _col_widths: &[f64],
995    table_x: f64,
996    row_y: f64,
997    geometry: &PageGeometry,
998    table_borders: Option<&rdocx_oxml::table::CT_TblBorders>,
999    elements: &mut Vec<PositionedElement>,
1000    media: &HashMap<MediaId, ImageData>,
1001) {
1002    let mut cell_x = table_x;
1003    let num_cells = row.cells.len();
1004
1005    for (cell_idx, cell) in row.cells.iter().enumerate() {
1006        // Render cell shading
1007        if let Some(ref shading) = cell.shading {
1008            elements.push(PositionedElement::FilledRect {
1009                rect: Rect {
1010                    x: cell_x,
1011                    y: row_y,
1012                    width: cell.width,
1013                    height: cell.height,
1014                },
1015                color: *shading,
1016            });
1017        }
1018
1019        // Render cell borders
1020        render_cell_borders(
1021            cell_x,
1022            row_y,
1023            cell.width,
1024            cell.height,
1025            &cell.borders,
1026            table_borders,
1027            cell_idx,
1028            num_cells,
1029            cell.is_first_row,
1030            cell.is_last_row,
1031            elements,
1032        );
1033
1034        if !cell.is_vmerge_continue {
1035            // Render cell content
1036            let cell_margin_top = cell.margin_top;
1037            let cell_margin_left = cell.margin_left;
1038
1039            // Compute vertical alignment offset
1040            let content_height: f64 = cell.paragraphs.iter().map(|p| p.total_height()).sum();
1041            let v_offset = match cell.v_align {
1042                Some(rdocx_oxml::table::ST_VerticalJc::Center) => {
1043                    ((cell.height - cell_margin_top - content_height) / 2.0).max(0.0)
1044                }
1045                Some(rdocx_oxml::table::ST_VerticalJc::Bottom) => {
1046                    (cell.height - cell_margin_top - content_height).max(0.0)
1047                }
1048                _ => 0.0, // Top or unspecified
1049            };
1050
1051            let mut para_y = row_y - geometry.margin_top + cell_margin_top + v_offset;
1052            for para in &cell.paragraphs {
1053                render_paragraph_lines(
1054                    &para.lines,
1055                    para,
1056                    &PageGeometry {
1057                        margin_left: cell_x + cell_margin_left,
1058                        ..*geometry
1059                    },
1060                    para_y,
1061                    elements,
1062                    media,
1063                );
1064                para_y += para.total_height();
1065            }
1066        }
1067        cell_x += cell.width;
1068    }
1069}
1070
1071/// Render borders for a table cell.
1072fn render_cell_borders(
1073    x: f64,
1074    y: f64,
1075    w: f64,
1076    h: f64,
1077    cell_borders: &Option<rdocx_oxml::table::CT_TblBorders>,
1078    table_borders: Option<&rdocx_oxml::table::CT_TblBorders>,
1079    cell_idx: usize,
1080    num_cells: usize,
1081    is_first_row: bool,
1082    is_last_row: bool,
1083    elements: &mut Vec<PositionedElement>,
1084) {
1085    // Determine effective border for each edge (cell overrides table)
1086    let get_edge = |cell_edge: Option<&rdocx_oxml::borders::CT_BorderEdge>,
1087                    table_edge: Option<&rdocx_oxml::borders::CT_BorderEdge>|
1088     -> Option<BorderEdge> {
1089        let edge = cell_edge.or(table_edge)?;
1090        if edge.val == ST_Border::None {
1091            return None;
1092        }
1093        let thickness = edge.sz.unwrap_or(4) as f64 / 8.0; // sz is in 1/8 pt
1094        let color = edge
1095            .color
1096            .as_ref()
1097            .filter(|c| c.as_str() != "auto")
1098            .map(|c| Color::from_hex(c))
1099            .unwrap_or(Color::BLACK);
1100        let dash = border_dash_pattern(edge.val, thickness);
1101        Some((thickness, color, dash))
1102    };
1103
1104    // Top border: use table top for first row, table insideH otherwise
1105    let table_top = table_borders.and_then(|b| {
1106        if is_first_row {
1107            b.top.as_ref()
1108        } else {
1109            b.inside_h.as_ref()
1110        }
1111    });
1112    let cell_top = cell_borders.as_ref().and_then(|b| b.top.as_ref());
1113    if let Some((thickness, color, dash_pattern)) = get_edge(cell_top, table_top) {
1114        elements.push(PositionedElement::Line {
1115            start: Point { x, y },
1116            end: Point { x: x + w, y },
1117            width: thickness,
1118            color,
1119            dash_pattern,
1120        });
1121    }
1122
1123    // Bottom border: use table bottom for last row, table insideH otherwise
1124    let table_bottom = table_borders.and_then(|b| {
1125        if is_last_row {
1126            b.bottom.as_ref()
1127        } else {
1128            b.inside_h.as_ref()
1129        }
1130    });
1131    let cell_bottom = cell_borders.as_ref().and_then(|b| b.bottom.as_ref());
1132    if let Some((thickness, color, dash_pattern)) = get_edge(cell_bottom, table_bottom) {
1133        elements.push(PositionedElement::Line {
1134            start: Point { x, y: y + h },
1135            end: Point { x: x + w, y: y + h },
1136            width: thickness,
1137            color,
1138            dash_pattern,
1139        });
1140    }
1141
1142    // Left border: use table left for first cell, table insideV otherwise
1143    let table_left = table_borders.and_then(|b| {
1144        if cell_idx == 0 {
1145            b.left.as_ref()
1146        } else {
1147            b.inside_v.as_ref()
1148        }
1149    });
1150    let cell_left = cell_borders.as_ref().and_then(|b| b.left.as_ref());
1151    if let Some((thickness, color, dash_pattern)) = get_edge(cell_left, table_left) {
1152        elements.push(PositionedElement::Line {
1153            start: Point { x, y },
1154            end: Point { x, y: y + h },
1155            width: thickness,
1156            color,
1157            dash_pattern,
1158        });
1159    }
1160
1161    // Right border: use table right for last cell, table insideV otherwise
1162    let table_right = table_borders.and_then(|b| {
1163        if cell_idx == num_cells - 1 {
1164            b.right.as_ref()
1165        } else {
1166            b.inside_v.as_ref()
1167        }
1168    });
1169    let cell_right = cell_borders.as_ref().and_then(|b| b.right.as_ref());
1170    if let Some((thickness, color, dash_pattern)) = get_edge(cell_right, table_right) {
1171        elements.push(PositionedElement::Line {
1172            start: Point { x: x + w, y },
1173            end: Point { x: x + w, y: y + h },
1174            width: thickness,
1175            color,
1176            dash_pattern,
1177        });
1178    }
1179}
1180
1181/// Render paragraph border edges as positioned lines.
1182fn render_border_edges(
1183    borders: &rdocx_oxml::borders::CT_PBdr,
1184    x: f64,
1185    y: f64,
1186    w: f64,
1187    h: f64,
1188    elements: &mut Vec<PositionedElement>,
1189) {
1190    let render_edge = |edge: &rdocx_oxml::borders::CT_BorderEdge,
1191                       start: Point,
1192                       end: Point,
1193                       elements: &mut Vec<PositionedElement>| {
1194        if edge.val == ST_Border::None {
1195            return;
1196        }
1197        let thickness = edge.sz.unwrap_or(4) as f64 / 8.0; // sz is in eighths of a point
1198        let color = edge
1199            .color
1200            .as_ref()
1201            .filter(|c| c.as_str() != "auto")
1202            .map(|c| Color::from_hex(c))
1203            .unwrap_or(Color::BLACK);
1204        let dash_pattern = border_dash_pattern(edge.val, thickness);
1205
1206        if edge.val == ST_Border::Double {
1207            // Double border: emit two parallel lines
1208            let gap = thickness * 2.0;
1209            let dx = end.x - start.x;
1210            let dy = end.y - start.y;
1211            let len = (dx * dx + dy * dy).sqrt();
1212            let (nx, ny) = if len > 0.0 {
1213                (-dy / len, dx / len)
1214            } else {
1215                (0.0, 1.0)
1216            };
1217            let offset = gap / 2.0;
1218            elements.push(PositionedElement::Line {
1219                start: Point {
1220                    x: start.x + nx * offset,
1221                    y: start.y + ny * offset,
1222                },
1223                end: Point {
1224                    x: end.x + nx * offset,
1225                    y: end.y + ny * offset,
1226                },
1227                width: thickness,
1228                color,
1229                dash_pattern: None,
1230            });
1231            elements.push(PositionedElement::Line {
1232                start: Point {
1233                    x: start.x - nx * offset,
1234                    y: start.y - ny * offset,
1235                },
1236                end: Point {
1237                    x: end.x - nx * offset,
1238                    y: end.y - ny * offset,
1239                },
1240                width: thickness,
1241                color,
1242                dash_pattern: None,
1243            });
1244        } else {
1245            elements.push(PositionedElement::Line {
1246                start,
1247                end,
1248                width: thickness,
1249                color,
1250                dash_pattern,
1251            });
1252        }
1253    };
1254
1255    if let Some(ref edge) = borders.top {
1256        let space = edge.space.unwrap_or(0) as f64;
1257        render_edge(
1258            edge,
1259            Point { x, y: y - space },
1260            Point {
1261                x: x + w,
1262                y: y - space,
1263            },
1264            elements,
1265        );
1266    }
1267    if let Some(ref edge) = borders.bottom {
1268        let space = edge.space.unwrap_or(0) as f64;
1269        render_edge(
1270            edge,
1271            Point {
1272                x,
1273                y: y + h + space,
1274            },
1275            Point {
1276                x: x + w,
1277                y: y + h + space,
1278            },
1279            elements,
1280        );
1281    }
1282    if let Some(ref edge) = borders.left {
1283        let space = edge.space.unwrap_or(0) as f64;
1284        render_edge(
1285            edge,
1286            Point { x: x - space, y },
1287            Point {
1288                x: x - space,
1289                y: y + h,
1290            },
1291            elements,
1292        );
1293    }
1294    if let Some(ref edge) = borders.right {
1295        let space = edge.space.unwrap_or(0) as f64;
1296        render_edge(
1297            edge,
1298            Point {
1299                x: x + w + space,
1300                y,
1301            },
1302            Point {
1303                x: x + w + space,
1304                y: y + h,
1305            },
1306            elements,
1307        );
1308    }
1309}
1310
1311/// Map a border style to a dash pattern (dash_on, dash_off) in points.
1312/// Returns None for solid lines (Single, Thick, Double, etc.).
1313fn border_dash_pattern(style: ST_Border, thickness: f64) -> Option<(f64, f64)> {
1314    match style {
1315        ST_Border::Dashed => Some((3.0 * thickness, 2.0 * thickness)),
1316        ST_Border::Dotted => Some((thickness, thickness)),
1317        ST_Border::DotDash | ST_Border::DotDotDash => Some((3.0 * thickness, thickness)),
1318        _ => None,
1319    }
1320}
1321
1322/// Count inter-word gap positions in a line (spaces within text segments).
1323fn count_word_gaps(items: &[LineItem]) -> usize {
1324    let mut count = 0;
1325    for item in items {
1326        match item {
1327            LineItem::Text(seg) | LineItem::Marker(seg) => {
1328                count += seg.text.chars().filter(|c| *c == ' ').count();
1329            }
1330            LineItem::Tab { .. } => {
1331                count += 1;
1332            }
1333            _ => {}
1334        }
1335    }
1336    count
1337}
1338
1339/// Distribute extra justify space across advances by widening space-character advances.
1340fn distribute_justify_advances(text: &str, advances: &[f64], extra_per_gap: f64) -> Vec<f64> {
1341    let chars: Vec<char> = text.chars().collect();
1342    let mut result = advances.to_vec();
1343
1344    if chars.len() == result.len() {
1345        // 1:1 char-to-glyph mapping
1346        for (i, &ch) in chars.iter().enumerate() {
1347            if ch == ' ' {
1348                result[i] += extra_per_gap;
1349            }
1350        }
1351    } else {
1352        // Fallback: distribute evenly across all glyphs
1353        let total_extra = extra_per_gap * text.chars().filter(|c| *c == ' ').count() as f64;
1354        if !result.is_empty() {
1355            let per_glyph = total_extra / result.len() as f64;
1356            for a in &mut result {
1357                *a += per_glyph;
1358            }
1359        }
1360    }
1361
1362    result
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367    use super::*;
1368    use crate::block::ParagraphBlock;
1369    use oxml_layout::LayoutLine;
1370
1371    fn empty_media() -> MediaRegistry {
1372        MediaRegistry::new(&HashMap::new())
1373    }
1374
1375    fn make_line(height: f64) -> LayoutLine {
1376        LayoutLine {
1377            items: vec![],
1378            width: 100.0,
1379            ascent: height * 0.77,
1380            descent: height * 0.23,
1381            line_gap: 0.0,
1382            height,
1383            indent_left: 0.0,
1384            available_width: 468.0,
1385            is_last: true,
1386        }
1387    }
1388
1389    fn make_para(line_count: usize, line_height: f64) -> ParagraphBlock {
1390        let mut lines = Vec::new();
1391        for _ in 0..line_count {
1392            lines.push(make_line(line_height));
1393        }
1394        ParagraphBlock {
1395            anchored: Vec::new(),
1396            lines,
1397            space_before: 0.0,
1398            space_after: 0.0,
1399            borders: None,
1400            shading: None,
1401            indent_left: 0.0,
1402            indent_right: 0.0,
1403            jc: None,
1404            keep_next: false,
1405            keep_lines: false,
1406            page_break_before: false,
1407            widow_control: true,
1408            heading_level: None,
1409            heading_text: None,
1410        }
1411    }
1412
1413    #[test]
1414    fn single_page_layout() {
1415        let fm = FontManager::new();
1416        let blocks = vec![LayoutBlock::Paragraph(make_para(3, 14.0))];
1417        let geom = PageGeometry::default();
1418        let (pages, _outlines) = paginate(&blocks, geom, None, false, &fm, &empty_media());
1419        assert_eq!(pages.len(), 1);
1420        assert_eq!(pages[0].page_number, 1);
1421    }
1422
1423    #[test]
1424    fn multi_page_overflow() {
1425        let fm = FontManager::new();
1426        // 648pt content height / 14pt per line ≈ 46 lines per page
1427        let blocks = vec![LayoutBlock::Paragraph(make_para(100, 14.0))];
1428        let geom = PageGeometry::default();
1429        let (pages, _outlines) = paginate(&blocks, geom, None, false, &fm, &empty_media());
1430        assert!(pages.len() >= 2);
1431    }
1432
1433    #[test]
1434    fn forced_page_break() {
1435        let fm = FontManager::new();
1436        let mut para2 = make_para(3, 14.0);
1437        para2.page_break_before = true;
1438        let blocks = vec![
1439            LayoutBlock::Paragraph(make_para(3, 14.0)),
1440            LayoutBlock::Paragraph(para2),
1441        ];
1442        let geom = PageGeometry::default();
1443        let (pages, _outlines) = paginate(&blocks, geom, None, false, &fm, &empty_media());
1444        assert_eq!(pages.len(), 2);
1445    }
1446
1447    #[test]
1448    fn page_dimensions() {
1449        let fm = FontManager::new();
1450        let blocks = vec![LayoutBlock::Paragraph(make_para(1, 14.0))];
1451        let geom = PageGeometry::default();
1452        let (pages, _outlines) = paginate(&blocks, geom, None, false, &fm, &empty_media());
1453        assert!((pages[0].width - 612.0).abs() < 0.01);
1454        assert!((pages[0].height - 792.0).abs() < 0.01);
1455    }
1456
1457    fn make_text_line(height: f64, underline: Option<Underline>, strike: bool) -> LayoutLine {
1458        use oxml_layout::TextSegment;
1459        let seg = TextSegment {
1460            text: "Hello".to_string(),
1461            font_id: oxml_layout::FontId(0),
1462            font_size: 12.0,
1463            glyph_ids: vec![1, 2, 3],
1464            advances: vec![6.0, 6.0, 6.0],
1465            width: 40.0,
1466            ascent: height * 0.77,
1467            descent: height * 0.23,
1468            line_gap: 0.0,
1469            color: Color::BLACK,
1470            bold: false,
1471            italic: false,
1472            underline,
1473            strike,
1474            dstrike: false,
1475            highlight: None,
1476            baseline_offset: 0.0,
1477            hyperlink_url: None,
1478            field_kind: None,
1479            footnote_id: None,
1480        };
1481        LayoutLine {
1482            items: vec![LineItem::Text(seg)],
1483            width: 40.0,
1484            ascent: height * 0.77,
1485            descent: height * 0.23,
1486            line_gap: 0.0,
1487            height,
1488            indent_left: 0.0,
1489            available_width: 468.0,
1490            is_last: true,
1491        }
1492    }
1493
1494    #[test]
1495    fn underline_renders_line_element() {
1496        let fm = FontManager::new();
1497        let para = ParagraphBlock {
1498            anchored: Vec::new(),
1499            lines: vec![make_text_line(14.0, Some(Underline::Single), false)],
1500            space_before: 0.0,
1501            space_after: 0.0,
1502            borders: None,
1503            shading: None,
1504            indent_left: 0.0,
1505            indent_right: 0.0,
1506            jc: None,
1507            keep_next: false,
1508            keep_lines: false,
1509            page_break_before: false,
1510            widow_control: true,
1511            heading_level: None,
1512            heading_text: None,
1513        };
1514        let blocks = vec![LayoutBlock::Paragraph(para)];
1515        let (pages, _outlines) = paginate(
1516            &blocks,
1517            PageGeometry::default(),
1518            None,
1519            false,
1520            &fm,
1521            &empty_media(),
1522        );
1523        // Should have Text + Line (underline)
1524        let lines: Vec<_> = pages[0]
1525            .elements
1526            .iter()
1527            .filter(|e| matches!(e, PositionedElement::Line { .. }))
1528            .collect();
1529        assert_eq!(lines.len(), 1, "expected 1 underline line");
1530    }
1531
1532    #[test]
1533    fn strikethrough_renders_line_element() {
1534        let fm = FontManager::new();
1535        let para = ParagraphBlock {
1536            anchored: Vec::new(),
1537            lines: vec![make_text_line(14.0, None, true)],
1538            space_before: 0.0,
1539            space_after: 0.0,
1540            borders: None,
1541            shading: None,
1542            indent_left: 0.0,
1543            indent_right: 0.0,
1544            jc: None,
1545            keep_next: false,
1546            keep_lines: false,
1547            page_break_before: false,
1548            widow_control: true,
1549            heading_level: None,
1550            heading_text: None,
1551        };
1552        let blocks = vec![LayoutBlock::Paragraph(para)];
1553        let (pages, _outlines) = paginate(
1554            &blocks,
1555            PageGeometry::default(),
1556            None,
1557            false,
1558            &fm,
1559            &empty_media(),
1560        );
1561        let lines: Vec<_> = pages[0]
1562            .elements
1563            .iter()
1564            .filter(|e| matches!(e, PositionedElement::Line { .. }))
1565            .collect();
1566        assert_eq!(lines.len(), 1, "expected 1 strikethrough line");
1567    }
1568
1569    #[test]
1570    fn highlight_renders_filled_rect() {
1571        use oxml_layout::TextSegment;
1572        let fm = FontManager::new();
1573        let seg = TextSegment {
1574            text: "Hi".to_string(),
1575            font_id: oxml_layout::FontId(0),
1576            font_size: 12.0,
1577            glyph_ids: vec![1],
1578            advances: vec![10.0],
1579            width: 20.0,
1580            ascent: 10.0,
1581            descent: 3.0,
1582            line_gap: 0.0,
1583            color: Color::BLACK,
1584            bold: false,
1585            italic: false,
1586            underline: None,
1587            strike: false,
1588            dstrike: false,
1589            highlight: Some(Color {
1590                r: 1.0,
1591                g: 1.0,
1592                b: 0.0,
1593                a: 1.0,
1594            }),
1595            baseline_offset: 0.0,
1596            hyperlink_url: None,
1597            field_kind: None,
1598            footnote_id: None,
1599        };
1600        let line = LayoutLine {
1601            items: vec![LineItem::Text(seg)],
1602            width: 20.0,
1603            ascent: 10.0,
1604            descent: 3.0,
1605            line_gap: 0.0,
1606            height: 13.0,
1607            indent_left: 0.0,
1608            available_width: 468.0,
1609            is_last: true,
1610        };
1611        let para = ParagraphBlock {
1612            anchored: Vec::new(),
1613            lines: vec![line],
1614            space_before: 0.0,
1615            space_after: 0.0,
1616            borders: None,
1617            shading: None,
1618            indent_left: 0.0,
1619            indent_right: 0.0,
1620            jc: None,
1621            keep_next: false,
1622            keep_lines: false,
1623            page_break_before: false,
1624            widow_control: true,
1625            heading_level: None,
1626            heading_text: None,
1627        };
1628        let blocks = vec![LayoutBlock::Paragraph(para)];
1629        let (pages, _outlines) = paginate(
1630            &blocks,
1631            PageGeometry::default(),
1632            None,
1633            false,
1634            &fm,
1635            &empty_media(),
1636        );
1637        let rects: Vec<_> = pages[0]
1638            .elements
1639            .iter()
1640            .filter(|e| matches!(e, PositionedElement::FilledRect { .. }))
1641            .collect();
1642        assert_eq!(rects.len(), 1, "expected 1 highlight rect");
1643    }
1644
1645    #[test]
1646    fn paragraph_borders_render_lines() {
1647        use rdocx_oxml::borders::{CT_BorderEdge, CT_PBdr};
1648        let fm = FontManager::new();
1649        let para = ParagraphBlock {
1650            anchored: Vec::new(),
1651            lines: vec![make_line(14.0)],
1652            space_before: 0.0,
1653            space_after: 0.0,
1654            borders: Some(CT_PBdr {
1655                top: Some(CT_BorderEdge {
1656                    val: ST_Border::Single,
1657                    sz: Some(4),
1658                    space: Some(1),
1659                    color: Some("000000".to_string()),
1660                }),
1661                bottom: Some(CT_BorderEdge {
1662                    val: ST_Border::Single,
1663                    sz: Some(4),
1664                    space: Some(1),
1665                    color: Some("000000".to_string()),
1666                }),
1667                ..Default::default()
1668            }),
1669            shading: None,
1670            indent_left: 0.0,
1671            indent_right: 0.0,
1672            jc: None,
1673            keep_next: false,
1674            keep_lines: false,
1675            page_break_before: false,
1676            widow_control: true,
1677            heading_level: None,
1678            heading_text: None,
1679        };
1680        let blocks = vec![LayoutBlock::Paragraph(para)];
1681        let (pages, _outlines) = paginate(
1682            &blocks,
1683            PageGeometry::default(),
1684            None,
1685            false,
1686            &fm,
1687            &empty_media(),
1688        );
1689        let lines: Vec<_> = pages[0]
1690            .elements
1691            .iter()
1692            .filter(|e| matches!(e, PositionedElement::Line { .. }))
1693            .collect();
1694        assert_eq!(lines.len(), 2, "expected 2 border lines (top + bottom)");
1695    }
1696
1697    #[test]
1698    fn paragraph_shading_renders_filled_rect() {
1699        let fm = FontManager::new();
1700        let para = ParagraphBlock {
1701            anchored: Vec::new(),
1702            lines: vec![make_line(14.0)],
1703            space_before: 0.0,
1704            space_after: 0.0,
1705            borders: None,
1706            shading: Some(Color {
1707                r: 1.0,
1708                g: 1.0,
1709                b: 0.0,
1710                a: 1.0,
1711            }),
1712            indent_left: 0.0,
1713            indent_right: 0.0,
1714            jc: None,
1715            keep_next: false,
1716            keep_lines: false,
1717            page_break_before: false,
1718            widow_control: true,
1719            heading_level: None,
1720            heading_text: None,
1721        };
1722        let blocks = vec![LayoutBlock::Paragraph(para)];
1723        let (pages, _outlines) = paginate(
1724            &blocks,
1725            PageGeometry::default(),
1726            None,
1727            false,
1728            &fm,
1729            &empty_media(),
1730        );
1731        let rects: Vec<_> = pages[0]
1732            .elements
1733            .iter()
1734            .filter(|e| matches!(e, PositionedElement::FilledRect { .. }))
1735            .collect();
1736        assert_eq!(rects.len(), 1, "expected 1 paragraph shading rect");
1737    }
1738
1739    #[test]
1740    fn double_underline_renders_two_lines() {
1741        let fm = FontManager::new();
1742        let para = ParagraphBlock {
1743            anchored: Vec::new(),
1744            lines: vec![make_text_line(14.0, Some(Underline::Double), false)],
1745            space_before: 0.0,
1746            space_after: 0.0,
1747            borders: None,
1748            shading: None,
1749            indent_left: 0.0,
1750            indent_right: 0.0,
1751            jc: None,
1752            keep_next: false,
1753            keep_lines: false,
1754            page_break_before: false,
1755            widow_control: true,
1756            heading_level: None,
1757            heading_text: None,
1758        };
1759        let blocks = vec![LayoutBlock::Paragraph(para)];
1760        let (pages, _outlines) = paginate(
1761            &blocks,
1762            PageGeometry::default(),
1763            None,
1764            false,
1765            &fm,
1766            &empty_media(),
1767        );
1768        let lines: Vec<_> = pages[0]
1769            .elements
1770            .iter()
1771            .filter(|e| matches!(e, PositionedElement::Line { .. }))
1772            .collect();
1773        assert_eq!(lines.len(), 2, "expected 2 lines for double underline");
1774    }
1775
1776    fn make_justified_line(text: &str, seg_width: f64, is_last: bool) -> LayoutLine {
1777        use oxml_layout::TextSegment;
1778        let seg = TextSegment {
1779            text: text.to_string(),
1780            font_id: oxml_layout::FontId(0),
1781            font_size: 12.0,
1782            glyph_ids: vec![1; text.len()],
1783            advances: vec![seg_width / text.len() as f64; text.len()],
1784            width: seg_width,
1785            ascent: 10.0,
1786            descent: 3.0,
1787            line_gap: 0.0,
1788            color: Color::BLACK,
1789            bold: false,
1790            italic: false,
1791            underline: None,
1792            strike: false,
1793            dstrike: false,
1794            highlight: None,
1795            baseline_offset: 0.0,
1796            hyperlink_url: None,
1797            field_kind: None,
1798            footnote_id: None,
1799        };
1800        LayoutLine {
1801            items: vec![LineItem::Text(seg)],
1802            width: seg_width,
1803            ascent: 10.0,
1804            descent: 3.0,
1805            line_gap: 0.0,
1806            height: 13.0,
1807            indent_left: 0.0,
1808            available_width: 468.0,
1809            is_last,
1810        }
1811    }
1812
1813    #[test]
1814    fn hyperlink_emits_link_annotation() {
1815        use oxml_layout::TextSegment;
1816        let fm = FontManager::new();
1817        let seg = TextSegment {
1818            text: "Click me".to_string(),
1819            font_id: oxml_layout::FontId(0),
1820            font_size: 12.0,
1821            glyph_ids: vec![1, 2, 3],
1822            advances: vec![8.0, 8.0, 8.0],
1823            width: 60.0,
1824            ascent: 10.0,
1825            descent: 3.0,
1826            line_gap: 0.0,
1827            color: Color::BLACK,
1828            bold: false,
1829            italic: false,
1830            underline: None,
1831            strike: false,
1832            dstrike: false,
1833            highlight: None,
1834            baseline_offset: 0.0,
1835            hyperlink_url: Some("https://example.com".to_string()),
1836            field_kind: None,
1837            footnote_id: None,
1838        };
1839        let line = LayoutLine {
1840            items: vec![LineItem::Text(seg)],
1841            width: 60.0,
1842            ascent: 10.0,
1843            descent: 3.0,
1844            line_gap: 0.0,
1845            height: 13.0,
1846            indent_left: 0.0,
1847            available_width: 468.0,
1848            is_last: true,
1849        };
1850        let para = ParagraphBlock {
1851            anchored: Vec::new(),
1852            lines: vec![line],
1853            space_before: 0.0,
1854            space_after: 0.0,
1855            borders: None,
1856            shading: None,
1857            indent_left: 0.0,
1858            indent_right: 0.0,
1859            jc: None,
1860            keep_next: false,
1861            keep_lines: false,
1862            page_break_before: false,
1863            widow_control: true,
1864            heading_level: None,
1865            heading_text: None,
1866        };
1867        let blocks = vec![LayoutBlock::Paragraph(para)];
1868        let (pages, _outlines) = paginate(
1869            &blocks,
1870            PageGeometry::default(),
1871            None,
1872            false,
1873            &fm,
1874            &empty_media(),
1875        );
1876        let annotations: Vec<_> = pages[0]
1877            .elements
1878            .iter()
1879            .filter(|e| matches!(e, PositionedElement::LinkAnnotation { .. }))
1880            .collect();
1881        assert_eq!(annotations.len(), 1, "expected 1 link annotation");
1882        if let PositionedElement::LinkAnnotation { url, .. } = annotations[0] {
1883            assert_eq!(url, "https://example.com");
1884        }
1885    }
1886
1887    #[test]
1888    fn justified_text_fills_line_width() {
1889        let fm = FontManager::new();
1890        // Line with "Hello World" (1 space = 1 gap), width 200 out of 468 available
1891        let para = ParagraphBlock {
1892            anchored: Vec::new(),
1893            lines: vec![
1894                make_justified_line("Hello World", 200.0, false),
1895                make_justified_line("End.", 40.0, true),
1896            ],
1897            space_before: 0.0,
1898            space_after: 0.0,
1899            borders: None,
1900            shading: None,
1901            indent_left: 0.0,
1902            indent_right: 0.0,
1903            jc: Some(Align::Justify),
1904            keep_next: false,
1905            keep_lines: false,
1906            page_break_before: false,
1907            widow_control: true,
1908            heading_level: None,
1909            heading_text: None,
1910        };
1911
1912        let blocks = vec![LayoutBlock::Paragraph(para)];
1913        let (pages, _outlines) = paginate(
1914            &blocks,
1915            PageGeometry::default(),
1916            None,
1917            false,
1918            &fm,
1919            &empty_media(),
1920        );
1921
1922        // The first line's text run should have widened advances
1923        let first_text = pages[0].elements.iter().find_map(|e| {
1924            if let PositionedElement::Text(run) = e {
1925                Some(run)
1926            } else {
1927                None
1928            }
1929        });
1930        assert!(first_text.is_some());
1931        let run = first_text.unwrap();
1932        // The total advance should be wider than the original 200pt
1933        let total_advance: f64 = run.advances.iter().sum();
1934        assert!(
1935            total_advance > 200.0,
1936            "justified text should be wider than original: {total_advance}"
1937        );
1938    }
1939
1940    #[test]
1941    fn justified_last_line_stays_left_aligned() {
1942        let fm = FontManager::new();
1943        let para = ParagraphBlock {
1944            anchored: Vec::new(),
1945            lines: vec![
1946                make_justified_line("Hello World Test", 200.0, false),
1947                make_justified_line("End.", 40.0, true),
1948            ],
1949            space_before: 0.0,
1950            space_after: 0.0,
1951            borders: None,
1952            shading: None,
1953            indent_left: 0.0,
1954            indent_right: 0.0,
1955            jc: Some(Align::Justify),
1956            keep_next: false,
1957            keep_lines: false,
1958            page_break_before: false,
1959            widow_control: true,
1960            heading_level: None,
1961            heading_text: None,
1962        };
1963
1964        let blocks = vec![LayoutBlock::Paragraph(para)];
1965        let (pages, _outlines) = paginate(
1966            &blocks,
1967            PageGeometry::default(),
1968            None,
1969            false,
1970            &fm,
1971            &empty_media(),
1972        );
1973
1974        // Find the second text run (last line)
1975        let text_runs: Vec<_> = pages[0]
1976            .elements
1977            .iter()
1978            .filter_map(|e| {
1979                if let PositionedElement::Text(run) = e {
1980                    Some(run)
1981                } else {
1982                    None
1983                }
1984            })
1985            .collect();
1986
1987        assert!(text_runs.len() >= 2);
1988        // Last line should NOT be stretched — advances should sum to original width
1989        let last_advance: f64 = text_runs[1].advances.iter().sum();
1990        assert!(
1991            (last_advance - 40.0).abs() < 0.1,
1992            "last line should stay at original width: {last_advance}"
1993        );
1994    }
1995
1996    #[test]
1997    fn justified_single_word_not_stretched() {
1998        let fm = FontManager::new();
1999        // A line with a single word (no spaces) should not be stretched
2000        let para = ParagraphBlock {
2001            anchored: Vec::new(),
2002            lines: vec![
2003                make_justified_line("Superlongword", 100.0, false),
2004                make_justified_line("End.", 40.0, true),
2005            ],
2006            space_before: 0.0,
2007            space_after: 0.0,
2008            borders: None,
2009            shading: None,
2010            indent_left: 0.0,
2011            indent_right: 0.0,
2012            jc: Some(Align::Justify),
2013            keep_next: false,
2014            keep_lines: false,
2015            page_break_before: false,
2016            widow_control: true,
2017            heading_level: None,
2018            heading_text: None,
2019        };
2020
2021        let blocks = vec![LayoutBlock::Paragraph(para)];
2022        let (pages, _outlines) = paginate(
2023            &blocks,
2024            PageGeometry::default(),
2025            None,
2026            false,
2027            &fm,
2028            &empty_media(),
2029        );
2030
2031        let first_text = pages[0].elements.iter().find_map(|e| {
2032            if let PositionedElement::Text(run) = e {
2033                Some(run)
2034            } else {
2035                None
2036            }
2037        });
2038        assert!(first_text.is_some());
2039        let run = first_text.unwrap();
2040        let total_advance: f64 = run.advances.iter().sum();
2041        // No spaces → no stretching
2042        assert!(
2043            (total_advance - 100.0).abs() < 0.1,
2044            "single word should not be stretched: {total_advance}"
2045        );
2046    }
2047
2048    /// A wp:anchor offset means nothing without the frame it is measured from.
2049    /// Treating every offset as a page coordinate put anchored drawings in the
2050    /// corner of the sheet instead of beside their paragraph.
2051    #[test]
2052    fn anchor_offsets_resolve_against_their_frame() {
2053        let g = PageGeometry::default(); // 612 x 792, 72pt margins
2054        let para_top = 100.0;
2055        let off = 10.0;
2056
2057        assert_eq!(resolve_anchor_h(ST_RelativeFromH::Page, off, &g, 0.0), 10.0);
2058        assert_eq!(
2059            resolve_anchor_h(ST_RelativeFromH::LeftMargin, off, &g, 0.0),
2060            10.0
2061        );
2062        assert_eq!(
2063            resolve_anchor_h(ST_RelativeFromH::Margin, off, &g, 0.0),
2064            82.0,
2065            "margin-relative starts at the left margin"
2066        );
2067        assert_eq!(
2068            resolve_anchor_h(ST_RelativeFromH::Column, off, &g, 0.0),
2069            82.0,
2070            "column-relative starts at the text area"
2071        );
2072        assert_eq!(
2073            resolve_anchor_h(ST_RelativeFromH::RightMargin, off, &g, 0.0),
2074            550.0,
2075            "right-margin-relative starts at the right margin edge"
2076        );
2077        assert_eq!(
2078            resolve_anchor_h(ST_RelativeFromH::Character, off, &g, 36.0),
2079            118.0,
2080            "character-relative includes the paragraph indent"
2081        );
2082
2083        assert_eq!(
2084            resolve_anchor_v(ST_RelativeFromV::Page, off, &g, para_top),
2085            10.0
2086        );
2087        assert_eq!(
2088            resolve_anchor_v(ST_RelativeFromV::TopMargin, off, &g, para_top),
2089            10.0
2090        );
2091        assert_eq!(
2092            resolve_anchor_v(ST_RelativeFromV::Margin, off, &g, para_top),
2093            82.0
2094        );
2095        assert_eq!(
2096            resolve_anchor_v(ST_RelativeFromV::Paragraph, off, &g, para_top),
2097            182.0,
2098            "paragraph-relative follows the paragraph down the page"
2099        );
2100        assert_eq!(
2101            resolve_anchor_v(ST_RelativeFromV::Line, off, &g, para_top),
2102            182.0
2103        );
2104        assert_eq!(
2105            resolve_anchor_v(ST_RelativeFromV::BottomMargin, off, &g, para_top),
2106            730.0
2107        );
2108    }
2109
2110    /// The same offset must land somewhere different once the paragraph moves.
2111    /// This is the property the old code could not express at all.
2112    #[test]
2113    fn paragraph_relative_anchor_tracks_the_paragraph() {
2114        let g = PageGeometry::default();
2115        let near_top = resolve_anchor_v(ST_RelativeFromV::Paragraph, 5.0, &g, 0.0);
2116        let further_down = resolve_anchor_v(ST_RelativeFromV::Paragraph, 5.0, &g, 300.0);
2117        assert_eq!(near_top, 77.0);
2118        assert_eq!(further_down, 377.0);
2119        assert!(further_down > near_top);
2120    }
2121}