Skip to main content

rdocx_layout/
paginator.rs

1//! Pagination: distribute blocks across pages with constraints.
2//!
3//! Handles page breaks, widow/orphan control, keep-with-next,
4//! keep-lines-together, and header/footer placement.
5
6use crate::block::{AnchoredContent, AnchoredDrawing, LayoutBlock, ParagraphBlock, ShapePreset};
7use std::collections::HashMap;
8
9use oxml_layout::{
10    Align, Color, FontManager, GlyphRun, LayoutLine, LineItem, MediaId, NoteRef, NoteStream,
11    OutlineEntry, PageFrame, Point, PositionedElement, Rect, Underline, break_into_lines,
12};
13
14use rdocx_oxml::drawing::{
15    AnchorAlignH, AnchorAlignV, ST_RelativeFromH, ST_RelativeFromV, WrapType,
16};
17use rdocx_oxml::shared::ST_Border;
18
19use crate::input::{ImageData, MediaRegistry};
20use crate::notes::{
21    NOTE_INDENT, NOTE_SEPARATOR_OFFSET, NoteLayout, NoteRegistry, SEPARATOR_WIDTH_FRACTION,
22};
23
24/// A wrapping drawing that has been placed on the page being built.
25#[derive(Debug, Clone, Copy)]
26struct PlacedWrap {
27    rect: Rect,
28    wrap: WrapType,
29    dist_top: f64,
30    dist_bottom: f64,
31    dist_left: f64,
32    dist_right: f64,
33}
34
35impl PlacedWrap {
36    /// Top of the band this drawing keeps text out of.
37    fn keep_out_top(&self) -> f64 {
38        self.rect.y - self.dist_top
39    }
40
41    /// Bottom of the band this drawing keeps text out of.
42    fn keep_out_bottom(&self) -> f64 {
43        self.rect.y + self.rect.height + self.dist_bottom
44    }
45}
46
47/// A resolved border edge: (thickness in pt, color, optional dash pattern as (dash, gap)).
48type BorderEdge = (f64, Color, Option<(f64, f64)>);
49
50/// Page geometry derived from section properties.
51#[derive(Debug, Clone, Copy)]
52pub struct PageGeometry {
53    pub page_width: f64,
54    pub page_height: f64,
55    pub margin_top: f64,
56    pub margin_right: f64,
57    pub margin_bottom: f64,
58    pub margin_left: f64,
59    pub header_distance: f64,
60    pub footer_distance: f64,
61}
62
63impl PageGeometry {
64    /// Content area width.
65    pub fn content_width(&self) -> f64 {
66        self.page_width - self.margin_left - self.margin_right
67    }
68
69    /// Content area height.
70    pub fn content_height(&self) -> f64 {
71        self.page_height - self.margin_top - self.margin_bottom
72    }
73}
74
75impl Default for PageGeometry {
76    fn default() -> Self {
77        // US Letter with 1" margins
78        PageGeometry {
79            page_width: 612.0,
80            page_height: 792.0,
81            margin_top: 72.0,
82            margin_right: 72.0,
83            margin_bottom: 72.0,
84            margin_left: 72.0,
85            header_distance: 36.0,
86            footer_distance: 36.0,
87        }
88    }
89}
90
91/// Header/footer content already laid out as paragraph blocks.
92pub struct HeaderFooterContent {
93    pub header_blocks: Vec<ParagraphBlock>,
94    pub footer_blocks: Vec<ParagraphBlock>,
95    /// First-page header blocks (used when title_pg is true).
96    pub first_header_blocks: Vec<ParagraphBlock>,
97    /// First-page footer blocks (used when title_pg is true).
98    pub first_footer_blocks: Vec<ParagraphBlock>,
99}
100
101/// A section with its blocks, geometry, and header/footer content.
102pub struct Section {
103    pub blocks: Vec<LayoutBlock>,
104    pub geometry: PageGeometry,
105    pub header_footer: Option<HeaderFooterContent>,
106    /// Whether this section uses a different first page header/footer.
107    pub title_pg: bool,
108}
109
110/// Paginate across multiple sections, each with its own geometry and header/footer.
111pub fn paginate_sections(
112    sections: &[Section],
113    fm: &FontManager,
114    media: &MediaRegistry,
115    notes: &NoteRegistry,
116) -> (Vec<PageFrame>, Vec<OutlineEntry>) {
117    let media = media.media();
118    if sections.is_empty() {
119        return (
120            vec![PageFrame::new(1, 612.0, 792.0, Vec::new())],
121            Vec::new(),
122        );
123    }
124
125    // For a single section, delegate to the existing paginate function
126    if sections.len() == 1 {
127        let s = &sections[0];
128        return paginate_with_media(
129            &s.blocks,
130            s.geometry,
131            s.header_footer.as_ref(),
132            s.title_pg,
133            fm,
134            media,
135            notes,
136        );
137    }
138
139    // Multi-section pagination
140    let mut all_pages = Vec::new();
141    let mut all_outlines = Vec::new();
142    let mut page_offset = 0;
143
144    for section in sections {
145        let (mut pages, mut outlines) = paginate_with_media(
146            &section.blocks,
147            section.geometry,
148            section.header_footer.as_ref(),
149            section.title_pg,
150            fm,
151            media,
152            notes,
153        );
154
155        // Adjust page numbers and outline page indices
156        for page in &mut pages {
157            page.page_number += page_offset;
158        }
159        for outline in &mut outlines {
160            outline.page_index += page_offset;
161        }
162
163        page_offset += pages.len();
164        all_pages.append(&mut pages);
165        all_outlines.append(&mut outlines);
166    }
167
168    // If a section produced no pages (empty blocks), we might have duplicates
169    // Renumber pages sequentially
170    for (i, page) in all_pages.iter_mut().enumerate() {
171        page.page_number = i + 1;
172    }
173
174    (all_pages, all_outlines)
175}
176
177/// Paginate a sequence of blocks into pages.
178pub fn paginate(
179    blocks: &[LayoutBlock],
180    geometry: PageGeometry,
181    header_footer: Option<&HeaderFooterContent>,
182    title_pg: bool,
183    _fm: &FontManager,
184    media: &MediaRegistry,
185    notes: &NoteRegistry,
186) -> (Vec<PageFrame>, Vec<OutlineEntry>) {
187    paginate_with_media(
188        blocks,
189        geometry,
190        header_footer,
191        title_pg,
192        _fm,
193        media.media(),
194        notes,
195    )
196}
197
198fn paginate_with_media(
199    blocks: &[LayoutBlock],
200    geometry: PageGeometry,
201    header_footer: Option<&HeaderFooterContent>,
202    title_pg: bool,
203    _fm: &FontManager,
204    media: &HashMap<MediaId, ImageData>,
205    notes: &NoteRegistry,
206) -> (Vec<PageFrame>, Vec<OutlineEntry>) {
207    let mut pager = Pager::new(geometry, header_footer, title_pg, media, notes, _fm);
208
209    for (block_idx, block) in blocks.iter().enumerate() {
210        // Check for page break before
211        if block.page_break_before() && pager.has_content() {
212            pager.finish_page();
213        }
214
215        match block {
216            LayoutBlock::Paragraph(para) => {
217                // Record heading outline entry before rendering
218                if let (Some(level), Some(title)) = (para.heading_level, &para.heading_text) {
219                    pager.outlines.push(OutlineEntry {
220                        title: title.clone(),
221                        level,
222                        page_index: pager.page_number - 1,
223                        y_position: pager.geometry.margin_top + pager.cursor_y,
224                    });
225                }
226                paginate_paragraph(para, block_idx, blocks, &mut pager);
227            }
228            LayoutBlock::Table(table) => {
229                let table_x = geometry.margin_left + table.table_indent;
230                let tbl_borders = table.borders.as_ref();
231
232                for (row_idx, row) in table.rows.iter().enumerate() {
233                    if pager.cursor_y + row.height > pager.available_height() && pager.has_content()
234                    {
235                        pager.finish_page();
236
237                        // Repeat header rows
238                        for &hdr_idx in &table.header_row_indices {
239                            if hdr_idx < row_idx {
240                                let hdr_row = &table.rows[hdr_idx];
241                                render_table_row(
242                                    hdr_row,
243                                    &table.col_widths,
244                                    table_x,
245                                    pager.geometry.margin_top + pager.cursor_y,
246                                    &pager.geometry,
247                                    tbl_borders,
248                                    &mut pager.elements,
249                                    pager.media,
250                                );
251                                pager.cursor_y += hdr_row.height;
252                                pager.mark_content();
253                            }
254                        }
255                    }
256
257                    render_table_row(
258                        row,
259                        &table.col_widths,
260                        table_x,
261                        pager.geometry.margin_top + pager.cursor_y,
262                        &pager.geometry,
263                        tbl_borders,
264                        &mut pager.elements,
265                        pager.media,
266                    );
267                    pager.cursor_y += row.height;
268                    pager.mark_content();
269                }
270            }
271        }
272    }
273
274    pager.flush()
275}
276
277/// Helper struct to track page state during pagination.
278struct Pager<'a> {
279    pages: Vec<PageFrame>,
280    elements: Vec<PositionedElement>,
281    /// Anchored drawings marked behindDoc. Held apart from the normal element
282    /// list so they can be emitted before everything else on the page, which
283    /// is what puts them underneath the text.
284    behind_elements: Vec<PositionedElement>,
285    cursor_y: f64,
286    page_number: usize,
287    content_height: f64,
288    geometry: PageGeometry,
289    header_footer: Option<&'a HeaderFooterContent>,
290    has_content_flag: bool,
291    outlines: Vec<OutlineEntry>,
292    /// Whether the current page is the first page of the section.
293    is_first_page: bool,
294    /// Whether this section uses different first page header/footer.
295    title_pg: bool,
296    media: &'a HashMap<MediaId, ImageData>,
297    /// Every note the document defines, laid out once before pagination.
298    notes: &'a NoteRegistry,
299    /// Notes first referenced by a line placed on the page being built, in
300    /// reference order. Line counts are decided when the page is finished,
301    /// since that is when the leftover height is known.
302    page_note_ids: Vec<NoteRef>,
303    /// Note content that did not fit on the previous page, as (id, next line).
304    /// Placed before this page's own notes, and drawn without a marker.
305    pending_notes: Vec<(NoteRef, usize)>,
306    /// Re-breaking a paragraph around a drawing needs the shaper.
307    fm: &'a FontManager,
308    /// Rectangles of the wrapping drawings already placed on this page, with
309    /// the wrap mode and text distances each one asks for.
310    page_wraps: Vec<PlacedWrap>,
311    /// Where the body's last mark sits, ignoring trailing paragraph spacing.
312    ///
313    /// `cursor_y` includes the space after the final paragraph, and that space
314    /// collapses at a page break. Measuring the note area from `cursor_y`
315    /// would let it eat into the height that was reserved, which is enough to
316    /// push a note off the page its own reference sits on.
317    ink_bottom: f64,
318}
319
320impl<'a> Pager<'a> {
321    fn new(
322        geometry: PageGeometry,
323        header_footer: Option<&'a HeaderFooterContent>,
324        title_pg: bool,
325        media: &'a HashMap<MediaId, ImageData>,
326        notes: &'a NoteRegistry,
327        fm: &'a FontManager,
328    ) -> Self {
329        Pager {
330            pages: Vec::new(),
331            elements: Vec::new(),
332            behind_elements: Vec::new(),
333            cursor_y: 0.0,
334            page_number: 1,
335            content_height: geometry.content_height(),
336            geometry,
337            header_footer,
338            has_content_flag: false,
339            outlines: Vec::new(),
340            is_first_page: true,
341            title_pg,
342            media,
343            notes,
344            page_note_ids: Vec::new(),
345            pending_notes: Vec::new(),
346            fm,
347            page_wraps: Vec::new(),
348            ink_bottom: 0.0,
349        }
350    }
351
352    fn has_content(&self) -> bool {
353        self.has_content_flag
354    }
355
356    /// Height the note area needs for a given set of notes, in full.
357    ///
358    /// Zero when there are none, so a page without notes keeps every point of
359    /// its content height.
360    fn reserve_for(&self, carried: &[(NoteRef, usize)], fresh: &[NoteRef]) -> f64 {
361        if carried.is_empty() && fresh.is_empty() {
362            return 0.0;
363        }
364        let carried_height: f64 = carried
365            .iter()
366            .filter_map(|(id, first)| self.notes.get(*id).map(|note| note.height_from(*first)))
367            .sum();
368        let fresh_height: f64 = fresh
369            .iter()
370            .filter_map(|id| self.notes.get(*id).map(NoteLayout::height))
371            .sum();
372        NOTE_SEPARATOR_OFFSET + carried_height + fresh_height
373    }
374
375    /// The note area currently committed for the page being built.
376    fn reserved_height(&self) -> f64 {
377        self.reserve_for(&self.pending_notes, &self.page_note_ids)
378    }
379
380    /// Content height still usable by body text on this page.
381    fn available_height(&self) -> f64 {
382        (self.content_height - self.reserved_height()).max(0.0)
383    }
384
385    /// What the note area would cost if `lines` were placed on this page,
386    /// without committing to placing them.
387    ///
388    /// A paragraph is measured before anyone knows which page it lands on, so
389    /// its notes must be priced without being claimed. Claiming first and
390    /// moving the paragraph afterwards leaves the note stranded on the page
391    /// before its own reference.
392    fn available_height_for(&self, lines: &[LayoutLine]) -> f64 {
393        let mut fresh = self.page_note_ids.clone();
394        for line in lines {
395            for id in page_foot_notes_in_line(line) {
396                if self.notes.get(id).is_some()
397                    && !fresh.contains(&id)
398                    && !self.pending_notes.iter().any(|(pending, _)| *pending == id)
399                {
400                    fresh.push(id);
401                }
402            }
403        }
404        (self.content_height - self.reserve_for(&self.pending_notes, &fresh)).max(0.0)
405    }
406
407    /// Record the footnotes referenced by lines about to be placed.
408    ///
409    /// Endnotes are ignored here. They are emitted at the document end, so
410    /// they cost the page carrying their reference nothing.
411    fn claim_notes(&mut self, lines: &[LayoutLine]) {
412        for id in lines.iter().flat_map(page_foot_notes_in_line) {
413            {
414                if self.notes.get(id).is_some()
415                    && !self.page_note_ids.contains(&id)
416                    && !self.pending_notes.iter().any(|(pending, _)| *pending == id)
417                {
418                    self.page_note_ids.push(id);
419                }
420            }
421        }
422    }
423
424    /// How many of `lines` fit, once the note area their references demand is
425    /// taken out of the page.
426    ///
427    /// A line is admitted only if the whole note area still fits after it, so
428    /// a note is not split merely because body text was greedy. The one
429    /// exception is a page that has placed nothing yet: there the line goes
430    /// down regardless and the note splits, because a page that admits neither
431    /// body nor note makes no progress and pagination would not terminate.
432    fn count_lines_that_fit_with_notes(&self, lines: &[LayoutLine], start_y: f64) -> usize {
433        let mut fresh = self.page_note_ids.clone();
434        let mut used = 0.0;
435
436        for (index, line) in lines.iter().enumerate() {
437            for id in page_foot_notes_in_line(line) {
438                if self.notes.get(id).is_some()
439                    && !fresh.contains(&id)
440                    && !self.pending_notes.iter().any(|(pending, _)| *pending == id)
441                {
442                    fresh.push(id);
443                }
444            }
445
446            let reserve = self.reserve_for(&self.pending_notes, &fresh);
447            if start_y + used + line.height > self.content_height - reserve + 0.01 {
448                let page_is_empty = !self.has_content() && used == 0.0 && index == 0;
449                if !page_is_empty {
450                    return index;
451                }
452            }
453            used += line.height;
454        }
455
456        lines.len()
457    }
458
459    fn mark_content(&mut self) {
460        self.has_content_flag = true;
461    }
462
463    /// Resolve the wrapping drawings a paragraph carries, without placing
464    /// them. Measuring a paragraph needs to know what it must flow around
465    /// before anything is committed to the page.
466    fn wrap_rects_for(
467        &self,
468        anchored: &[AnchoredDrawing],
469        para_top: f64,
470        indent_left: f64,
471    ) -> Vec<PlacedWrap> {
472        anchored
473            .iter()
474            .filter(|a| a.wrap != WrapType::None)
475            .map(|a| PlacedWrap {
476                rect: Rect {
477                    x: resolve_anchor_h(
478                        a.rel_h,
479                        a.off_h,
480                        a.align_h,
481                        a.width,
482                        &self.geometry,
483                        indent_left,
484                    ),
485                    y: resolve_anchor_v(
486                        a.rel_v,
487                        a.off_v,
488                        a.align_v,
489                        a.height,
490                        &self.geometry,
491                        para_top,
492                    ),
493                    width: a.width,
494                    height: a.height,
495                },
496                wrap: a.wrap,
497                dist_top: a.dist_top,
498                dist_bottom: a.dist_bottom,
499                dist_left: a.dist_left,
500                dist_right: a.dist_right,
501            })
502            .collect()
503    }
504
505    /// Wrapping drawings anchored to blocks after `block_idx` whose position
506    /// does not depend on where their own paragraph lands.
507    ///
508    /// A drawing anchored to a later paragraph still pushes earlier text aside,
509    /// and Word documents do this routinely: the arrow beside a paragraph is
510    /// often anchored to the paragraph after it. Looking ahead is only sound
511    /// when the drawing's vertical frame is the page or a margin, because then
512    /// its position is known without paginating the block that owns it. A
513    /// paragraph-relative anchor genuinely needs its own paragraph placed
514    /// first, so those are left to the pass that places them.
515    fn lookahead_wraps(&self, block_idx: usize, blocks: &[LayoutBlock]) -> Vec<PlacedWrap> {
516        let mut out = Vec::new();
517        let mut height = self.cursor_y;
518
519        for block in blocks.iter().skip(block_idx + 1) {
520            if block.page_break_before() || height > self.content_height {
521                break;
522            }
523            height += block.space_before() + block.content_height() + block.space_after();
524
525            let LayoutBlock::Paragraph(para) = block else {
526                continue;
527            };
528            let absolute = para.anchored.iter().filter(|a| {
529                a.wrap != WrapType::None
530                    && !matches!(
531                        a.rel_v,
532                        ST_RelativeFromV::Paragraph | ST_RelativeFromV::Line
533                    )
534            });
535            for a in absolute {
536                out.extend(self.wrap_rects_for(std::slice::from_ref(a), 0.0, para.indent_left));
537            }
538        }
539
540        out
541    }
542
543    /// Place the drawings anchored to a paragraph whose top sits at `para_top`,
544    /// measured from the top of the content area.
545    fn place_anchored(&mut self, anchored: &[AnchoredDrawing], para_top: f64, indent_left: f64) {
546        for a in anchored {
547            let x = resolve_anchor_h(
548                a.rel_h,
549                a.off_h,
550                a.align_h,
551                a.width,
552                &self.geometry,
553                indent_left,
554            );
555            let y = resolve_anchor_v(
556                a.rel_v,
557                a.off_v,
558                a.align_v,
559                a.height,
560                &self.geometry,
561                para_top,
562            );
563            let rect = Rect {
564                x,
565                y,
566                width: a.width,
567                height: a.height,
568            };
569
570            if a.wrap != WrapType::None {
571                self.page_wraps.push(PlacedWrap {
572                    rect,
573                    wrap: a.wrap,
574                    dist_top: a.dist_top,
575                    dist_bottom: a.dist_bottom,
576                    dist_left: a.dist_left,
577                    dist_right: a.dist_right,
578                });
579            }
580
581            let mut produced: Vec<PositionedElement> = Vec::new();
582
583            match &a.content {
584                AnchoredContent::Image { media_id } => {
585                    let image = self.media.get(media_id);
586                    produced.push(PositionedElement::Image {
587                        rect,
588                        data: image.map_or_else(Vec::new, |image| image.data.clone()),
589                        content_type: image
590                            .map_or_else(String::new, |image| image.content_type.clone()),
591                        media_id: *media_id,
592                    });
593                }
594                AnchoredContent::Shape { preset, fill, text } => {
595                    // A shape with no fill draws no body. That is not a gap:
596                    // Word uses unfilled rectangles as plain text boxes.
597                    match (preset, fill) {
598                        (ShapePreset::Rect, Some(color)) => {
599                            produced.push(PositionedElement::FilledRect {
600                                rect,
601                                color: *color,
602                            });
603                        }
604                        (ShapePreset::Line, Some(color)) => {
605                            // A line shape's extent describes its bounding box,
606                            // so the stroke runs corner to corner.
607                            produced.push(PositionedElement::Line {
608                                start: Point { x, y },
609                                end: Point {
610                                    x: x + a.width,
611                                    y: y + a.height,
612                                },
613                                width: 1.0,
614                                color: *color,
615                                dash_pattern: None,
616                            });
617                        }
618                        _ => {}
619                    }
620                    produced.extend(render_shape_text(text, &self.geometry, rect, self.media));
621                }
622            }
623
624            if a.behind_doc {
625                self.behind_elements.append(&mut produced);
626            } else {
627                self.elements.append(&mut produced);
628            }
629        }
630    }
631
632    /// Draw the note area for the page being built, and carry what did not
633    /// fit onto the next one.
634    ///
635    /// Notes sit above the bottom margin and grow upward, so the body text
636    /// above them was already kept clear by `available_height`.
637    fn place_page_notes(&mut self) {
638        let mut queue: Vec<(NoteRef, usize, bool)> = self
639            .pending_notes
640            .drain(..)
641            .map(|(id, first)| (id, first, true))
642            .collect();
643        queue.extend(self.page_note_ids.drain(..).map(|id| (id, 0usize, false)));
644
645        if queue.is_empty() {
646            return;
647        }
648
649        let opens_with_continuation = queue[0].2;
650        let available = (self.content_height - self.ink_bottom - NOTE_SEPARATOR_OFFSET).max(0.0);
651
652        // Decide how much of each note this page can hold.
653        let mut placed: Vec<(NoteRef, usize, usize, bool)> = Vec::new();
654        let mut used = 0.0;
655        let mut carried: Vec<(NoteRef, usize)> = Vec::new();
656
657        for (id, first, continued) in queue {
658            let Some(note) = self.notes.get(id) else {
659                continue;
660            };
661            if !carried.is_empty() {
662                // An earlier note already ran out of room, so everything
663                // after it waits too, or the notes would be reordered.
664                carried.push((id, first));
665                continue;
666            }
667
668            let mut count = 0;
669            for line in note.lines.iter().skip(first) {
670                if used + line.height > available + 0.01 {
671                    break;
672                }
673                used += line.height;
674                count += 1;
675            }
676
677            if count > 0 {
678                placed.push((id, first, count, continued));
679            }
680            if first + count < note.lines.len() {
681                carried.push((id, first + count));
682            }
683        }
684
685        self.pending_notes = carried;
686
687        if placed.is_empty() {
688            return;
689        }
690
691        let total: f64 = placed
692            .iter()
693            .filter_map(|(id, first, count, _)| {
694                self.notes.get(*id).map(|n| n.height_of(*first, *count))
695            })
696            .sum();
697
698        let separator_y =
699            self.geometry.page_height - self.geometry.margin_bottom - total - NOTE_SEPARATOR_OFFSET;
700
701        // A page opening with carried content gets the full-width rule, which
702        // is how Word says "this continues from the previous page". A document
703        // that never defined one keeps the short rule.
704        let separator_width = if opens_with_continuation && self.notes.has_continuation_separator()
705        {
706            self.geometry.content_width()
707        } else {
708            self.geometry.content_width() * SEPARATOR_WIDTH_FRACTION
709        };
710
711        self.elements.push(PositionedElement::Line {
712            start: Point {
713                x: self.geometry.margin_left,
714                y: separator_y,
715            },
716            end: Point {
717                x: self.geometry.margin_left + separator_width,
718                y: separator_y,
719            },
720            width: 0.5,
721            color: Color::BLACK,
722            dash_pattern: None,
723        });
724
725        let mut cursor_y = separator_y + NOTE_SEPARATOR_OFFSET;
726        for (id, first, count, continued) in placed {
727            let Some(note) = self.notes.get(id) else {
728                continue;
729            };
730            cursor_y += draw_note(
731                &mut self.elements,
732                &self.geometry,
733                note,
734                first,
735                count,
736                continued,
737                cursor_y,
738            );
739        }
740    }
741
742    fn finish_page(&mut self) {
743        self.place_page_notes();
744        let mut all_elements = Vec::new();
745
746        // behindDoc drawings render underneath everything else on the page.
747        all_elements.append(&mut self.behind_elements);
748
749        if let Some(hf) = self.header_footer {
750            // Choose header blocks: first-page or default
751            let header_blocks = if self.is_first_page && self.title_pg {
752                &hf.first_header_blocks
753            } else {
754                &hf.header_blocks
755            };
756            if !header_blocks.is_empty() {
757                let header_y = self.geometry.header_distance;
758                render_hf_blocks(
759                    header_blocks,
760                    &self.geometry,
761                    header_y,
762                    &mut all_elements,
763                    self.media,
764                );
765            }
766        }
767
768        all_elements.append(&mut self.elements);
769
770        if let Some(hf) = self.header_footer {
771            // Choose footer blocks: first-page or default
772            let footer_blocks = if self.is_first_page && self.title_pg {
773                &hf.first_footer_blocks
774            } else {
775                &hf.footer_blocks
776            };
777            if !footer_blocks.is_empty() {
778                let footer_height: f64 = footer_blocks.iter().map(|b| b.content_height()).sum();
779                let footer_y =
780                    self.geometry.page_height - self.geometry.footer_distance - footer_height;
781                render_hf_blocks(
782                    footer_blocks,
783                    &self.geometry,
784                    footer_y,
785                    &mut all_elements,
786                    self.media,
787                );
788            }
789        }
790
791        self.pages.push(PageFrame::new(
792            self.page_number,
793            self.geometry.page_width,
794            self.geometry.page_height,
795            all_elements,
796        ));
797        self.page_number += 1;
798        self.cursor_y = 0.0;
799        self.page_wraps.clear();
800        self.ink_bottom = 0.0;
801        self.has_content_flag = false;
802        self.is_first_page = false;
803    }
804
805    fn flush(mut self) -> (Vec<PageFrame>, Vec<OutlineEntry>) {
806        // Always create at least one page
807        if self.has_content() || self.pages.is_empty() {
808            self.finish_page();
809        }
810        // A note that ran past the last page of body text still has to land
811        // somewhere, so keep making pages until the queue drains. Each page
812        // places at least one note line, so this terminates.
813        while !self.pending_notes.is_empty() {
814            let before = self.pending_notes.clone();
815            self.finish_page();
816            if self.pending_notes == before {
817                // Every page places at least one note line, so this is
818                // unreachable. It exists so a future change that breaks that
819                // guarantee stops rather than spins, and the assertion makes
820                // it loud in tests instead of silently losing note text.
821                debug_assert!(
822                    false,
823                    "a page placed no note content, dropping {:?}",
824                    self.pending_notes
825                );
826                break;
827            }
828        }
829        (self.pages, self.outlines)
830    }
831}
832
833/// Draw one note, or one slice of one, with its top edge at `top`.
834///
835/// Returns the height consumed. Shared by the page foot and the document end
836/// so the two regions cannot drift apart in how a note looks.
837fn draw_note(
838    elements: &mut Vec<PositionedElement>,
839    geometry: &PageGeometry,
840    note: &NoteLayout,
841    first: usize,
842    count: usize,
843    continued: bool,
844    top: f64,
845) -> f64 {
846    let baseline = top + note.lines.get(first).map_or(0.0, |line| line.ascent);
847
848    // A continuation does not repeat the marker.
849    if !continued {
850        elements.push(PositionedElement::Text(GlyphRun {
851            origin: Point {
852                x: geometry.margin_left,
853                y: baseline - note.marker_rise,
854            },
855            font_id: note.marker.font_id,
856            font_size: note.marker.font_size,
857            glyph_ids: note.marker.glyph_ids.clone(),
858            advances: note.marker.advances.clone(),
859            text: note.marker.text.clone(),
860            color: note.marker.color,
861            bold: note.marker.bold,
862            italic: note.marker.italic,
863            field_kind: None,
864            note: None,
865        }));
866    }
867
868    let mut cursor_y = top;
869    for line in note.lines.iter().skip(first).take(count) {
870        let line_baseline = cursor_y + line.ascent;
871        let mut x = geometry.margin_left + NOTE_INDENT;
872        for item in &line.items {
873            let (segment, advance) = match item {
874                LineItem::Text(seg) | LineItem::Marker(seg) => (Some(seg), seg.width),
875                LineItem::Tab { width, .. } | LineItem::Image { width, .. } => (None, *width),
876            };
877            if let Some(seg) = segment {
878                elements.push(PositionedElement::Text(GlyphRun {
879                    origin: Point {
880                        x,
881                        y: line_baseline - seg.baseline_offset,
882                    },
883                    font_id: seg.font_id,
884                    font_size: seg.font_size,
885                    glyph_ids: seg.glyph_ids.clone(),
886                    advances: seg.advances.clone(),
887                    text: seg.text.clone(),
888                    color: seg.color,
889                    bold: seg.bold,
890                    italic: seg.italic,
891                    field_kind: None,
892                    note: None,
893                }));
894            }
895            x += advance;
896        }
897        cursor_y += line.height;
898    }
899
900    cursor_y - top
901}
902
903/// Append the document's endnotes as pages after the last body page.
904///
905/// Endnotes are flow content read at the end, not marginalia, so they start at
906/// the top of a fresh page and carry no separator rule. There is no body text
907/// on these pages for a rule to divide them from.
908pub fn append_endnote_pages(
909    pages: &mut Vec<PageFrame>,
910    notes: &NoteRegistry,
911    geometry: PageGeometry,
912) {
913    // First-reference order across the document, which is the order a reader
914    // met them in.
915    let mut ordered: Vec<NoteRef> = Vec::new();
916    for page in pages.iter() {
917        for element in &page.elements {
918            if let PositionedElement::Text(run) = element
919                && let Some(note) = run.note
920                && note.stream == NoteStream::Endnote
921                && notes.get(note).is_some()
922                && !ordered.contains(&note)
923            {
924                ordered.push(note);
925            }
926        }
927    }
928
929    if ordered.is_empty() {
930        return;
931    }
932
933    let content_height = geometry.content_height();
934    let mut elements: Vec<PositionedElement> = Vec::new();
935    let mut cursor_y = 0.0;
936    let mut page_number = pages.len() + 1;
937
938    let mut flush = |elements: &mut Vec<PositionedElement>, page_number: &mut usize| {
939        pages.push(PageFrame::new(
940            *page_number,
941            geometry.page_width,
942            geometry.page_height,
943            std::mem::take(elements),
944        ));
945        *page_number += 1;
946    };
947
948    for note_ref in ordered {
949        let Some(note) = notes.get(note_ref) else {
950            continue;
951        };
952
953        let mut first = 0;
954        let mut continued = false;
955        while first < note.lines.len() {
956            let mut count = 0;
957            let mut used = cursor_y;
958            for line in note.lines.iter().skip(first) {
959                if used + line.height > content_height + 0.01 {
960                    break;
961                }
962                used += line.height;
963                count += 1;
964            }
965
966            if count == 0 {
967                // Nothing more fits on this page. Start a fresh one, unless
968                // the page is already empty. An empty page that still cannot
969                // take a line means the line is taller than the page, so it is
970                // placed and allowed to overflow rather than looping forever.
971                // Overflowing beats dropping the text, and body text on a page
972                // of its own behaves the same way.
973                if cursor_y == 0.0 {
974                    count = 1;
975                } else {
976                    flush(&mut elements, &mut page_number);
977                    cursor_y = 0.0;
978                    continue;
979                }
980            }
981
982            cursor_y += draw_note(
983                &mut elements,
984                &geometry,
985                note,
986                first,
987                count,
988                continued,
989                geometry.margin_top + cursor_y,
990            );
991            first += count;
992            continued = true;
993        }
994    }
995
996    if !elements.is_empty() {
997        flush(&mut elements, &mut page_number);
998    }
999}
1000
1001/// The notes referenced by the segments on one line.
1002fn notes_in_line(line: &LayoutLine) -> impl Iterator<Item = NoteRef> + '_ {
1003    line.items.iter().filter_map(|item| match item {
1004        LineItem::Text(seg) | LineItem::Marker(seg) => seg.note,
1005        _ => None,
1006    })
1007}
1008
1009/// The notes on one line that belong at the foot of its page.
1010///
1011/// Endnotes are excluded. They are emitted at the document end and take no
1012/// height from the page their reference sits on.
1013fn page_foot_notes_in_line(line: &LayoutLine) -> impl Iterator<Item = NoteRef> + '_ {
1014    notes_in_line(line).filter(|note| note.stream == NoteStream::Footnote)
1015}
1016
1017/// Paginate a single paragraph, handling splitting across pages.
1018/// Shift a positioned element by a fixed offset.
1019///
1020/// Paragraph rendering always lays out against the page margins, so a text box
1021/// is rendered at the margin first and then moved to where the shape sits.
1022fn translate_element(element: &mut PositionedElement, dx: f64, dy: f64) {
1023    match element {
1024        PositionedElement::Text(run) => {
1025            run.origin.x += dx;
1026            run.origin.y += dy;
1027        }
1028        PositionedElement::Line { start, end, .. } => {
1029            start.x += dx;
1030            start.y += dy;
1031            end.x += dx;
1032            end.y += dy;
1033        }
1034        PositionedElement::FilledRect { rect, .. }
1035        | PositionedElement::Image { rect, .. }
1036        | PositionedElement::LinkAnnotation { rect, .. } => {
1037            rect.x += dx;
1038            rect.y += dy;
1039        }
1040        _ => {}
1041    }
1042}
1043
1044/// Render a shape's text box inside `rect`.
1045///
1046/// The paragraphs arrive already laid out at the shape's width. They are
1047/// rendered as if they sat at the left margin and then translated onto the
1048/// shape, which keeps all the justification and indent handling in one place.
1049fn render_shape_text(
1050    text: &[ParagraphBlock],
1051    geometry: &PageGeometry,
1052    rect: Rect,
1053    media: &HashMap<MediaId, ImageData>,
1054) -> Vec<PositionedElement> {
1055    if text.is_empty() {
1056        return Vec::new();
1057    }
1058
1059    let mut local = Vec::new();
1060    let mut y = 0.0;
1061    for para in text {
1062        render_paragraph_lines(&para.lines, para, geometry, y, &mut local, media);
1063        y += para.content_height();
1064    }
1065
1066    // render_paragraph_lines works in content-area coordinates, so undo the
1067    // margin it applied and then move onto the shape.
1068    let dx = rect.x - geometry.margin_left;
1069    let dy = rect.y - geometry.margin_top;
1070    for element in &mut local {
1071        translate_element(element, dx, dy);
1072    }
1073    local
1074}
1075
1076/// Resolve a horizontal anchor offset against the frame it is measured from.
1077///
1078/// An offset says nothing on its own. The same number lands somewhere
1079/// different depending on the frame, and treating every offset as a page
1080/// coordinate put anchored drawings in the corner of the sheet.
1081fn frame_h(rel: ST_RelativeFromH, g: &PageGeometry, indent_left: f64) -> (f64, f64) {
1082    let text_width = g.page_width - g.margin_left - g.margin_right;
1083    match rel {
1084        ST_RelativeFromH::Page | ST_RelativeFromH::LeftMargin => (0.0, g.page_width),
1085        ST_RelativeFromH::RightMargin | ST_RelativeFromH::OutsideMargin => {
1086            (g.page_width - g.margin_right, g.margin_right)
1087        }
1088        ST_RelativeFromH::InsideMargin => (g.margin_left, g.margin_left),
1089        // A character-relative offset starts where the text does on the line.
1090        ST_RelativeFromH::Character => (g.margin_left + indent_left, text_width),
1091        // Margin and column both start at the left edge of the text area.
1092        // Multiple columns are not laid out yet, so the two coincide.
1093        ST_RelativeFromH::Margin | ST_RelativeFromH::Column => (g.margin_left, text_width),
1094    }
1095}
1096
1097fn resolve_anchor_h(
1098    rel: ST_RelativeFromH,
1099    off: f64,
1100    align: Option<AnchorAlignH>,
1101    width: f64,
1102    g: &PageGeometry,
1103    indent_left: f64,
1104) -> f64 {
1105    let (start, size) = frame_h(rel, g, indent_left);
1106    match align {
1107        // Inside and outside mean binding-side and outer-edge, which differ on
1108        // facing pages. Facing pages are not modelled, so the odd-page reading
1109        // stands in for both.
1110        Some(AnchorAlignH::Left | AnchorAlignH::Inside) => start,
1111        Some(AnchorAlignH::Center) => start + (size - width) / 2.0,
1112        Some(AnchorAlignH::Right | AnchorAlignH::Outside) => start + size - width,
1113        None => start + off,
1114    }
1115}
1116
1117/// Resolve a vertical anchor offset against the frame it is measured from.
1118///
1119/// `para_top` is the top of the anchoring paragraph, measured from the top of
1120/// the content area.
1121fn frame_v(rel: ST_RelativeFromV, g: &PageGeometry, para_top: f64) -> (f64, f64) {
1122    let text_height = g.page_height - g.margin_top - g.margin_bottom;
1123    match rel {
1124        ST_RelativeFromV::Page | ST_RelativeFromV::TopMargin => (0.0, g.page_height),
1125        ST_RelativeFromV::BottomMargin | ST_RelativeFromV::OutsideMargin => {
1126            (g.page_height - g.margin_bottom, g.margin_bottom)
1127        }
1128        ST_RelativeFromV::Margin | ST_RelativeFromV::InsideMargin => (g.margin_top, text_height),
1129        // Paragraph and line are both relative to where this paragraph landed.
1130        // Per-line anchoring would need the line box, which is finer than we
1131        // track here, so the paragraph top stands in for both.
1132        ST_RelativeFromV::Paragraph | ST_RelativeFromV::Line => {
1133            (g.margin_top + para_top, text_height)
1134        }
1135    }
1136}
1137
1138fn resolve_anchor_v(
1139    rel: ST_RelativeFromV,
1140    off: f64,
1141    align: Option<AnchorAlignV>,
1142    height: f64,
1143    g: &PageGeometry,
1144    para_top: f64,
1145) -> f64 {
1146    let (start, size) = frame_v(rel, g, para_top);
1147    match align {
1148        Some(AnchorAlignV::Top | AnchorAlignV::Inside) => start,
1149        Some(AnchorAlignV::Center) => start + (size - height) / 2.0,
1150        Some(AnchorAlignV::Bottom | AnchorAlignV::Outside) => start + size - height,
1151        None => start + off,
1152    }
1153}
1154
1155/// Re-break a paragraph so its text flows around the wrapping drawings that
1156/// share its band of the page.
1157///
1158/// `para_top` is where the paragraph's content starts, measured from the top of
1159/// the content area. Returns `None` when nothing applies, so the caller keeps
1160/// the paragraph it already has.
1161fn reflow_around_wraps(
1162    para: &ParagraphBlock,
1163    wraps: &[PlacedWrap],
1164    para_top: f64,
1165    geometry: &PageGeometry,
1166    fm: &FontManager,
1167) -> Option<ParagraphBlock> {
1168    let reflow = para.reflow.as_ref()?;
1169    if wraps.is_empty() {
1170        return None;
1171    }
1172
1173    let mut lines = para.lines.clone();
1174    let mut offset_top = 0.0;
1175
1176    // Two passes. The first reserves against the paragraph as laid out, the
1177    // second against the heights the first produced, which is what settles a
1178    // drawing that only overlaps once the text has moved.
1179    for _ in 0..2 {
1180        let mut prefix: Vec<f64> = Vec::new();
1181        let mut suffix: Vec<f64> = Vec::new();
1182
1183        // Vertical clearance is resolved first, because it moves the lines the
1184        // horizontal reservations are then measured against.
1185        let paragraph_top = geometry.margin_top + para_top;
1186        let mut next_offset_top: f64 = 0.0;
1187        for wrap in wraps.iter().filter(|w| w.wrap == WrapType::TopAndBottom) {
1188            if wrap.keep_out_top() <= paragraph_top + 1.0 && wrap.keep_out_bottom() > paragraph_top
1189            {
1190                next_offset_top = next_offset_top.max(wrap.keep_out_bottom() - paragraph_top);
1191            }
1192        }
1193
1194        for wrap in wraps.iter().filter(|w| w.wrap != WrapType::TopAndBottom) {
1195            // Square, and the outline wraps approximated as square.
1196            let text_left = geometry.margin_left + para.indent_left;
1197            let text_right = geometry.page_width - geometry.margin_right - para.indent_right;
1198            let drawing_centre = wrap.rect.x + wrap.rect.width / 2.0;
1199            let on_the_left = drawing_centre < (text_left + text_right) / 2.0;
1200
1201            let reserve = if on_the_left {
1202                (wrap.rect.x + wrap.rect.width + wrap.dist_right - text_left).max(0.0)
1203            } else {
1204                (text_right - (wrap.rect.x - wrap.dist_left)).max(0.0)
1205            };
1206            if reserve <= 0.0 {
1207                continue;
1208            }
1209
1210            let mut line_top = geometry.margin_top + para_top + next_offset_top;
1211            for (index, line) in lines.iter().enumerate() {
1212                let line_bottom = line_top + line.height;
1213                if line_bottom > wrap.keep_out_top() && line_top < wrap.keep_out_bottom() {
1214                    let target = if on_the_left {
1215                        &mut prefix
1216                    } else {
1217                        &mut suffix
1218                    };
1219                    if target.len() <= index {
1220                        target.resize(index + 1, 0.0);
1221                    }
1222                    target[index] += reserve;
1223                }
1224                line_top = line_bottom;
1225            }
1226        }
1227
1228        if prefix.is_empty() && suffix.is_empty() && next_offset_top == 0.0 {
1229            return None;
1230        }
1231
1232        let mut params = reflow.params.clone();
1233        params.line_prefix_widths = prefix;
1234        params.line_suffix_widths = suffix;
1235
1236        let Ok(reflowed) = break_into_lines(&reflow.items, &params, fm) else {
1237            return None;
1238        };
1239        lines = reflowed;
1240        offset_top = next_offset_top;
1241    }
1242
1243    let mut adjusted = para.clone();
1244    adjusted.lines = lines;
1245    adjusted.content_offset_top = offset_top;
1246    // The paragraph has been reflowed. Re-entering would reserve twice.
1247    adjusted.reflow = None;
1248    Some(adjusted)
1249}
1250
1251fn paginate_paragraph(
1252    para: &ParagraphBlock,
1253    block_idx: usize,
1254    blocks: &[LayoutBlock],
1255    pager: &mut Pager,
1256) {
1257    let space_before = if pager.cursor_y == 0.0 {
1258        0.0
1259    } else {
1260        para.space_before
1261    };
1262
1263    // Flow the paragraph around anything floating in its band of the page,
1264    // before anything is measured. A reflow changes the paragraph's height, so
1265    // doing it after the fitting decision would measure the wrong thing.
1266    let reflowed = {
1267        let para_top = pager.cursor_y + space_before;
1268        let mut wraps = pager.page_wraps.clone();
1269        wraps.extend(pager.wrap_rects_for(&para.anchored, para_top, para.indent_left));
1270        wraps.extend(pager.lookahead_wraps(block_idx, blocks));
1271        reflow_around_wraps(para, &wraps, para_top, &pager.geometry, pager.fm)
1272    };
1273    let para = reflowed.as_ref().unwrap_or(para);
1274
1275    // Check if paragraph fits on current page. The note area its references
1276    // will demand is priced in, but not claimed: the paragraph may yet move to
1277    // the next page, and its notes must move with it.
1278    let total_needed = space_before + para.content_height();
1279    let remaining = pager.available_height_for(&para.lines) - pager.cursor_y;
1280
1281    if total_needed > remaining && pager.has_content() {
1282        // Paragraph doesn't fit. Decide: move whole or split.
1283        if para.keep_lines || para.lines.len() <= 2 {
1284            pager.finish_page();
1285            // Re-call with fresh page
1286            paginate_paragraph(para, block_idx, blocks, pager);
1287            return;
1288        }
1289
1290        // The lines start below any drawing the paragraph must clear, so the
1291        // counter has to be told where they actually begin.
1292        let lines_that_fit = pager.count_lines_that_fit_with_notes(
1293            &para.lines,
1294            pager.cursor_y + space_before + para.content_offset_top,
1295        );
1296
1297        if para.widow_control && lines_that_fit < 2 {
1298            // Can't fit enough lines — move whole paragraph
1299            pager.finish_page();
1300            paginate_paragraph(para, block_idx, blocks, pager);
1301            return;
1302        }
1303
1304        let lines_remaining = para.lines.len() - lines_that_fit;
1305        if para.widow_control && lines_remaining < 2 && lines_that_fit >= 3 {
1306            // Would leave orphan — move one line to next page
1307            let split_at = lines_that_fit - 1;
1308            render_para_split(para, split_at, space_before, pager);
1309            return;
1310        }
1311
1312        if lines_that_fit > 0 {
1313            render_para_split(para, lines_that_fit, space_before, pager);
1314            return;
1315        }
1316
1317        // No lines fit (shouldn't happen since we checked has_content above)
1318        pager.finish_page();
1319        paginate_paragraph(para, block_idx, blocks, pager);
1320        return;
1321    }
1322
1323    // Paragraph fits OR we're at the top of a page
1324    // If it doesn't fit and we're at the top, we must split line by line
1325    if total_needed > pager.available_height_for(&para.lines) && pager.cursor_y == 0.0 {
1326        // Paragraph is taller than a page; split line by line
1327        let lines_that_fit =
1328            pager.count_lines_that_fit_with_notes(&para.lines, para.content_offset_top);
1329        if lines_that_fit > 0 && lines_that_fit < para.lines.len() {
1330            render_para_split(para, lines_that_fit, 0.0, pager);
1331            return;
1332        }
1333    }
1334
1335    // Check keep-with-next
1336    if para.keep_next && block_idx + 1 < blocks.len() {
1337        let next_first = match &blocks[block_idx + 1] {
1338            LayoutBlock::Paragraph(p) => p.lines.first().map(|l| l.height).unwrap_or(0.0),
1339            LayoutBlock::Table(t) => t.rows.first().map(|r| r.height).unwrap_or(0.0),
1340        };
1341        if pager.cursor_y + space_before + para.content_height() + next_first
1342            > pager.available_height_for(&para.lines)
1343            && pager.has_content()
1344        {
1345            pager.finish_page();
1346        }
1347    }
1348
1349    // Render the paragraph
1350    let space = if pager.cursor_y == 0.0 {
1351        0.0
1352    } else {
1353        para.space_before
1354    };
1355    pager.cursor_y += space;
1356
1357    if let Some(shading) = para.shading {
1358        pager.elements.push(PositionedElement::FilledRect {
1359            rect: Rect {
1360                x: pager.geometry.margin_left + para.indent_left,
1361                y: pager.geometry.margin_top + pager.cursor_y,
1362                width: pager.geometry.content_width() - para.indent_left - para.indent_right,
1363                height: para.content_height(),
1364            },
1365            color: shading,
1366        });
1367    }
1368
1369    // Render paragraph borders
1370    if let Some(ref borders) = para.borders {
1371        let border_x = pager.geometry.margin_left + para.indent_left;
1372        let border_y = pager.geometry.margin_top + pager.cursor_y;
1373        let border_w = pager.geometry.content_width() - para.indent_left - para.indent_right;
1374        let border_h = para.content_height();
1375        render_border_edges(
1376            borders,
1377            border_x,
1378            border_y,
1379            border_w,
1380            border_h,
1381            &mut pager.elements,
1382        );
1383    }
1384
1385    // Anchored drawings resolve against the paragraph's position, so place
1386    // them now that the page and the cursor are settled.
1387    pager.place_anchored(&para.anchored, pager.cursor_y, para.indent_left);
1388
1389    render_paragraph_lines(
1390        &para.lines,
1391        para,
1392        &pager.geometry,
1393        pager.cursor_y,
1394        &mut pager.elements,
1395        pager.media,
1396    );
1397    pager.claim_notes(&para.lines);
1398    pager.cursor_y += para.content_height();
1399    pager.ink_bottom = pager.cursor_y;
1400    pager.cursor_y += para.space_after;
1401    pager.mark_content();
1402}
1403
1404/// Split a paragraph at the given line index, rendering first part on current page
1405/// and continuing the rest on a new page (recursively if needed).
1406fn render_para_split(para: &ParagraphBlock, split_at: usize, space_before: f64, pager: &mut Pager) {
1407    // Render lines before split on current page
1408    pager.cursor_y += space_before;
1409    // A split paragraph anchors its drawings to where it starts.
1410    pager.place_anchored(&para.anchored, pager.cursor_y, para.indent_left);
1411    render_paragraph_lines(
1412        &para.lines[..split_at],
1413        para,
1414        &pager.geometry,
1415        pager.cursor_y,
1416        &mut pager.elements,
1417        pager.media,
1418    );
1419    // Only the lines placed on this page count toward its notes. The rest of
1420    // the paragraph, and any note it references, belong to the next page.
1421    pager.claim_notes(&para.lines[..split_at]);
1422    pager.ink_bottom = pager.cursor_y
1423        + para.content_offset_top
1424        + para.lines[..split_at].iter().map(|l| l.height).sum::<f64>();
1425    pager.mark_content();
1426    pager.finish_page();
1427
1428    // Handle remaining lines, which may themselves need splitting
1429    let remaining_lines = &para.lines[split_at..];
1430    let remaining_height: f64 = remaining_lines.iter().map(|l| l.height).sum();
1431
1432    if remaining_height > pager.available_height_for(remaining_lines) {
1433        // Still too tall — split again
1434        let lines_that_fit = pager.count_lines_that_fit_with_notes(remaining_lines, 0.0);
1435        if lines_that_fit > 0 && lines_that_fit < remaining_lines.len() {
1436            // Build a temporary para with remaining lines
1437            let temp_para = ParagraphBlock {
1438                // The anchors were placed with the first part of the
1439                // paragraph, so the continuation must not place them again.
1440                anchored: Vec::new(),
1441                lines: remaining_lines.to_vec(),
1442                space_before: 0.0,
1443                space_after: para.space_after,
1444                borders: para.borders.clone(),
1445                shading: para.shading,
1446                indent_left: para.indent_left,
1447                indent_right: para.indent_right,
1448                jc: para.jc,
1449                keep_next: para.keep_next,
1450                keep_lines: false,
1451                page_break_before: false,
1452                widow_control: para.widow_control,
1453                heading_level: None,
1454                heading_text: None,
1455                // The continuation was already reflowed as part of the whole
1456                // paragraph, so it must not be reflowed again.
1457                reflow: None,
1458                content_offset_top: 0.0,
1459            };
1460            render_para_split(&temp_para, lines_that_fit, 0.0, pager);
1461            return;
1462        }
1463    }
1464
1465    // Remaining fits on the new page
1466    render_paragraph_lines(
1467        remaining_lines,
1468        para,
1469        &pager.geometry,
1470        0.0,
1471        &mut pager.elements,
1472        pager.media,
1473    );
1474    pager.claim_notes(remaining_lines);
1475    pager.ink_bottom = remaining_height;
1476    pager.cursor_y = remaining_height + para.space_after;
1477    pager.mark_content();
1478}
1479
1480/// Render paragraph lines as positioned elements.
1481fn render_paragraph_lines(
1482    lines: &[LayoutLine],
1483    para: &ParagraphBlock,
1484    geometry: &PageGeometry,
1485    start_y: f64,
1486    elements: &mut Vec<PositionedElement>,
1487    media: &HashMap<MediaId, ImageData>,
1488) {
1489    // A drawing this paragraph must clear rather than flow beside pushes its
1490    // first line down. `content_height` already counts the same offset.
1491    let mut y = start_y + para.content_offset_top;
1492    for line in lines {
1493        let baseline_y = geometry.margin_top + y + line.ascent;
1494
1495        // Compute x offset based on justification
1496        let text_width: f64 = line.items.iter().map(|item| item.width()).sum();
1497        let remaining_width = line.available_width - text_width;
1498
1499        // For justified text (Both), compute extra space per gap
1500        let justify_extra =
1501            if para.jc == Some(Align::Justify) && !line.is_last && remaining_width > 0.0 {
1502                // Count inter-word gaps: spaces between items + spaces within text segments
1503                let gap_count = count_word_gaps(&line.items);
1504                if gap_count > 0 {
1505                    remaining_width / gap_count as f64
1506                } else {
1507                    0.0
1508                }
1509            } else {
1510                0.0
1511            };
1512
1513        let x_offset = match para.jc {
1514            Some(Align::Center) => geometry.margin_left + line.indent_left + remaining_width / 2.0,
1515            Some(Align::End) => geometry.margin_left + line.indent_left + remaining_width,
1516            Some(Align::Justify) if !line.is_last && justify_extra > 0.0 => {
1517                // Justified: start from left margin (extra space distributed in gaps)
1518                geometry.margin_left + line.indent_left
1519            }
1520            _ => geometry.margin_left + line.indent_left,
1521        };
1522
1523        let mut x = x_offset;
1524        let mut _accumulated_extra = 0.0;
1525
1526        for item in &line.items {
1527            match item {
1528                LineItem::Text(seg) | LineItem::Marker(seg) => {
1529                    let adjusted_baseline = baseline_y - seg.baseline_offset;
1530
1531                    // For justified text, compute the extra width from spaces in this segment
1532                    let segment_spaces = if justify_extra > 0.0 {
1533                        seg.text.chars().filter(|c| *c == ' ').count()
1534                    } else {
1535                        0
1536                    };
1537                    let segment_extra = segment_spaces as f64 * justify_extra;
1538                    let effective_width = seg.width + segment_extra;
1539
1540                    // Render highlight background
1541                    if let Some(hl_color) = seg.highlight {
1542                        elements.push(PositionedElement::FilledRect {
1543                            rect: Rect {
1544                                x,
1545                                y: geometry.margin_top + y,
1546                                width: effective_width,
1547                                height: line.height,
1548                            },
1549                            color: hl_color,
1550                        });
1551                    }
1552
1553                    // Render text, adjusting advances for justified text
1554                    let advances = if justify_extra > 0.0 && segment_spaces > 0 {
1555                        // Widen advances for space glyphs
1556                        distribute_justify_advances(&seg.text, &seg.advances, justify_extra)
1557                    } else {
1558                        seg.advances.clone()
1559                    };
1560
1561                    elements.push(PositionedElement::Text(GlyphRun {
1562                        origin: Point {
1563                            x,
1564                            y: adjusted_baseline,
1565                        },
1566                        font_id: seg.font_id,
1567                        font_size: seg.font_size,
1568                        glyph_ids: seg.glyph_ids.clone(),
1569                        advances,
1570                        text: seg.text.clone(),
1571                        color: seg.color,
1572                        bold: seg.bold,
1573                        italic: seg.italic,
1574                        field_kind: seg.field_kind,
1575                        note: seg.note,
1576                    }));
1577
1578                    // Render underline
1579                    if let Some(ul_style) = seg.underline {
1580                        let ul_y = adjusted_baseline + seg.descent * 0.3;
1581                        let ul_thickness = match ul_style {
1582                            Underline::Thick => seg.font_size / 12.0,
1583                            Underline::Double => seg.font_size / 24.0,
1584                            _ => seg.font_size / 18.0,
1585                        };
1586                        elements.push(PositionedElement::Line {
1587                            start: Point { x, y: ul_y },
1588                            end: Point {
1589                                x: x + effective_width,
1590                                y: ul_y,
1591                            },
1592                            width: ul_thickness,
1593                            color: seg.color,
1594                            dash_pattern: None,
1595                        });
1596                        // Second line for double underline
1597                        if ul_style == Underline::Double {
1598                            let ul_y2 = ul_y + ul_thickness * 2.5;
1599                            elements.push(PositionedElement::Line {
1600                                start: Point { x, y: ul_y2 },
1601                                end: Point {
1602                                    x: x + effective_width,
1603                                    y: ul_y2,
1604                                },
1605                                width: ul_thickness,
1606                                color: seg.color,
1607                                dash_pattern: None,
1608                            });
1609                        }
1610                    }
1611
1612                    // Render strikethrough
1613                    if seg.strike {
1614                        let strike_y = adjusted_baseline - seg.ascent * 0.3;
1615                        let strike_thickness = seg.font_size / 24.0;
1616                        elements.push(PositionedElement::Line {
1617                            start: Point { x, y: strike_y },
1618                            end: Point {
1619                                x: x + effective_width,
1620                                y: strike_y,
1621                            },
1622                            width: strike_thickness,
1623                            color: seg.color,
1624                            dash_pattern: None,
1625                        });
1626                    }
1627
1628                    // Render double strikethrough
1629                    if seg.dstrike {
1630                        let strike_y = adjusted_baseline - seg.ascent * 0.3;
1631                        let strike_thickness = seg.font_size / 24.0;
1632                        let gap = strike_thickness * 2.0;
1633                        elements.push(PositionedElement::Line {
1634                            start: Point {
1635                                x,
1636                                y: strike_y - gap / 2.0,
1637                            },
1638                            end: Point {
1639                                x: x + effective_width,
1640                                y: strike_y - gap / 2.0,
1641                            },
1642                            width: strike_thickness,
1643                            color: seg.color,
1644                            dash_pattern: None,
1645                        });
1646                        elements.push(PositionedElement::Line {
1647                            start: Point {
1648                                x,
1649                                y: strike_y + gap / 2.0,
1650                            },
1651                            end: Point {
1652                                x: x + effective_width,
1653                                y: strike_y + gap / 2.0,
1654                            },
1655                            width: strike_thickness,
1656                            color: seg.color,
1657                            dash_pattern: None,
1658                        });
1659                    }
1660
1661                    // Render hyperlink annotation
1662                    if let Some(ref url) = seg.hyperlink_url {
1663                        elements.push(PositionedElement::LinkAnnotation {
1664                            rect: Rect {
1665                                x,
1666                                y: geometry.margin_top + y,
1667                                width: effective_width,
1668                                height: line.height,
1669                            },
1670                            url: url.clone(),
1671                        });
1672                    }
1673
1674                    _accumulated_extra += segment_extra;
1675                    x += effective_width;
1676                }
1677                LineItem::Tab { width, leader } => {
1678                    if let Some(leader_seg) = leader {
1679                        // Render the pre-shaped leader text
1680                        let baseline_y = geometry.margin_top + y + line.ascent;
1681                        elements.push(PositionedElement::Text(GlyphRun {
1682                            origin: Point { x, y: baseline_y },
1683                            font_id: leader_seg.font_id,
1684                            font_size: leader_seg.font_size,
1685                            glyph_ids: leader_seg.glyph_ids.clone(),
1686                            advances: leader_seg.advances.clone(),
1687                            text: leader_seg.text.clone(),
1688                            color: leader_seg.color,
1689                            bold: leader_seg.bold,
1690                            italic: leader_seg.italic,
1691                            field_kind: None,
1692                            note: None,
1693                        }));
1694                    }
1695                    x += width;
1696                }
1697                LineItem::Image {
1698                    width,
1699                    height,
1700                    media_id,
1701                } => {
1702                    let image = media.get(media_id);
1703                    // Image positioned at current x, top-aligned with line
1704                    elements.push(PositionedElement::Image {
1705                        rect: Rect {
1706                            x,
1707                            y: geometry.margin_top + y,
1708                            width: *width,
1709                            height: *height,
1710                        },
1711                        data: image.map_or_else(Vec::new, |image| image.data.clone()),
1712                        content_type: image
1713                            .map_or_else(String::new, |image| image.content_type.clone()),
1714                        media_id: *media_id,
1715                    });
1716                    x += width;
1717                }
1718            }
1719        }
1720
1721        y += line.height;
1722    }
1723}
1724
1725/// Render header/footer blocks.
1726fn render_hf_blocks(
1727    blocks: &[ParagraphBlock],
1728    geometry: &PageGeometry,
1729    start_y: f64,
1730    elements: &mut Vec<PositionedElement>,
1731    media: &HashMap<MediaId, ImageData>,
1732) {
1733    let mut y = start_y - geometry.margin_top; // Convert to relative
1734    for para in blocks {
1735        render_paragraph_lines(&para.lines, para, geometry, y, elements, media);
1736        y += para.content_height();
1737    }
1738}
1739
1740/// Render a table row.
1741fn render_table_row(
1742    row: &crate::table::TableRow,
1743    _col_widths: &[f64],
1744    table_x: f64,
1745    row_y: f64,
1746    geometry: &PageGeometry,
1747    table_borders: Option<&rdocx_oxml::table::CT_TblBorders>,
1748    elements: &mut Vec<PositionedElement>,
1749    media: &HashMap<MediaId, ImageData>,
1750) {
1751    let mut cell_x = table_x;
1752    let num_cells = row.cells.len();
1753
1754    for (cell_idx, cell) in row.cells.iter().enumerate() {
1755        // Render cell shading
1756        if let Some(ref shading) = cell.shading {
1757            elements.push(PositionedElement::FilledRect {
1758                rect: Rect {
1759                    x: cell_x,
1760                    y: row_y,
1761                    width: cell.width,
1762                    height: cell.height,
1763                },
1764                color: *shading,
1765            });
1766        }
1767
1768        // Render cell borders
1769        render_cell_borders(
1770            cell_x,
1771            row_y,
1772            cell.width,
1773            cell.height,
1774            &cell.borders,
1775            table_borders,
1776            cell_idx,
1777            num_cells,
1778            cell.is_first_row,
1779            cell.is_last_row,
1780            elements,
1781        );
1782
1783        if !cell.is_vmerge_continue {
1784            // Render cell content
1785            let cell_margin_top = cell.margin_top;
1786            let cell_margin_left = cell.margin_left;
1787
1788            // Compute vertical alignment offset
1789            let content_height: f64 = cell.paragraphs.iter().map(|p| p.total_height()).sum();
1790            let v_offset = match cell.v_align {
1791                Some(rdocx_oxml::table::ST_VerticalJc::Center) => {
1792                    ((cell.height - cell_margin_top - content_height) / 2.0).max(0.0)
1793                }
1794                Some(rdocx_oxml::table::ST_VerticalJc::Bottom) => {
1795                    (cell.height - cell_margin_top - content_height).max(0.0)
1796                }
1797                _ => 0.0, // Top or unspecified
1798            };
1799
1800            let mut para_y = row_y - geometry.margin_top + cell_margin_top + v_offset;
1801            for para in &cell.paragraphs {
1802                render_paragraph_lines(
1803                    &para.lines,
1804                    para,
1805                    &PageGeometry {
1806                        margin_left: cell_x + cell_margin_left,
1807                        ..*geometry
1808                    },
1809                    para_y,
1810                    elements,
1811                    media,
1812                );
1813                para_y += para.total_height();
1814            }
1815        }
1816        cell_x += cell.width;
1817    }
1818}
1819
1820/// Render borders for a table cell.
1821fn render_cell_borders(
1822    x: f64,
1823    y: f64,
1824    w: f64,
1825    h: f64,
1826    cell_borders: &Option<rdocx_oxml::table::CT_TblBorders>,
1827    table_borders: Option<&rdocx_oxml::table::CT_TblBorders>,
1828    cell_idx: usize,
1829    num_cells: usize,
1830    is_first_row: bool,
1831    is_last_row: bool,
1832    elements: &mut Vec<PositionedElement>,
1833) {
1834    // Determine effective border for each edge (cell overrides table)
1835    let get_edge = |cell_edge: Option<&rdocx_oxml::borders::CT_BorderEdge>,
1836                    table_edge: Option<&rdocx_oxml::borders::CT_BorderEdge>|
1837     -> Option<BorderEdge> {
1838        let edge = cell_edge.or(table_edge)?;
1839        if edge.val == ST_Border::None {
1840            return None;
1841        }
1842        let thickness = edge.sz.unwrap_or(4) as f64 / 8.0; // sz is in 1/8 pt
1843        let color = edge
1844            .color
1845            .as_ref()
1846            .filter(|c| c.as_str() != "auto")
1847            .map(|c| Color::from_hex(c))
1848            .unwrap_or(Color::BLACK);
1849        let dash = border_dash_pattern(edge.val, thickness);
1850        Some((thickness, color, dash))
1851    };
1852
1853    // Top border: use table top for first row, table insideH otherwise
1854    let table_top = table_borders.and_then(|b| {
1855        if is_first_row {
1856            b.top.as_ref()
1857        } else {
1858            b.inside_h.as_ref()
1859        }
1860    });
1861    let cell_top = cell_borders.as_ref().and_then(|b| b.top.as_ref());
1862    if let Some((thickness, color, dash_pattern)) = get_edge(cell_top, table_top) {
1863        elements.push(PositionedElement::Line {
1864            start: Point { x, y },
1865            end: Point { x: x + w, y },
1866            width: thickness,
1867            color,
1868            dash_pattern,
1869        });
1870    }
1871
1872    // Bottom border: use table bottom for last row, table insideH otherwise
1873    let table_bottom = table_borders.and_then(|b| {
1874        if is_last_row {
1875            b.bottom.as_ref()
1876        } else {
1877            b.inside_h.as_ref()
1878        }
1879    });
1880    let cell_bottom = cell_borders.as_ref().and_then(|b| b.bottom.as_ref());
1881    if let Some((thickness, color, dash_pattern)) = get_edge(cell_bottom, table_bottom) {
1882        elements.push(PositionedElement::Line {
1883            start: Point { x, y: y + h },
1884            end: Point { x: x + w, y: y + h },
1885            width: thickness,
1886            color,
1887            dash_pattern,
1888        });
1889    }
1890
1891    // Left border: use table left for first cell, table insideV otherwise
1892    let table_left = table_borders.and_then(|b| {
1893        if cell_idx == 0 {
1894            b.left.as_ref()
1895        } else {
1896            b.inside_v.as_ref()
1897        }
1898    });
1899    let cell_left = cell_borders.as_ref().and_then(|b| b.left.as_ref());
1900    if let Some((thickness, color, dash_pattern)) = get_edge(cell_left, table_left) {
1901        elements.push(PositionedElement::Line {
1902            start: Point { x, y },
1903            end: Point { x, y: y + h },
1904            width: thickness,
1905            color,
1906            dash_pattern,
1907        });
1908    }
1909
1910    // Right border: use table right for last cell, table insideV otherwise
1911    let table_right = table_borders.and_then(|b| {
1912        if cell_idx == num_cells - 1 {
1913            b.right.as_ref()
1914        } else {
1915            b.inside_v.as_ref()
1916        }
1917    });
1918    let cell_right = cell_borders.as_ref().and_then(|b| b.right.as_ref());
1919    if let Some((thickness, color, dash_pattern)) = get_edge(cell_right, table_right) {
1920        elements.push(PositionedElement::Line {
1921            start: Point { x: x + w, y },
1922            end: Point { x: x + w, y: y + h },
1923            width: thickness,
1924            color,
1925            dash_pattern,
1926        });
1927    }
1928}
1929
1930/// Render paragraph border edges as positioned lines.
1931fn render_border_edges(
1932    borders: &rdocx_oxml::borders::CT_PBdr,
1933    x: f64,
1934    y: f64,
1935    w: f64,
1936    h: f64,
1937    elements: &mut Vec<PositionedElement>,
1938) {
1939    let render_edge = |edge: &rdocx_oxml::borders::CT_BorderEdge,
1940                       start: Point,
1941                       end: Point,
1942                       elements: &mut Vec<PositionedElement>| {
1943        if edge.val == ST_Border::None {
1944            return;
1945        }
1946        let thickness = edge.sz.unwrap_or(4) as f64 / 8.0; // sz is in eighths of a point
1947        let color = edge
1948            .color
1949            .as_ref()
1950            .filter(|c| c.as_str() != "auto")
1951            .map(|c| Color::from_hex(c))
1952            .unwrap_or(Color::BLACK);
1953        let dash_pattern = border_dash_pattern(edge.val, thickness);
1954
1955        if edge.val == ST_Border::Double {
1956            // Double border: emit two parallel lines
1957            let gap = thickness * 2.0;
1958            let dx = end.x - start.x;
1959            let dy = end.y - start.y;
1960            let len = (dx * dx + dy * dy).sqrt();
1961            let (nx, ny) = if len > 0.0 {
1962                (-dy / len, dx / len)
1963            } else {
1964                (0.0, 1.0)
1965            };
1966            let offset = gap / 2.0;
1967            elements.push(PositionedElement::Line {
1968                start: Point {
1969                    x: start.x + nx * offset,
1970                    y: start.y + ny * offset,
1971                },
1972                end: Point {
1973                    x: end.x + nx * offset,
1974                    y: end.y + ny * offset,
1975                },
1976                width: thickness,
1977                color,
1978                dash_pattern: None,
1979            });
1980            elements.push(PositionedElement::Line {
1981                start: Point {
1982                    x: start.x - nx * offset,
1983                    y: start.y - ny * offset,
1984                },
1985                end: Point {
1986                    x: end.x - nx * offset,
1987                    y: end.y - ny * offset,
1988                },
1989                width: thickness,
1990                color,
1991                dash_pattern: None,
1992            });
1993        } else {
1994            elements.push(PositionedElement::Line {
1995                start,
1996                end,
1997                width: thickness,
1998                color,
1999                dash_pattern,
2000            });
2001        }
2002    };
2003
2004    if let Some(ref edge) = borders.top {
2005        let space = edge.space.unwrap_or(0) as f64;
2006        render_edge(
2007            edge,
2008            Point { x, y: y - space },
2009            Point {
2010                x: x + w,
2011                y: y - space,
2012            },
2013            elements,
2014        );
2015    }
2016    if let Some(ref edge) = borders.bottom {
2017        let space = edge.space.unwrap_or(0) as f64;
2018        render_edge(
2019            edge,
2020            Point {
2021                x,
2022                y: y + h + space,
2023            },
2024            Point {
2025                x: x + w,
2026                y: y + h + space,
2027            },
2028            elements,
2029        );
2030    }
2031    if let Some(ref edge) = borders.left {
2032        let space = edge.space.unwrap_or(0) as f64;
2033        render_edge(
2034            edge,
2035            Point { x: x - space, y },
2036            Point {
2037                x: x - space,
2038                y: y + h,
2039            },
2040            elements,
2041        );
2042    }
2043    if let Some(ref edge) = borders.right {
2044        let space = edge.space.unwrap_or(0) as f64;
2045        render_edge(
2046            edge,
2047            Point {
2048                x: x + w + space,
2049                y,
2050            },
2051            Point {
2052                x: x + w + space,
2053                y: y + h,
2054            },
2055            elements,
2056        );
2057    }
2058}
2059
2060/// Map a border style to a dash pattern (dash_on, dash_off) in points.
2061/// Returns None for solid lines (Single, Thick, Double, etc.).
2062fn border_dash_pattern(style: ST_Border, thickness: f64) -> Option<(f64, f64)> {
2063    match style {
2064        ST_Border::Dashed => Some((3.0 * thickness, 2.0 * thickness)),
2065        ST_Border::Dotted => Some((thickness, thickness)),
2066        ST_Border::DotDash | ST_Border::DotDotDash => Some((3.0 * thickness, thickness)),
2067        _ => None,
2068    }
2069}
2070
2071/// Count inter-word gap positions in a line (spaces within text segments).
2072fn count_word_gaps(items: &[LineItem]) -> usize {
2073    let mut count = 0;
2074    for item in items {
2075        match item {
2076            LineItem::Text(seg) | LineItem::Marker(seg) => {
2077                count += seg.text.chars().filter(|c| *c == ' ').count();
2078            }
2079            LineItem::Tab { .. } => {
2080                count += 1;
2081            }
2082            _ => {}
2083        }
2084    }
2085    count
2086}
2087
2088/// Distribute extra justify space across advances by widening space-character advances.
2089fn distribute_justify_advances(text: &str, advances: &[f64], extra_per_gap: f64) -> Vec<f64> {
2090    let chars: Vec<char> = text.chars().collect();
2091    let mut result = advances.to_vec();
2092
2093    if chars.len() == result.len() {
2094        // 1:1 char-to-glyph mapping
2095        for (i, &ch) in chars.iter().enumerate() {
2096            if ch == ' ' {
2097                result[i] += extra_per_gap;
2098            }
2099        }
2100    } else {
2101        // Fallback: distribute evenly across all glyphs
2102        let total_extra = extra_per_gap * text.chars().filter(|c| *c == ' ').count() as f64;
2103        if !result.is_empty() {
2104            let per_glyph = total_extra / result.len() as f64;
2105            for a in &mut result {
2106                *a += per_glyph;
2107            }
2108        }
2109    }
2110
2111    result
2112}
2113
2114#[cfg(test)]
2115mod tests {
2116    use super::*;
2117    use crate::block::ParagraphBlock;
2118    use oxml_layout::LayoutLine;
2119
2120    fn empty_media() -> MediaRegistry {
2121        MediaRegistry::new(&HashMap::new())
2122    }
2123
2124    fn make_line(height: f64) -> LayoutLine {
2125        LayoutLine {
2126            items: vec![],
2127            width: 100.0,
2128            ascent: height * 0.77,
2129            descent: height * 0.23,
2130            line_gap: 0.0,
2131            height,
2132            indent_left: 0.0,
2133            available_width: 468.0,
2134            is_last: true,
2135        }
2136    }
2137
2138    fn make_para(line_count: usize, line_height: f64) -> ParagraphBlock {
2139        let mut lines = Vec::new();
2140        for _ in 0..line_count {
2141            lines.push(make_line(line_height));
2142        }
2143        ParagraphBlock {
2144            anchored: Vec::new(),
2145            lines,
2146            space_before: 0.0,
2147            space_after: 0.0,
2148            borders: None,
2149            shading: None,
2150            indent_left: 0.0,
2151            indent_right: 0.0,
2152            jc: None,
2153            keep_next: false,
2154            keep_lines: false,
2155            page_break_before: false,
2156            widow_control: true,
2157            heading_level: None,
2158            heading_text: None,
2159            reflow: None,
2160            content_offset_top: 0.0,
2161        }
2162    }
2163
2164    #[test]
2165    fn single_page_layout() {
2166        let fm = FontManager::new();
2167        let blocks = vec![LayoutBlock::Paragraph(make_para(3, 14.0))];
2168        let geom = PageGeometry::default();
2169        let (pages, _outlines) = paginate(
2170            &blocks,
2171            geom,
2172            None,
2173            false,
2174            &fm,
2175            &empty_media(),
2176            &NoteRegistry::default(),
2177        );
2178        assert_eq!(pages.len(), 1);
2179        assert_eq!(pages[0].page_number, 1);
2180    }
2181
2182    #[test]
2183    fn multi_page_overflow() {
2184        let fm = FontManager::new();
2185        // 648pt content height / 14pt per line ≈ 46 lines per page
2186        let blocks = vec![LayoutBlock::Paragraph(make_para(100, 14.0))];
2187        let geom = PageGeometry::default();
2188        let (pages, _outlines) = paginate(
2189            &blocks,
2190            geom,
2191            None,
2192            false,
2193            &fm,
2194            &empty_media(),
2195            &NoteRegistry::default(),
2196        );
2197        assert!(pages.len() >= 2);
2198    }
2199
2200    #[test]
2201    fn forced_page_break() {
2202        let fm = FontManager::new();
2203        let mut para2 = make_para(3, 14.0);
2204        para2.page_break_before = true;
2205        let blocks = vec![
2206            LayoutBlock::Paragraph(make_para(3, 14.0)),
2207            LayoutBlock::Paragraph(para2),
2208        ];
2209        let geom = PageGeometry::default();
2210        let (pages, _outlines) = paginate(
2211            &blocks,
2212            geom,
2213            None,
2214            false,
2215            &fm,
2216            &empty_media(),
2217            &NoteRegistry::default(),
2218        );
2219        assert_eq!(pages.len(), 2);
2220    }
2221
2222    #[test]
2223    fn page_dimensions() {
2224        let fm = FontManager::new();
2225        let blocks = vec![LayoutBlock::Paragraph(make_para(1, 14.0))];
2226        let geom = PageGeometry::default();
2227        let (pages, _outlines) = paginate(
2228            &blocks,
2229            geom,
2230            None,
2231            false,
2232            &fm,
2233            &empty_media(),
2234            &NoteRegistry::default(),
2235        );
2236        assert!((pages[0].width - 612.0).abs() < 0.01);
2237        assert!((pages[0].height - 792.0).abs() < 0.01);
2238    }
2239
2240    fn make_text_line(height: f64, underline: Option<Underline>, strike: bool) -> LayoutLine {
2241        use oxml_layout::TextSegment;
2242        let seg = TextSegment {
2243            text: "Hello".to_string(),
2244            font_id: oxml_layout::FontId(0),
2245            font_size: 12.0,
2246            glyph_ids: vec![1, 2, 3],
2247            advances: vec![6.0, 6.0, 6.0],
2248            width: 40.0,
2249            ascent: height * 0.77,
2250            descent: height * 0.23,
2251            line_gap: 0.0,
2252            color: Color::BLACK,
2253            bold: false,
2254            italic: false,
2255            underline,
2256            strike,
2257            dstrike: false,
2258            highlight: None,
2259            baseline_offset: 0.0,
2260            hyperlink_url: None,
2261            field_kind: None,
2262            note: None,
2263        };
2264        LayoutLine {
2265            items: vec![LineItem::Text(seg)],
2266            width: 40.0,
2267            ascent: height * 0.77,
2268            descent: height * 0.23,
2269            line_gap: 0.0,
2270            height,
2271            indent_left: 0.0,
2272            available_width: 468.0,
2273            is_last: true,
2274        }
2275    }
2276
2277    #[test]
2278    fn underline_renders_line_element() {
2279        let fm = FontManager::new();
2280        let para = ParagraphBlock {
2281            anchored: Vec::new(),
2282            lines: vec![make_text_line(14.0, Some(Underline::Single), false)],
2283            space_before: 0.0,
2284            space_after: 0.0,
2285            borders: None,
2286            shading: None,
2287            indent_left: 0.0,
2288            indent_right: 0.0,
2289            jc: None,
2290            keep_next: false,
2291            keep_lines: false,
2292            page_break_before: false,
2293            widow_control: true,
2294            heading_level: None,
2295            heading_text: None,
2296            reflow: None,
2297            content_offset_top: 0.0,
2298        };
2299        let blocks = vec![LayoutBlock::Paragraph(para)];
2300        let (pages, _outlines) = paginate(
2301            &blocks,
2302            PageGeometry::default(),
2303            None,
2304            false,
2305            &fm,
2306            &empty_media(),
2307            &NoteRegistry::default(),
2308        );
2309        // Should have Text + Line (underline)
2310        let lines: Vec<_> = pages[0]
2311            .elements
2312            .iter()
2313            .filter(|e| matches!(e, PositionedElement::Line { .. }))
2314            .collect();
2315        assert_eq!(lines.len(), 1, "expected 1 underline line");
2316    }
2317
2318    #[test]
2319    fn strikethrough_renders_line_element() {
2320        let fm = FontManager::new();
2321        let para = ParagraphBlock {
2322            anchored: Vec::new(),
2323            lines: vec![make_text_line(14.0, None, true)],
2324            space_before: 0.0,
2325            space_after: 0.0,
2326            borders: None,
2327            shading: None,
2328            indent_left: 0.0,
2329            indent_right: 0.0,
2330            jc: None,
2331            keep_next: false,
2332            keep_lines: false,
2333            page_break_before: false,
2334            widow_control: true,
2335            heading_level: None,
2336            heading_text: None,
2337            reflow: None,
2338            content_offset_top: 0.0,
2339        };
2340        let blocks = vec![LayoutBlock::Paragraph(para)];
2341        let (pages, _outlines) = paginate(
2342            &blocks,
2343            PageGeometry::default(),
2344            None,
2345            false,
2346            &fm,
2347            &empty_media(),
2348            &NoteRegistry::default(),
2349        );
2350        let lines: Vec<_> = pages[0]
2351            .elements
2352            .iter()
2353            .filter(|e| matches!(e, PositionedElement::Line { .. }))
2354            .collect();
2355        assert_eq!(lines.len(), 1, "expected 1 strikethrough line");
2356    }
2357
2358    #[test]
2359    fn highlight_renders_filled_rect() {
2360        use oxml_layout::TextSegment;
2361        let fm = FontManager::new();
2362        let seg = TextSegment {
2363            text: "Hi".to_string(),
2364            font_id: oxml_layout::FontId(0),
2365            font_size: 12.0,
2366            glyph_ids: vec![1],
2367            advances: vec![10.0],
2368            width: 20.0,
2369            ascent: 10.0,
2370            descent: 3.0,
2371            line_gap: 0.0,
2372            color: Color::BLACK,
2373            bold: false,
2374            italic: false,
2375            underline: None,
2376            strike: false,
2377            dstrike: false,
2378            highlight: Some(Color {
2379                r: 1.0,
2380                g: 1.0,
2381                b: 0.0,
2382                a: 1.0,
2383            }),
2384            baseline_offset: 0.0,
2385            hyperlink_url: None,
2386            field_kind: None,
2387            note: None,
2388        };
2389        let line = LayoutLine {
2390            items: vec![LineItem::Text(seg)],
2391            width: 20.0,
2392            ascent: 10.0,
2393            descent: 3.0,
2394            line_gap: 0.0,
2395            height: 13.0,
2396            indent_left: 0.0,
2397            available_width: 468.0,
2398            is_last: true,
2399        };
2400        let para = ParagraphBlock {
2401            anchored: Vec::new(),
2402            lines: vec![line],
2403            space_before: 0.0,
2404            space_after: 0.0,
2405            borders: None,
2406            shading: None,
2407            indent_left: 0.0,
2408            indent_right: 0.0,
2409            jc: None,
2410            keep_next: false,
2411            keep_lines: false,
2412            page_break_before: false,
2413            widow_control: true,
2414            heading_level: None,
2415            heading_text: None,
2416            reflow: None,
2417            content_offset_top: 0.0,
2418        };
2419        let blocks = vec![LayoutBlock::Paragraph(para)];
2420        let (pages, _outlines) = paginate(
2421            &blocks,
2422            PageGeometry::default(),
2423            None,
2424            false,
2425            &fm,
2426            &empty_media(),
2427            &NoteRegistry::default(),
2428        );
2429        let rects: Vec<_> = pages[0]
2430            .elements
2431            .iter()
2432            .filter(|e| matches!(e, PositionedElement::FilledRect { .. }))
2433            .collect();
2434        assert_eq!(rects.len(), 1, "expected 1 highlight rect");
2435    }
2436
2437    #[test]
2438    fn paragraph_borders_render_lines() {
2439        use rdocx_oxml::borders::{CT_BorderEdge, CT_PBdr};
2440        let fm = FontManager::new();
2441        let para = ParagraphBlock {
2442            anchored: Vec::new(),
2443            lines: vec![make_line(14.0)],
2444            space_before: 0.0,
2445            space_after: 0.0,
2446            borders: Some(CT_PBdr {
2447                top: Some(CT_BorderEdge {
2448                    val: ST_Border::Single,
2449                    sz: Some(4),
2450                    space: Some(1),
2451                    color: Some("000000".to_string()),
2452                }),
2453                bottom: Some(CT_BorderEdge {
2454                    val: ST_Border::Single,
2455                    sz: Some(4),
2456                    space: Some(1),
2457                    color: Some("000000".to_string()),
2458                }),
2459                ..Default::default()
2460            }),
2461            shading: None,
2462            indent_left: 0.0,
2463            indent_right: 0.0,
2464            jc: None,
2465            keep_next: false,
2466            keep_lines: false,
2467            page_break_before: false,
2468            widow_control: true,
2469            heading_level: None,
2470            heading_text: None,
2471            reflow: None,
2472            content_offset_top: 0.0,
2473        };
2474        let blocks = vec![LayoutBlock::Paragraph(para)];
2475        let (pages, _outlines) = paginate(
2476            &blocks,
2477            PageGeometry::default(),
2478            None,
2479            false,
2480            &fm,
2481            &empty_media(),
2482            &NoteRegistry::default(),
2483        );
2484        let lines: Vec<_> = pages[0]
2485            .elements
2486            .iter()
2487            .filter(|e| matches!(e, PositionedElement::Line { .. }))
2488            .collect();
2489        assert_eq!(lines.len(), 2, "expected 2 border lines (top + bottom)");
2490    }
2491
2492    #[test]
2493    fn paragraph_shading_renders_filled_rect() {
2494        let fm = FontManager::new();
2495        let para = ParagraphBlock {
2496            anchored: Vec::new(),
2497            lines: vec![make_line(14.0)],
2498            space_before: 0.0,
2499            space_after: 0.0,
2500            borders: None,
2501            shading: Some(Color {
2502                r: 1.0,
2503                g: 1.0,
2504                b: 0.0,
2505                a: 1.0,
2506            }),
2507            indent_left: 0.0,
2508            indent_right: 0.0,
2509            jc: None,
2510            keep_next: false,
2511            keep_lines: false,
2512            page_break_before: false,
2513            widow_control: true,
2514            heading_level: None,
2515            heading_text: None,
2516            reflow: None,
2517            content_offset_top: 0.0,
2518        };
2519        let blocks = vec![LayoutBlock::Paragraph(para)];
2520        let (pages, _outlines) = paginate(
2521            &blocks,
2522            PageGeometry::default(),
2523            None,
2524            false,
2525            &fm,
2526            &empty_media(),
2527            &NoteRegistry::default(),
2528        );
2529        let rects: Vec<_> = pages[0]
2530            .elements
2531            .iter()
2532            .filter(|e| matches!(e, PositionedElement::FilledRect { .. }))
2533            .collect();
2534        assert_eq!(rects.len(), 1, "expected 1 paragraph shading rect");
2535    }
2536
2537    #[test]
2538    fn double_underline_renders_two_lines() {
2539        let fm = FontManager::new();
2540        let para = ParagraphBlock {
2541            anchored: Vec::new(),
2542            lines: vec![make_text_line(14.0, Some(Underline::Double), false)],
2543            space_before: 0.0,
2544            space_after: 0.0,
2545            borders: None,
2546            shading: None,
2547            indent_left: 0.0,
2548            indent_right: 0.0,
2549            jc: None,
2550            keep_next: false,
2551            keep_lines: false,
2552            page_break_before: false,
2553            widow_control: true,
2554            heading_level: None,
2555            heading_text: None,
2556            reflow: None,
2557            content_offset_top: 0.0,
2558        };
2559        let blocks = vec![LayoutBlock::Paragraph(para)];
2560        let (pages, _outlines) = paginate(
2561            &blocks,
2562            PageGeometry::default(),
2563            None,
2564            false,
2565            &fm,
2566            &empty_media(),
2567            &NoteRegistry::default(),
2568        );
2569        let lines: Vec<_> = pages[0]
2570            .elements
2571            .iter()
2572            .filter(|e| matches!(e, PositionedElement::Line { .. }))
2573            .collect();
2574        assert_eq!(lines.len(), 2, "expected 2 lines for double underline");
2575    }
2576
2577    fn make_justified_line(text: &str, seg_width: f64, is_last: bool) -> LayoutLine {
2578        use oxml_layout::TextSegment;
2579        let seg = TextSegment {
2580            text: text.to_string(),
2581            font_id: oxml_layout::FontId(0),
2582            font_size: 12.0,
2583            glyph_ids: vec![1; text.len()],
2584            advances: vec![seg_width / text.len() as f64; text.len()],
2585            width: seg_width,
2586            ascent: 10.0,
2587            descent: 3.0,
2588            line_gap: 0.0,
2589            color: Color::BLACK,
2590            bold: false,
2591            italic: false,
2592            underline: None,
2593            strike: false,
2594            dstrike: false,
2595            highlight: None,
2596            baseline_offset: 0.0,
2597            hyperlink_url: None,
2598            field_kind: None,
2599            note: None,
2600        };
2601        LayoutLine {
2602            items: vec![LineItem::Text(seg)],
2603            width: seg_width,
2604            ascent: 10.0,
2605            descent: 3.0,
2606            line_gap: 0.0,
2607            height: 13.0,
2608            indent_left: 0.0,
2609            available_width: 468.0,
2610            is_last,
2611        }
2612    }
2613
2614    #[test]
2615    fn hyperlink_emits_link_annotation() {
2616        use oxml_layout::TextSegment;
2617        let fm = FontManager::new();
2618        let seg = TextSegment {
2619            text: "Click me".to_string(),
2620            font_id: oxml_layout::FontId(0),
2621            font_size: 12.0,
2622            glyph_ids: vec![1, 2, 3],
2623            advances: vec![8.0, 8.0, 8.0],
2624            width: 60.0,
2625            ascent: 10.0,
2626            descent: 3.0,
2627            line_gap: 0.0,
2628            color: Color::BLACK,
2629            bold: false,
2630            italic: false,
2631            underline: None,
2632            strike: false,
2633            dstrike: false,
2634            highlight: None,
2635            baseline_offset: 0.0,
2636            hyperlink_url: Some("https://example.com".to_string()),
2637            field_kind: None,
2638            note: None,
2639        };
2640        let line = LayoutLine {
2641            items: vec![LineItem::Text(seg)],
2642            width: 60.0,
2643            ascent: 10.0,
2644            descent: 3.0,
2645            line_gap: 0.0,
2646            height: 13.0,
2647            indent_left: 0.0,
2648            available_width: 468.0,
2649            is_last: true,
2650        };
2651        let para = ParagraphBlock {
2652            anchored: Vec::new(),
2653            lines: vec![line],
2654            space_before: 0.0,
2655            space_after: 0.0,
2656            borders: None,
2657            shading: None,
2658            indent_left: 0.0,
2659            indent_right: 0.0,
2660            jc: None,
2661            keep_next: false,
2662            keep_lines: false,
2663            page_break_before: false,
2664            widow_control: true,
2665            heading_level: None,
2666            heading_text: None,
2667            reflow: None,
2668            content_offset_top: 0.0,
2669        };
2670        let blocks = vec![LayoutBlock::Paragraph(para)];
2671        let (pages, _outlines) = paginate(
2672            &blocks,
2673            PageGeometry::default(),
2674            None,
2675            false,
2676            &fm,
2677            &empty_media(),
2678            &NoteRegistry::default(),
2679        );
2680        let annotations: Vec<_> = pages[0]
2681            .elements
2682            .iter()
2683            .filter(|e| matches!(e, PositionedElement::LinkAnnotation { .. }))
2684            .collect();
2685        assert_eq!(annotations.len(), 1, "expected 1 link annotation");
2686        if let PositionedElement::LinkAnnotation { url, .. } = annotations[0] {
2687            assert_eq!(url, "https://example.com");
2688        }
2689    }
2690
2691    #[test]
2692    fn justified_text_fills_line_width() {
2693        let fm = FontManager::new();
2694        // Line with "Hello World" (1 space = 1 gap), width 200 out of 468 available
2695        let para = ParagraphBlock {
2696            anchored: Vec::new(),
2697            lines: vec![
2698                make_justified_line("Hello World", 200.0, false),
2699                make_justified_line("End.", 40.0, true),
2700            ],
2701            space_before: 0.0,
2702            space_after: 0.0,
2703            borders: None,
2704            shading: None,
2705            indent_left: 0.0,
2706            indent_right: 0.0,
2707            jc: Some(Align::Justify),
2708            keep_next: false,
2709            keep_lines: false,
2710            page_break_before: false,
2711            widow_control: true,
2712            heading_level: None,
2713            heading_text: None,
2714            reflow: None,
2715            content_offset_top: 0.0,
2716        };
2717
2718        let blocks = vec![LayoutBlock::Paragraph(para)];
2719        let (pages, _outlines) = paginate(
2720            &blocks,
2721            PageGeometry::default(),
2722            None,
2723            false,
2724            &fm,
2725            &empty_media(),
2726            &NoteRegistry::default(),
2727        );
2728
2729        // The first line's text run should have widened advances
2730        let first_text = pages[0].elements.iter().find_map(|e| {
2731            if let PositionedElement::Text(run) = e {
2732                Some(run)
2733            } else {
2734                None
2735            }
2736        });
2737        assert!(first_text.is_some());
2738        let run = first_text.unwrap();
2739        // The total advance should be wider than the original 200pt
2740        let total_advance: f64 = run.advances.iter().sum();
2741        assert!(
2742            total_advance > 200.0,
2743            "justified text should be wider than original: {total_advance}"
2744        );
2745    }
2746
2747    #[test]
2748    fn justified_last_line_stays_left_aligned() {
2749        let fm = FontManager::new();
2750        let para = ParagraphBlock {
2751            anchored: Vec::new(),
2752            lines: vec![
2753                make_justified_line("Hello World Test", 200.0, false),
2754                make_justified_line("End.", 40.0, true),
2755            ],
2756            space_before: 0.0,
2757            space_after: 0.0,
2758            borders: None,
2759            shading: None,
2760            indent_left: 0.0,
2761            indent_right: 0.0,
2762            jc: Some(Align::Justify),
2763            keep_next: false,
2764            keep_lines: false,
2765            page_break_before: false,
2766            widow_control: true,
2767            heading_level: None,
2768            heading_text: None,
2769            reflow: None,
2770            content_offset_top: 0.0,
2771        };
2772
2773        let blocks = vec![LayoutBlock::Paragraph(para)];
2774        let (pages, _outlines) = paginate(
2775            &blocks,
2776            PageGeometry::default(),
2777            None,
2778            false,
2779            &fm,
2780            &empty_media(),
2781            &NoteRegistry::default(),
2782        );
2783
2784        // Find the second text run (last line)
2785        let text_runs: Vec<_> = pages[0]
2786            .elements
2787            .iter()
2788            .filter_map(|e| {
2789                if let PositionedElement::Text(run) = e {
2790                    Some(run)
2791                } else {
2792                    None
2793                }
2794            })
2795            .collect();
2796
2797        assert!(text_runs.len() >= 2);
2798        // Last line should NOT be stretched — advances should sum to original width
2799        let last_advance: f64 = text_runs[1].advances.iter().sum();
2800        assert!(
2801            (last_advance - 40.0).abs() < 0.1,
2802            "last line should stay at original width: {last_advance}"
2803        );
2804    }
2805
2806    #[test]
2807    fn justified_single_word_not_stretched() {
2808        let fm = FontManager::new();
2809        // A line with a single word (no spaces) should not be stretched
2810        let para = ParagraphBlock {
2811            anchored: Vec::new(),
2812            lines: vec![
2813                make_justified_line("Superlongword", 100.0, false),
2814                make_justified_line("End.", 40.0, true),
2815            ],
2816            space_before: 0.0,
2817            space_after: 0.0,
2818            borders: None,
2819            shading: None,
2820            indent_left: 0.0,
2821            indent_right: 0.0,
2822            jc: Some(Align::Justify),
2823            keep_next: false,
2824            keep_lines: false,
2825            page_break_before: false,
2826            widow_control: true,
2827            heading_level: None,
2828            heading_text: None,
2829            reflow: None,
2830            content_offset_top: 0.0,
2831        };
2832
2833        let blocks = vec![LayoutBlock::Paragraph(para)];
2834        let (pages, _outlines) = paginate(
2835            &blocks,
2836            PageGeometry::default(),
2837            None,
2838            false,
2839            &fm,
2840            &empty_media(),
2841            &NoteRegistry::default(),
2842        );
2843
2844        let first_text = pages[0].elements.iter().find_map(|e| {
2845            if let PositionedElement::Text(run) = e {
2846                Some(run)
2847            } else {
2848                None
2849            }
2850        });
2851        assert!(first_text.is_some());
2852        let run = first_text.unwrap();
2853        let total_advance: f64 = run.advances.iter().sum();
2854        // No spaces → no stretching
2855        assert!(
2856            (total_advance - 100.0).abs() < 0.1,
2857            "single word should not be stretched: {total_advance}"
2858        );
2859    }
2860
2861    /// A wp:anchor offset means nothing without the frame it is measured from.
2862    /// Treating every offset as a page coordinate put anchored drawings in the
2863    /// corner of the sheet instead of beside their paragraph.
2864    #[test]
2865    fn anchor_offsets_resolve_against_their_frame() {
2866        let g = PageGeometry::default(); // 612 x 792, 72pt margins
2867        let para_top = 100.0;
2868        let off = 10.0;
2869
2870        assert_eq!(
2871            resolve_anchor_h(ST_RelativeFromH::Page, off, None, 0.0, &g, 0.0),
2872            10.0
2873        );
2874        assert_eq!(
2875            resolve_anchor_h(ST_RelativeFromH::LeftMargin, off, None, 0.0, &g, 0.0),
2876            10.0
2877        );
2878        assert_eq!(
2879            resolve_anchor_h(ST_RelativeFromH::Margin, off, None, 0.0, &g, 0.0),
2880            82.0,
2881            "margin-relative starts at the left margin"
2882        );
2883        assert_eq!(
2884            resolve_anchor_h(ST_RelativeFromH::Column, off, None, 0.0, &g, 0.0),
2885            82.0,
2886            "column-relative starts at the text area"
2887        );
2888        assert_eq!(
2889            resolve_anchor_h(ST_RelativeFromH::RightMargin, off, None, 0.0, &g, 0.0),
2890            550.0,
2891            "right-margin-relative starts at the right margin edge"
2892        );
2893        assert_eq!(
2894            resolve_anchor_h(ST_RelativeFromH::Character, off, None, 0.0, &g, 36.0),
2895            118.0,
2896            "character-relative includes the paragraph indent"
2897        );
2898
2899        assert_eq!(
2900            resolve_anchor_v(ST_RelativeFromV::Page, off, None, 0.0, &g, para_top),
2901            10.0
2902        );
2903        assert_eq!(
2904            resolve_anchor_v(ST_RelativeFromV::TopMargin, off, None, 0.0, &g, para_top),
2905            10.0
2906        );
2907        assert_eq!(
2908            resolve_anchor_v(ST_RelativeFromV::Margin, off, None, 0.0, &g, para_top),
2909            82.0
2910        );
2911        assert_eq!(
2912            resolve_anchor_v(ST_RelativeFromV::Paragraph, off, None, 0.0, &g, para_top),
2913            182.0,
2914            "paragraph-relative follows the paragraph down the page"
2915        );
2916        assert_eq!(
2917            resolve_anchor_v(ST_RelativeFromV::Line, off, None, 0.0, &g, para_top),
2918            182.0
2919        );
2920        assert_eq!(
2921            resolve_anchor_v(ST_RelativeFromV::BottomMargin, off, None, 0.0, &g, para_top),
2922            730.0
2923        );
2924    }
2925
2926    /// The same offset must land somewhere different once the paragraph moves.
2927    /// This is the property the old code could not express at all.
2928    #[test]
2929    fn paragraph_relative_anchor_tracks_the_paragraph() {
2930        let g = PageGeometry::default();
2931        let near_top = resolve_anchor_v(ST_RelativeFromV::Paragraph, 5.0, None, 0.0, &g, 0.0);
2932        let further_down = resolve_anchor_v(ST_RelativeFromV::Paragraph, 5.0, None, 0.0, &g, 300.0);
2933        assert_eq!(near_top, 77.0);
2934        assert_eq!(further_down, 377.0);
2935        assert!(further_down > near_top);
2936    }
2937
2938    // F-X016, alignment placement and text wrapping.
2939
2940    #[test]
2941    fn an_aligned_anchor_resolves_against_its_frame() {
2942        let g = PageGeometry::default();
2943        let width = 100.0;
2944        let height = 50.0;
2945
2946        // Margin frame: the text area.
2947        let text_left = g.margin_left;
2948        let text_width = g.page_width - g.margin_left - g.margin_right;
2949
2950        assert_eq!(
2951            resolve_anchor_h(
2952                ST_RelativeFromH::Margin,
2953                999.0,
2954                Some(AnchorAlignH::Left),
2955                width,
2956                &g,
2957                0.0
2958            ),
2959            text_left,
2960            "an alignment replaces the offset rather than adding to it"
2961        );
2962        assert_eq!(
2963            resolve_anchor_h(
2964                ST_RelativeFromH::Margin,
2965                0.0,
2966                Some(AnchorAlignH::Right),
2967                width,
2968                &g,
2969                0.0
2970            ),
2971            text_left + text_width - width
2972        );
2973        assert_eq!(
2974            resolve_anchor_h(
2975                ST_RelativeFromH::Margin,
2976                0.0,
2977                Some(AnchorAlignH::Center),
2978                width,
2979                &g,
2980                0.0
2981            ),
2982            text_left + (text_width - width) / 2.0
2983        );
2984
2985        // Page frame, vertical axis.
2986        assert_eq!(
2987            resolve_anchor_v(
2988                ST_RelativeFromV::Page,
2989                0.0,
2990                Some(AnchorAlignV::Top),
2991                height,
2992                &g,
2993                0.0
2994            ),
2995            0.0
2996        );
2997        assert_eq!(
2998            resolve_anchor_v(
2999                ST_RelativeFromV::Page,
3000                0.0,
3001                Some(AnchorAlignV::Bottom),
3002                height,
3003                &g,
3004                0.0
3005            ),
3006            g.page_height - height
3007        );
3008    }
3009
3010    #[test]
3011    fn an_anchor_without_an_alignment_still_uses_its_offset() {
3012        // This is what keeps every existing baseline still.
3013        let g = PageGeometry::default();
3014        assert_eq!(
3015            resolve_anchor_h(ST_RelativeFromH::Page, 10.0, None, 100.0, &g, 0.0),
3016            10.0
3017        );
3018        assert_eq!(
3019            resolve_anchor_h(ST_RelativeFromH::Margin, 10.0, None, 100.0, &g, 0.0),
3020            g.margin_left + 10.0
3021        );
3022        assert_eq!(
3023            resolve_anchor_v(ST_RelativeFromV::Paragraph, 5.0, None, 50.0, &g, 300.0),
3024            g.margin_top + 300.0 + 5.0
3025        );
3026    }
3027}