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