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