Skip to main content

pdf_inspector/extractor/
mod.rs

1//! Text extraction from PDF using lopdf
2//!
3//! This module extracts text with position information for structure detection.
4
5mod base14;
6mod clip_boundaries;
7pub(crate) mod content_decode;
8pub(crate) mod content_stream;
9pub(crate) mod display_frame;
10pub(crate) mod fonts;
11pub(crate) mod geometry;
12mod layout;
13mod links;
14pub(crate) mod page_box;
15mod reading_order;
16mod scripts;
17mod text_paint;
18mod type1;
19pub(crate) mod underline;
20pub(crate) mod word_gaps;
21mod xobjects;
22
23use crate::text_utils::{is_cjk_char, is_rtl_text};
24use crate::tounicode::FontCMaps;
25use crate::types::{CMapCoverageByFont, PageExtraction, PdfLine, PdfRect, RunCoverage, TextItem};
26use crate::PdfError;
27use log::debug;
28use lopdf::{Document, Object, ObjectId};
29use std::borrow::Cow;
30use std::collections::{HashMap, HashSet};
31use std::path::Path;
32
33use content_stream::extract_page_text_items_with_options;
34pub(crate) use content_stream::TextExtractionOptions;
35pub(crate) use display_frame::DisplayPage;
36pub use display_frame::PositionFrame;
37
38/// Options of the positioned-text and region APIs, taken by their
39/// `_with_options` variants ([`extract_text_with_positions_mem_with_options`],
40/// [`extract_text_with_positions_and_rotations_mem_with_options`],
41/// [`extract_text_in_regions_mem_with_options`](crate::extract_text_in_regions_mem_with_options),
42/// [`extract_tables_in_regions_mem_with_options`](crate::extract_tables_in_regions_mem_with_options)).
43/// The default is what the plain and `_in_frame` variants do.
44///
45/// ```
46/// use pdf_inspector::{PositionFrame, PositionOptions};
47///
48/// let options = PositionOptions::new()
49///     .frame(PositionFrame::Display)
50///     .bold_from_weight(true)
51///     .bold_weight_threshold(700);
52/// ```
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct PositionOptions {
56    /// Coordinate frame items are reported in and region rects are read
57    /// in; [`PositionFrame::Sheet`] by default.
58    pub frame: PositionFrame,
59    /// Read bold from the font's weight class as well. When set,
60    /// `TextItem::is_bold` is also `true` for items whose
61    /// `TextItem::font_weight` is `bold_weight_threshold` (600, SemiBold,
62    /// by default) or more, `TextItem::bold_source` says so, and adjacent
63    /// runs are merged by that verdict: a run the weight makes bold stays
64    /// apart from its plain neighbours, so a heavier run inside a lighter
65    /// paragraph keeps its own item, while runs whose weights differ but
66    /// agree on bold — both below the threshold, or both at or above it —
67    /// merge as usual. Off by default: `is_bold` and item merging are then
68    /// exactly what they were before the option existed, and `font_weight`
69    /// is reported either way.
70    pub bold_from_weight: bool,
71    /// The weight class from which `bold_from_weight` reads bold, on the
72    /// 100..=900 scale: 600 by default, so SemiBold and heavier faces are
73    /// bold. It matters only when `bold_from_weight` is set; a value outside
74    /// the scale is clamped into it either way.
75    pub bold_weight_threshold: u16,
76}
77
78impl Default for PositionOptions {
79    fn default() -> Self {
80        Self {
81            frame: PositionFrame::default(),
82            bold_from_weight: false,
83            bold_weight_threshold: content_stream::DEFAULT_BOLD_WEIGHT_THRESHOLD,
84        }
85    }
86}
87
88impl PositionOptions {
89    /// The defaults: sheet frame, bold not read from the weight class, a
90    /// threshold of 600 for when it is.
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    /// Set the coordinate frame.
96    pub fn frame(mut self, frame: PositionFrame) -> Self {
97        self.frame = frame;
98        self
99    }
100
101    /// Set whether bold is also read from the weight class.
102    pub fn bold_from_weight(mut self, bold_from_weight: bool) -> Self {
103        self.bold_from_weight = bold_from_weight;
104        self
105    }
106
107    /// Set the weight class from which bold is read when `bold_from_weight`
108    /// is on: 600 by default, valid values 100..=900.
109    pub fn bold_weight_threshold(mut self, threshold: u16) -> Self {
110        self.bold_weight_threshold = threshold;
111        self
112    }
113
114    /// The content-stream switches these options ask for.
115    pub(crate) fn text_extraction(self, include_invisible: bool) -> TextExtractionOptions {
116        TextExtractionOptions {
117            include_invisible,
118            bold_from_weight: self.bold_from_weight,
119            bold_weight_threshold: self.bold_weight_threshold.clamp(100, 900),
120            // The position readers report no CMap coverage.
121            cmap_coverage: false,
122        }
123    }
124}
125use links::{extract_form_fields, extract_page_links};
126pub(crate) use page_box::{visible_page_box, PageBox};
127
128// Re-export public types so existing `crate::extractor::X` paths keep working.
129pub use crate::text_utils::{is_bold_font, is_italic_font};
130pub use crate::types::{ItemType, TextLine};
131pub(crate) use fonts::FontStyleCache;
132pub(crate) use layout::detect_columns;
133#[cfg(test)]
134use layout::filter_markdown_page_numbers;
135pub(crate) use layout::filter_markdown_page_numbers_with_removed_pages;
136pub(crate) use layout::group_into_lines_with_thresholds;
137pub(crate) use layout::group_prefiltered_items_into_lines_with_thresholds_and_charts;
138pub(crate) use layout::group_prefiltered_items_into_lines_with_thresholds_and_regions;
139pub(crate) use layout::is_newspaper_layout;
140pub(crate) use layout::ColumnRegion;
141pub use layout::{group_into_lines, group_into_lines_preserving_all_text};
142pub(crate) use scripts::merge_subscript_items;
143pub(crate) use xobjects::FormWalkBudget;
144
145// ---------------------------------------------------------------------------
146// Public API
147// ---------------------------------------------------------------------------
148
149pub(crate) fn trace_text_preview(text: &str, max_chars: usize) -> &str {
150    match text.char_indices().nth(max_chars) {
151        Some((idx, _)) => &text[..idx],
152        None => text,
153    }
154}
155
156/// Extract text from PDF file as plain string
157pub fn extract_text<P: AsRef<Path>>(path: P) -> Result<String, PdfError> {
158    crate::validate_pdf_file(&path)?;
159    let (doc, _) = crate::load_document_from_path(&path)?;
160    extract_text_from_doc(&doc)
161}
162
163/// Extract text from PDF memory buffer
164pub fn extract_text_mem(buffer: &[u8]) -> Result<String, PdfError> {
165    crate::validate_pdf_bytes(buffer)?;
166    let (doc, _) = crate::load_document_from_mem(buffer)?;
167    extract_text_from_doc(&doc)
168}
169
170/// Extract text from loaded document
171fn extract_text_from_doc(doc: &Document) -> Result<String, PdfError> {
172    let pages = doc.get_pages();
173    let page_nums: Vec<u32> = pages.keys().cloned().collect();
174
175    doc.extract_text(&page_nums)
176        .map_err(|e| PdfError::Parse(e.to_string()))
177}
178
179/// Extract text with position information from a PDF file.
180///
181/// # Coordinate frame
182///
183/// Every positioned item (text, image placeholder, link, form field) is
184/// reported in PDF points relative to the page's **visible page box**:
185/// `CropBox ∩ MediaBox` when the page has a CropBox, else the MediaBox. The
186/// box's lower-left corner is the origin and `y` grows upward. A renderer's
187/// page image and the region APIs ([`crate::extract_text_in_regions_mem`]
188/// and friends) use the same box from its top-left corner with `y` growing
189/// downward, so flipping `y` by the box height moves between the two. Raw
190/// content-stream coordinates differ whenever the CropBox or MediaBox origin
191/// is not `(0, 0)`. Pages whose text is drawn rotated by 90° are normalized
192/// into a synthetic landscape frame before the shift; `/Rotate` is not
193/// applied. See [`TextItem`] for the full note.
194pub fn extract_text_with_positions<P: AsRef<Path>>(path: P) -> Result<Vec<TextItem>, PdfError> {
195    extract_text_with_positions_pages(path, None)
196}
197
198/// Extract text with positions from a file, limited to specific pages.
199///
200/// `page_filter` is an optional set of 1-indexed page numbers to process.
201/// When `None`, all pages are processed. Coordinates: see
202/// [`extract_text_with_positions`].
203pub fn extract_text_with_positions_pages<P: AsRef<Path>>(
204    path: P,
205    page_filter: Option<&HashSet<u32>>,
206) -> Result<Vec<TextItem>, PdfError> {
207    let (items, _rects, _lines) =
208        extract_text_with_positions_and_rects_with_password(path, page_filter, None)?;
209    Ok(items)
210}
211
212/// Extract text with positions from a file, limited to specific pages and
213/// decrypting with `password` when the PDF is encrypted.
214///
215/// `page_filter` is an optional set of 1-indexed page numbers to process.
216/// When `None`, all pages are processed. Coordinates: see
217/// [`extract_text_with_positions`].
218pub fn extract_text_with_positions_pages_with_password<P: AsRef<Path>>(
219    path: P,
220    page_filter: Option<&HashSet<u32>>,
221    password: Option<&str>,
222) -> Result<Vec<TextItem>, PdfError> {
223    let (items, _rects, _lines) =
224        extract_text_with_positions_and_rects_with_password(path, page_filter, password)?;
225    Ok(items)
226}
227
228pub(crate) fn extract_text_with_positions_and_rects_with_password<P: AsRef<Path>>(
229    path: P,
230    page_filter: Option<&HashSet<u32>>,
231    password: Option<&str>,
232) -> Result<PageExtraction, PdfError> {
233    crate::validate_pdf_file(&path)?;
234    let (doc, _) = crate::load_document_from_path_with_password(&path, password)?;
235    let font_cmaps = FontCMaps::from_doc(&doc);
236    let (extraction, _thresholds, _gid_pages, _page_rotations, _cmap_coverage) =
237        extract_positioned_text_from_doc_in_page_box(
238            &doc,
239            &font_cmaps,
240            page_filter,
241            PositionOptions::default(),
242        )?;
243    Ok(extraction)
244}
245
246/// Extract text with positions from a memory buffer. Coordinates: see
247/// [`extract_text_with_positions`].
248pub fn extract_text_with_positions_mem(buffer: &[u8]) -> Result<Vec<TextItem>, PdfError> {
249    extract_text_with_positions_mem_pages(buffer, None)
250}
251
252/// Extract text with positions from a memory buffer, limited to specific
253/// pages. Coordinates: see [`extract_text_with_positions`].
254pub fn extract_text_with_positions_mem_pages(
255    buffer: &[u8],
256    page_filter: Option<&HashSet<u32>>,
257) -> Result<Vec<TextItem>, PdfError> {
258    extract_text_with_positions_mem_in_frame(buffer, page_filter, PositionFrame::Sheet)
259}
260
261/// Extract text with positions from a memory buffer in the given coordinate
262/// frame, limited to specific pages.
263///
264/// [`PositionFrame::Sheet`] is the frame of [`extract_text_with_positions`]:
265/// the visible page box as laid out in the content stream, `/Rotate` not
266/// applied, with predominantly rotated pages turned so their text reads
267/// left-to-right. [`PositionFrame::Display`] reports every item — text and
268/// image placeholders, links and form fields alike — in the rendered page's
269/// frame instead: the visible box turned clockwise by the page's inheritable
270/// `/Rotate`, lower-left origin, `y` up, with the turn of a rotated page
271/// undone first. Baseline angles (`TextItem::rotation`) are expressed in the
272/// same frame, so text that renders horizontally reads as `0`.
273pub fn extract_text_with_positions_mem_in_frame(
274    buffer: &[u8],
275    page_filter: Option<&HashSet<u32>>,
276    frame: PositionFrame,
277) -> Result<Vec<TextItem>, PdfError> {
278    extract_text_with_positions_mem_with_options(
279        buffer,
280        page_filter,
281        PositionOptions::new().frame(frame),
282    )
283}
284
285/// [`extract_text_with_positions_mem_in_frame`] with every option of the
286/// positioned-text APIs given as a [`PositionOptions`]: the frame, and
287/// whether bold is also read from the font's weight class
288/// (`bold_from_weight`). The default options are that function with
289/// [`PositionFrame::Sheet`].
290pub fn extract_text_with_positions_mem_with_options(
291    buffer: &[u8],
292    page_filter: Option<&HashSet<u32>>,
293    options: PositionOptions,
294) -> Result<Vec<TextItem>, PdfError> {
295    let (items, _page_rotations) =
296        extract_text_with_positions_and_rotations_mem_with_options(buffer, page_filter, options)?;
297    Ok(items)
298}
299
300/// Extract text with positions from a memory buffer, together with the
301/// coordinate frame of every page whose text was predominantly rotated.
302///
303/// Such pages are re-based so their dominant runs read left-to-right (see
304/// [`PageRotation`](crate::PageRotation)); their items live in the turned
305/// frame, and region boxes given in page coordinates must be turned the same
306/// way with [`collect_text_in_region_in_frame`](crate::collect_text_in_region_in_frame).
307/// Pages absent from the map are upright. Keys are 1-indexed like
308/// `TextItem::page`.
309pub fn extract_text_with_positions_and_rotations_mem(
310    buffer: &[u8],
311) -> Result<(Vec<TextItem>, HashMap<u32, geometry::PageRotation>), PdfError> {
312    extract_text_with_positions_and_rotations_mem_in_frame(buffer, None, PositionFrame::Sheet)
313}
314
315/// [`extract_text_with_positions_and_rotations_mem`] limited to specific
316/// pages and reporting items in the given coordinate frame (see
317/// [`extract_text_with_positions_mem_in_frame`]).
318///
319/// The returned map names the pages whose text was predominantly rotated
320/// whatever the frame: in the display frame their turn has already been
321/// undone, so the map is informational there.
322pub fn extract_text_with_positions_and_rotations_mem_in_frame(
323    buffer: &[u8],
324    page_filter: Option<&HashSet<u32>>,
325    frame: PositionFrame,
326) -> Result<(Vec<TextItem>, HashMap<u32, geometry::PageRotation>), PdfError> {
327    extract_text_with_positions_and_rotations_mem_with_options(
328        buffer,
329        page_filter,
330        PositionOptions::new().frame(frame),
331    )
332}
333
334/// [`extract_text_with_positions_and_rotations_mem_in_frame`] with every
335/// option given as a [`PositionOptions`] — see
336/// [`extract_text_with_positions_mem_with_options`].
337pub fn extract_text_with_positions_and_rotations_mem_with_options(
338    buffer: &[u8],
339    page_filter: Option<&HashSet<u32>>,
340    options: PositionOptions,
341) -> Result<(Vec<TextItem>, HashMap<u32, geometry::PageRotation>), PdfError> {
342    crate::validate_pdf_bytes(buffer)?;
343    let (doc, _) = crate::load_document_from_mem(buffer)?;
344    let font_cmaps = FontCMaps::from_doc(&doc);
345    let ((mut items, _rects, _lines), _thresholds, _gid_pages, page_rotations, _cmap_coverage) =
346        extract_positioned_text_from_doc_in_page_box(&doc, &font_cmaps, page_filter, options)?;
347    if options.frame == PositionFrame::Display {
348        display_frame::document_items_to_display_frame(&doc, &mut items, &page_rotations);
349    }
350    Ok((items, page_rotations))
351}
352
353/// One page's geometry in the visible-page-box frame, from
354/// [`extract_page_text_items_in_page_box`].
355pub(crate) struct PageBoxExtraction {
356    pub(crate) items: Vec<TextItem>,
357    pub(crate) rects: Vec<PdfRect>,
358    pub(crate) lines: Vec<PdfLine>,
359    /// Fonts with unresolvable gid-encoded glyphs were encountered.
360    pub(crate) has_gid_fonts: bool,
361    /// How the page's frame was turned so its text reads left-to-right
362    /// (see `content_stream::correct_rotated_page`); `Upright` when it was
363    /// not.
364    pub(crate) coords_rotated: geometry::PageRotation,
365    /// Invisible (Tr 3) text was skipped and could be recovered with
366    /// `include_invisible`.
367    pub(crate) skipped_invisible: bool,
368    /// The visible page box the geometry was shifted into; its height is the
369    /// flip height for top-left-origin region inputs.
370    pub(crate) page_box: PageBox,
371}
372
373/// Page-scoped extraction for the region APIs: `extract_page_text_items`
374/// followed by the visible-page-box shift every one of them must apply, so
375/// no caller can translate items but forget rects, or skip the rotated
376/// shift. Region bounds then flip `y` with `page_box.height()`.
377pub(crate) fn extract_page_text_items_in_page_box(
378    doc: &Document,
379    page_id: ObjectId,
380    page_num: u32,
381    font_cmaps: &FontCMaps,
382    include_invisible: bool,
383    style_cache: &mut FontStyleCache,
384    form_budget: &mut FormWalkBudget,
385) -> Result<PageBoxExtraction, PdfError> {
386    extract_page_text_items_in_page_box_with_options(
387        doc,
388        page_id,
389        page_num,
390        font_cmaps,
391        TextExtractionOptions {
392            include_invisible,
393            ..TextExtractionOptions::default()
394        },
395        style_cache,
396        form_budget,
397    )
398}
399
400/// [`extract_page_text_items_in_page_box`] with every content-stream switch
401/// given.
402pub(crate) fn extract_page_text_items_in_page_box_with_options(
403    doc: &Document,
404    page_id: ObjectId,
405    page_num: u32,
406    font_cmaps: &FontCMaps,
407    options: TextExtractionOptions,
408    style_cache: &mut FontStyleCache,
409    form_budget: &mut FormWalkBudget,
410) -> Result<PageBoxExtraction, PdfError> {
411    let page_box = visible_page_box(doc, page_id).unwrap_or(PageBox::LETTER);
412    // The region APIs read one page at a time and report no CMap coverage.
413    let (
414        (mut items, mut rects, mut lines),
415        has_gid_fonts,
416        coords_rotated,
417        skipped_invisible,
418        _run_coverage,
419    ) = extract_page_text_items_with_options(
420        doc,
421        page_id,
422        page_num,
423        font_cmaps,
424        options,
425        style_cache,
426        form_budget,
427    )?;
428    page_box.translate_page(&mut items, &mut rects, &mut lines, coords_rotated);
429    Ok(PageBoxExtraction {
430        items,
431        rects,
432        lines,
433        has_gid_fonts,
434        coords_rotated,
435        skipped_invisible,
436        page_box,
437    })
438}
439
440// ---------------------------------------------------------------------------
441// Orchestration
442// ---------------------------------------------------------------------------
443
444/// Per-page adaptive join thresholds from Canva-style letter-spacing detection.
445pub(crate) type PageThresholds = HashMap<u32, f32>;
446
447/// Pages (1-indexed, like `TextItem::page`) whose coordinate frame was turned
448/// because their text is predominantly rotated; pages absent from the map
449/// are upright.
450pub(crate) type PageRotations = HashMap<u32, geometry::PageRotation>;
451
452/// What a document-level extraction returns: the text, rectangles and lines
453/// of the extracted pages, their join thresholds, the pages with gid-encoded
454/// fonts, their frame rotations and, per font, the two-byte codes shown
455/// through the font's CMap and how many of them the CMap had no entry for.
456pub(crate) type DocumentExtraction = (
457    PageExtraction,
458    PageThresholds,
459    HashSet<u32>,
460    PageRotations,
461    CMapCoverageByFont,
462);
463
464/// Extract positioned text, rectangles, and line segments from a pre-loaded document.
465///
466/// Also returns per-page adaptive join thresholds for Canva-style pages.
467pub(crate) fn extract_positioned_text_from_doc(
468    doc: &Document,
469    font_cmaps: &FontCMaps,
470    page_filter: Option<&HashSet<u32>>,
471) -> Result<DocumentExtraction, PdfError> {
472    extract_positioned_text_impl(
473        doc,
474        font_cmaps,
475        page_filter,
476        TextExtractionOptions::default(),
477        None,
478        CoordinateFrame::UserSpace,
479    )
480}
481
482/// [`extract_positioned_text_from_doc`] with every page's geometry shifted
483/// into the visible-page-box frame — what the public position APIs return.
484/// A page whose frame was turned (see `PageRotation`) gets the equivalently
485/// turned shift, so its items, links and form fields agree with region
486/// bounds computed from the visible box.
487pub(crate) fn extract_positioned_text_from_doc_in_page_box(
488    doc: &Document,
489    font_cmaps: &FontCMaps,
490    page_filter: Option<&HashSet<u32>>,
491    options: PositionOptions,
492) -> Result<DocumentExtraction, PdfError> {
493    extract_positioned_text_impl(
494        doc,
495        font_cmaps,
496        page_filter,
497        options.text_extraction(false),
498        None,
499        CoordinateFrame::VisiblePageBox,
500    )
501}
502
503/// Coordinate frame of the geometry a positioned-text extraction returns.
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub(crate) enum CoordinateFrame {
506    /// Raw PDF user space, as written in the content stream. The markdown
507    /// pipeline works here; its page-edge heuristics never leave it.
508    UserSpace,
509    /// The visible page box ([`PageBox`]) with its lower-left corner as the
510    /// origin — the frame of a rendered page image. Public position APIs
511    /// return this so items can be intersected with rendered regions.
512    VisiblePageBox,
513}
514
515/// Extract selected pages and gather document-wide folio evidence only when a
516/// selected page contains an ambiguous contextual page-edge number. Errors on
517/// selected pages remain fatal; errors on context-only pages are skipped.
518pub(crate) fn extract_positioned_text_with_folio_context(
519    doc: &Document,
520    font_cmaps: &FontCMaps,
521    page_filter: Option<&HashSet<u32>>,
522) -> Result<DocumentExtraction, PdfError> {
523    extract_positioned_text_with_folio_context_impl(doc, font_cmaps, page_filter, false)
524}
525
526/// Invisible-text variant of [`extract_positioned_text_with_folio_context`].
527pub(crate) fn extract_positioned_text_include_invisible_with_folio_context(
528    doc: &Document,
529    font_cmaps: &FontCMaps,
530    page_filter: Option<&HashSet<u32>>,
531) -> Result<DocumentExtraction, PdfError> {
532    extract_positioned_text_with_folio_context_impl(doc, font_cmaps, page_filter, true)
533}
534
535fn extract_positioned_text_with_folio_context_impl(
536    doc: &Document,
537    font_cmaps: &FontCMaps,
538    page_filter: Option<&HashSet<u32>>,
539    include_invisible: bool,
540) -> Result<DocumentExtraction, PdfError> {
541    // The selected pages report their CMap coverage; the context pages
542    // gathered below for their folio evidence do not.
543    let options = TextExtractionOptions {
544        include_invisible,
545        cmap_coverage: true,
546        ..TextExtractionOptions::default()
547    };
548    let Some(required_pages) = page_filter else {
549        return extract_positioned_text_impl(
550            doc,
551            font_cmaps,
552            None,
553            options,
554            None,
555            CoordinateFrame::UserSpace,
556        );
557    };
558
559    let (
560        (mut selected_items, mut selected_rects, mut selected_lines),
561        mut page_thresholds,
562        mut gid_encoded_pages,
563        mut page_rotations,
564        cmap_coverage,
565    ) = extract_positioned_text_impl(
566        doc,
567        font_cmaps,
568        Some(required_pages),
569        options,
570        None,
571        CoordinateFrame::UserSpace,
572    )?;
573    if !layout::needs_document_page_number_context(&selected_items, doc.get_pages().len()) {
574        return Ok((
575            (selected_items, selected_rects, selected_lines),
576            page_thresholds,
577            gid_encoded_pages,
578            page_rotations,
579            cmap_coverage,
580        ));
581    }
582
583    let context_pages: HashSet<u32> = doc
584        .get_pages()
585        .keys()
586        .copied()
587        .filter(|page| !required_pages.contains(page))
588        .collect();
589    // The context pages' items are dropped again once the folios are
590    // decided; their fonts' coverage is no part of the selected pages'
591    // either, so it is left out here.
592    let (
593        (context_items, context_rects, context_lines),
594        context_thresholds,
595        context_gid_pages,
596        context_rotations,
597        _context_coverage,
598    ) = extract_positioned_text_impl(
599        doc,
600        font_cmaps,
601        Some(&context_pages),
602        TextExtractionOptions {
603            cmap_coverage: false,
604            ..options
605        },
606        Some(required_pages),
607        CoordinateFrame::UserSpace,
608    )?;
609    selected_items.extend(context_items);
610    selected_rects.extend(context_rects);
611    selected_lines.extend(context_lines);
612    page_thresholds.extend(context_thresholds);
613    gid_encoded_pages.extend(context_gid_pages);
614    page_rotations.extend(context_rotations);
615    Ok((
616        (selected_items, selected_rects, selected_lines),
617        page_thresholds,
618        gid_encoded_pages,
619        page_rotations,
620        cmap_coverage,
621    ))
622}
623
624/// Extract all pages for document-wide analysis while allowing malformed
625/// unselected pages to be skipped. Any requested page still fails normally.
626pub(crate) fn extract_positioned_text_for_document_analysis(
627    doc: &Document,
628    font_cmaps: &FontCMaps,
629    required_pages: &HashSet<u32>,
630) -> Result<DocumentExtraction, PdfError> {
631    extract_positioned_text_impl(
632        doc,
633        font_cmaps,
634        None,
635        TextExtractionOptions::default(),
636        Some(required_pages),
637        CoordinateFrame::UserSpace,
638    )
639}
640
641fn extract_positioned_text_impl(
642    doc: &Document,
643    font_cmaps: &FontCMaps,
644    page_filter: Option<&HashSet<u32>>,
645    options: TextExtractionOptions,
646    required_pages: Option<&HashSet<u32>>,
647    frame: CoordinateFrame,
648) -> Result<DocumentExtraction, PdfError> {
649    let pages = doc.get_pages();
650    let mut all_items = Vec::new();
651    let mut all_rects = Vec::new();
652    let mut all_lines = Vec::new();
653    let mut page_thresholds: PageThresholds = HashMap::new();
654    let mut gid_encoded_pages: HashSet<u32> = HashSet::new();
655    // Per font, the codes shown through its CMap over every extracted page.
656    let mut cmap_coverage = CMapCoverageByFont::new();
657    // Embedded-font style flags are document-scoped: the same font program
658    // is shared across pages, so parse it once, not once per page.
659    let mut style_cache = FontStyleCache::new();
660    // Pages whose coordinate frame was turned (see `PageRotation`): the
661    // document-level annotation items appended below must follow.
662    let mut page_rotations: PageRotations = HashMap::new();
663    // Visible page box per extracted page, for the form-field shift below.
664    let mut page_boxes: HashMap<u32, PageBox> = HashMap::new();
665
666    // Build page ObjectId → page number map for form field extraction
667    let page_id_to_num: HashMap<ObjectId, u32> =
668        pages.iter().map(|(num, &id)| (id, *num)).collect();
669
670    for (page_num, &page_id) in pages.iter() {
671        if let Some(filter) = page_filter {
672            if !filter.contains(page_num) {
673                continue;
674            }
675        }
676        let page_result = extract_page_text_items_with_options(
677            doc,
678            page_id,
679            *page_num,
680            font_cmaps,
681            options,
682            &mut style_cache,
683            &mut FormWalkBudget::new(),
684        );
685        let (
686            (mut items, mut rects, mut lines),
687            has_gid_fonts,
688            coords_rotated,
689            _skipped_invisible,
690            mut run_coverage,
691        ) = match page_result {
692            Ok(extraction) => extraction,
693            Err(error) if required_pages.is_some_and(|required| !required.contains(page_num)) => {
694                debug!(
695                    "page {}: skipping context-only extraction error: {}",
696                    page_num, error
697                );
698                continue;
699            }
700            Err(error) => return Err(error),
701        };
702        if coords_rotated != geometry::PageRotation::Upright {
703            page_rotations.insert(*page_num, coords_rotated);
704        }
705        // Clip to the visible page box: single-page extracts and imposed
706        // spreads keep neighboring pages' content in the stream, positioned
707        // outside the CropBox. Extracting it interleaves invisible text into
708        // the page and poisons font statistics. On a turned page the box is
709        // turned the same way, so the test runs in the items' own frame.
710        let page_box = visible_page_box(doc, page_id);
711        let mut clipped_box: Option<(f32, f32, f32, f32)> = None;
712        let clip_box = page_box.map(|b| {
713            let (mut x, mut y, mut w, mut h) = (b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0);
714            coords_rotated.rotate_box(&mut x, &mut y, &mut w, &mut h);
715            (x, y, x + w, y + h)
716        });
717        if let Some((bx0, by0, bx1, by1)) = clip_box {
718            const TOL: f32 = 6.0;
719            let outside = |it: &TextItem| {
720                let cx = it.x + it.width / 2.0;
721                !(cx >= bx0 - TOL && cx <= bx1 + TOL && it.y >= by0 - TOL && it.y <= by1 + TOL)
722            };
723            // Only clip when the off-page material reads as coherent text
724            // (neighboring-page paragraphs). Curved/rotated display text
725            // leaves short glyph fragments with artifact coordinates
726            // outside the box, and those must stay.
727            let off: Vec<&TextItem> = items.iter().filter(|it| outside(it)).collect();
728            // Judge by character mass: paragraphs are dominated by long
729            // word runs even when interleaved with short math fragments,
730            // while glyph-confetti is short items through and through.
731            let total_chars: usize = off.iter().map(|it| it.text.trim().chars().count()).sum();
732            let wordy_chars: usize = off
733                .iter()
734                .map(|it| it.text.trim().chars().count())
735                .filter(|&n| n >= 4)
736                .sum();
737            // Genuine neighboring-page content is cleanly separated from
738            // on-page text. When an off-page item continues an on-page
739            // line (same baseline, near-adjacent x), the coordinates are
740            // artifacts of transforms we mis-model — don't clip those.
741            let straddles = off.iter().any(|o| {
742                items.iter().any(|i| {
743                    !outside(i) && (i.y - o.y).abs() <= 2.0 && (o.x - (i.x + i.width)).abs() <= 10.0
744                })
745            });
746            let coherent = off.len() >= 10 && wordy_chars * 2 >= total_chars.max(1) && !straddles;
747            if bx1 - bx0 >= 72.0 && by1 - by0 >= 72.0 && coherent {
748                let before = items.len();
749                items.retain(|it| !outside(it));
750                // The runs left out take their CMap coverage with them; a
751                // run without a position (see `RunCoverage`) stays.
752                run_coverage.retain(|run| match run.position {
753                    Some((x, y, width)) => {
754                        let cx = x + width / 2.0;
755                        cx >= bx0 - TOL && cx <= bx1 + TOL && y >= by0 - TOL && y <= by1 + TOL
756                    }
757                    None => true,
758                });
759                if items.len() < before {
760                    debug!(
761                        "page {}: clipped {} items outside page box ({:.0},{:.0})-({:.0},{:.0})",
762                        page_num,
763                        before - items.len(),
764                        bx0,
765                        by0,
766                        bx1,
767                        by1
768                    );
769                    // Only prune off-page geometry when off-page text
770                    // existed — same neighboring-page content.
771                    let overlaps = |x: f32, y: f32, w: f32, h: f32| {
772                        let (x0, x1) = if w < 0.0 { (x + w, x) } else { (x, x + w) };
773                        let (y0, y1) = if h < 0.0 { (y + h, y) } else { (y, y + h) };
774                        x0 < bx1 + TOL && x1 > bx0 - TOL && y0 < by1 + TOL && y1 > by0 - TOL
775                    };
776                    rects.retain(|r| overlaps(r.x, r.y, r.width, r.height));
777                    clipped_box = Some((bx0, by0, bx1, by1));
778                    lines.retain(|l| {
779                        overlaps(
780                            l.x1.min(l.x2),
781                            l.y1.min(l.y2),
782                            (l.x2 - l.x1).abs(),
783                            (l.y2 - l.y1).abs(),
784                        )
785                    });
786                }
787            }
788        }
789        if has_gid_fonts {
790            gid_encoded_pages.insert(*page_num);
791        }
792        for RunCoverage { font, stats, .. } in run_coverage {
793            match cmap_coverage.get_mut(&*font) {
794                Some(total) => total.add(stats),
795                None => {
796                    cmap_coverage.insert(font.to_string(), stats);
797                }
798            }
799        }
800        let threshold = crate::text_utils::fix_letterspaced_items(&mut items);
801        if threshold > 0.10 {
802            page_thresholds.insert(*page_num, threshold);
803        }
804        suppress_table_underlines(&mut items, &rects, &lines, *page_num);
805        debug!(
806            "page {}: {} text items, {} rects, {} lines{}",
807            page_num,
808            items.len(),
809            rects.len(),
810            lines.len(),
811            if has_gid_fonts {
812                " [gid-encoded fonts]"
813            } else {
814                ""
815            }
816        );
817        if log::log_enabled!(log::Level::Trace) {
818            for item in &items {
819                log::trace!(
820                    "  p={} x={:7.1} y={:7.1} w={:7.1} fs={:5.1} font={:6} {:?}",
821                    page_num,
822                    item.x,
823                    item.y,
824                    item.width,
825                    item.font_size,
826                    item.font,
827                    trace_text_preview(&item.text, 80)
828                );
829            }
830        }
831        // Public position APIs report geometry relative to the visible page
832        // box; the markdown pipeline stays in raw user space.
833        let frame_box = page_box.unwrap_or(PageBox::LETTER);
834        if frame == CoordinateFrame::VisiblePageBox {
835            frame_box.translate_page(&mut items, &mut rects, &mut lines, coords_rotated);
836        }
837        page_boxes.insert(*page_num, frame_box);
838        all_items.extend(items);
839        all_rects.extend(rects);
840        all_lines.extend(lines);
841
842        // Extract hyperlinks from page annotations. A turned page turns its
843        // annotation boxes too, so links stay on the text they cover and in
844        // the regions that text is assigned to.
845        let mut links = extract_page_links(doc, page_id, *page_num);
846        for link in &mut links {
847            coords_rotated.rotate_box(&mut link.x, &mut link.y, &mut link.width, &mut link.height);
848        }
849        // Annotations from the neighboring page are off-box too.
850        if let Some((bx0, by0, bx1, by1)) = clipped_box {
851            links.retain(|it| {
852                let cx = it.x + it.width / 2.0;
853                // Center-y, not it.y: link items carry an annotation rect,
854                // so y is a box edge — unlike text items, where y is a
855                // baseline and testing it directly is the natural semantics.
856                let cy = it.y + it.height / 2.0;
857                cx >= bx0 - 6.0 && cx <= bx1 + 6.0 && cy >= by0 - 6.0 && cy <= by1 + 6.0
858            });
859        }
860        if frame == CoordinateFrame::VisiblePageBox {
861            // The link rects were turned with the page above, so they take
862            // the same turned shift as the page's items.
863            frame_box.translate_items(&mut links, coords_rotated);
864        }
865        all_items.extend(links);
866    }
867
868    // Extract AcroForm field values. Widgets on a turned page turn with it,
869    // like links, so positional consumers keep them on the text they cover;
870    // in the visible-box frame they take the page's (turned) shift as well.
871    let form_items: Vec<TextItem> = extract_form_fields(doc, &page_id_to_num)
872        .into_iter()
873        .filter(|item| page_filter.is_none_or(|filter| filter.contains(&item.page)))
874        .map(|mut item| {
875            let rotation = page_rotations
876                .get(&item.page)
877                .copied()
878                .unwrap_or(geometry::PageRotation::Upright);
879            rotation.rotate_box(&mut item.x, &mut item.y, &mut item.width, &mut item.height);
880            if frame == CoordinateFrame::VisiblePageBox {
881                let frame_box = page_boxes.get(&item.page).copied().unwrap_or_else(|| {
882                    pages
883                        .get(&item.page)
884                        .and_then(|&id| visible_page_box(doc, id))
885                        .unwrap_or(PageBox::LETTER)
886                });
887                frame_box.translate_items(std::slice::from_mut(&mut item), rotation);
888            }
889            item
890        })
891        .collect();
892    all_items.extend(form_items);
893
894    Ok((
895        (all_items, all_rects, all_lines),
896        page_thresholds,
897        gid_encoded_pages,
898        page_rotations,
899        cmap_coverage,
900    ))
901}
902
903fn suppress_table_underlines(
904    items: &mut [TextItem],
905    rects: &[PdfRect],
906    lines: &[PdfLine],
907    page: u32,
908) {
909    if !items
910        .iter()
911        .any(|item| item.is_underline || item.is_strikeout)
912    {
913        return;
914    }
915
916    let mut table_item_indices: HashSet<usize> = HashSet::new();
917    // A detected "table" that swallows nearly every text item on the page
918    // is a detection artifact (prose pages with boxed callouts or stacked
919    // underline rules read as one giant grid), not a real table — letting
920    // it through here erased every legitimate underline on the page
921    // (text_dense__underline: rect detection claimed 52/52 items). Real
922    // ruled tables share the page with headings, captions, and body text.
923    let plausible = |table: &crate::tables::Table| {
924        // Content sanity gate: prose pages with boxed callouts and stacked
925        // underline rules can detect as a structurally rich "table" that
926        // swallows every item on the page (text_dense__underline: a 4x8
927        // grid claiming 52/52 items, one "cell" holding 806 chars of body
928        // text) — suppressing there erased every legitimate underline on
929        // the page. Real data-table cells are short values; a cell with
930        // hundreds of characters means the grid captured flowing prose.
931        let lens: Vec<usize> = table
932            .cells
933            .iter()
934            .flatten()
935            .filter(|cell| !cell.trim().is_empty())
936            .map(|cell| cell.chars().count())
937            .collect();
938        if lens.is_empty() {
939            return false;
940        }
941        let long = lens.iter().filter(|&&n| n > 100).count();
942        (long as f32) < (lens.len() as f32) * 0.3
943    };
944
945    if !rects.is_empty() {
946        let (rect_tables, _) = crate::tables::detect_tables_from_rects(items, rects, page);
947        for table in rect_tables.iter().filter(|t| plausible(t)) {
948            table_item_indices.extend(table.item_indices.iter().copied());
949        }
950    }
951
952    if !lines.is_empty() {
953        for table in crate::tables::detect_tables_from_lines(items, lines, page)
954            .iter()
955            .filter(|t| plausible(t))
956        {
957            table_item_indices.extend(table.item_indices.iter().copied());
958        }
959    }
960
961    for index in table_item_indices {
962        if let Some(item) = items.get_mut(index) {
963            item.is_underline = false;
964            item.is_strikeout = false;
965        }
966    }
967}
968
969// ---------------------------------------------------------------------------
970// Shared helpers (used by submodules via `super::`)
971// ---------------------------------------------------------------------------
972
973/// Return true when this item should participate in text-layout
974/// heuristics (column detection, table grid detection, line grouping).
975///
976/// Image XObjects emit a positional placeholder via
977/// `extract_text_with_positions` (so layout-aware callers can crop +
978/// caption figures), but their bboxes don't carry text glyphs and would
979/// skew column/row clustering if they reached the heuristics. Hyperlinks
980/// and form fields *do* participate — the existing logic treats them as
981/// text-like and we keep that.
982pub(crate) fn is_text_layout_item(item: &crate::types::TextItem) -> bool {
983    !matches!(item.item_type, crate::types::ItemType::Image)
984}
985
986/// Map a (u, v) point in unit-square coordinates through the 6-element CTM
987/// to page-space. CTM format is `[a, b, c, d, e, f]` per
988/// [`multiply_matrices`].
989fn apply_ctm_point(ctm: &[f32; 6], u: f32, v: f32) -> (f32, f32) {
990    (
991        u * ctm[0] + v * ctm[2] + ctm[4],
992        u * ctm[1] + v * ctm[3] + ctm[5],
993    )
994}
995
996/// Compute the page-space axis-aligned bounding box of an Image XObject
997/// invoked under the given CTM.
998///
999/// Per the PDF spec, an image XObject is always rendered into a unit
1000/// square `(0,0)–(1,1)` in its local coordinate system, and the `Do`
1001/// operator applies the current CTM to position/scale/rotate that square
1002/// onto the page. For the common axis-aligned case (no rotation/shear),
1003/// the CTM reduces to `[w, 0, 0, h, x, y]` and the bbox is just
1004/// `(x, y, w, h)`. For rotated/sheared images we transform all four
1005/// corners and return their axis-aligned bbox so the caller always gets
1006/// an upright rectangle.
1007///
1008/// Coordinates are PDF user space (origin at bottom-left, y-up). Width
1009/// and height are non-negative.
1010pub(crate) fn image_bbox_from_ctm(ctm: &[f32; 6]) -> (f32, f32, f32, f32) {
1011    let corners = [
1012        apply_ctm_point(ctm, 0.0, 0.0),
1013        apply_ctm_point(ctm, 1.0, 0.0),
1014        apply_ctm_point(ctm, 1.0, 1.0),
1015        apply_ctm_point(ctm, 0.0, 1.0),
1016    ];
1017    let (mut x_min, mut x_max) = (corners[0].0, corners[0].0);
1018    let (mut y_min, mut y_max) = (corners[0].1, corners[0].1);
1019    for (cx, cy) in corners.iter().skip(1) {
1020        if *cx < x_min {
1021            x_min = *cx;
1022        }
1023        if *cx > x_max {
1024            x_max = *cx;
1025        }
1026        if *cy < y_min {
1027            y_min = *cy;
1028        }
1029        if *cy > y_max {
1030            y_max = *cy;
1031        }
1032    }
1033    (x_min, y_min, x_max - x_min, y_max - y_min)
1034}
1035
1036/// Multiply two 2D transformation matrices
1037/// Matrix format: [a, b, c, d, e, f] representing:
1038/// | a  b  0 |
1039/// | c  d  0 |
1040/// | e  f  1 |
1041pub(crate) fn multiply_matrices(m1: &[f32; 6], m2: &[f32; 6]) -> [f32; 6] {
1042    [
1043        m1[0] * m2[0] + m1[1] * m2[2],
1044        m1[0] * m2[1] + m1[1] * m2[3],
1045        m1[2] * m2[0] + m1[3] * m2[2],
1046        m1[2] * m2[1] + m1[3] * m2[3],
1047        m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
1048        m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
1049    ]
1050}
1051
1052/// Merge adjacent text items on the same line into single items.
1053///
1054/// Groups items into lines ([`group_fragments_into_lines`]: on one page,
1055/// within 5 pt of the line's first fragment and within a window that follows
1056/// the type size of a fragment already on the line), sorts within
1057/// each line by X, then merges consecutive items that share a similar font
1058/// size and are close horizontally.
1059/// Cap item width for merge-gap computation to guard against Tw inflation.
1060///
1061/// When PDF word-spacing (Tw) is large (used for text justification), the
1062/// advance width of strings containing spaces extends far past the visible
1063/// glyph extent.  This inflated width collapses inter-column gaps, making
1064/// `merge_text_items` incorrectly merge items from different table columns.
1065///
1066/// Only applies to non-CJK items whose text contains spaces (where Tw
1067/// contributes) and whose average width-per-character is abnormally high.
1068/// Extent used to detect overlapping (backtracking) paint order: the merge
1069/// width for measured runs, the estimated box for width-less ones — an
1070/// estimate is still evidence of where a fragment ends, and taking it as zero
1071/// would let a tagged widthless ActualText line be x-sorted out of order.
1072fn order_extent(item: &TextItem) -> f32 {
1073    if item.advance_known {
1074        effective_merge_width(item)
1075    } else {
1076        item.width
1077    }
1078}
1079
1080fn effective_merge_width(item: &TextItem) -> f32 {
1081    use crate::text_utils::is_cjk_char;
1082
1083    // An estimated box (font without widths) is the best extent there is for
1084    // word-gap decisions — as it was when `effective_width` estimated it here;
1085    // the Tw cap below only makes sense for measured widths.
1086    if !item.advance_known {
1087        return item.width;
1088    }
1089    if item.width <= 0.0 || item.font_size <= 0.0 {
1090        return item.width;
1091    }
1092    // Tw only inflates strings that contain space characters.
1093    if !item.text.contains(' ') {
1094        return item.width;
1095    }
1096    // CJK characters are naturally ~1.0× font_size wide; skip the cap.
1097    if item.text.chars().any(is_cjk_char) {
1098        return item.width;
1099    }
1100    let char_count = item.text.chars().count();
1101    if char_count == 0 {
1102        return item.width;
1103    }
1104    let avg = item.width / char_count as f32;
1105    // Normal proportional text: ~0.5× font_size per char.
1106    // Monospace: ~0.6×.  Threshold at 0.85× catches Tw inflation.
1107    if avg > item.font_size * 0.85 {
1108        let capped = char_count as f32 * item.font_size * 0.6;
1109        capped.min(item.width)
1110    } else {
1111        item.width
1112    }
1113}
1114
1115/// Width, as a share of the font size, under which a measured run is a
1116/// glyph without advance.
1117const ZERO_WIDTH_MARK_EM: f32 = 0.01;
1118
1119/// Most characters a dependent sign shown as a run of its own decodes to:
1120/// a sign, or one that decodes to two code points. A longer run without
1121/// advance is hidden text, not a sign.
1122const ZERO_WIDTH_MARK_MAX_CHARS: usize = 2;
1123
1124/// Whether `item` is a dependent sign shown as a run of its own: a glyph
1125/// without advance — a vowel sign, a subscript letter, an accent — by its
1126/// measured width, or a run of nothing but combining marks, of a character
1127/// or two either way. Such a sign is drawn over the glyph before it, behind
1128/// the pen, and the line's right edge does not move for it.
1129fn is_zero_width_mark(item: &TextItem) -> bool {
1130    let text = item.text.trim();
1131    if !(1..=ZERO_WIDTH_MARK_MAX_CHARS).contains(&text.chars().count())
1132        || !matches!(item.item_type, crate::types::ItemType::Text)
1133    {
1134        return false;
1135    }
1136    (item.advance_known && item.width.abs() <= item.font_size.abs() * ZERO_WIDTH_MARK_EM)
1137        || text.chars().all(crate::bidi::is_combining_mark)
1138}
1139
1140/// Whether `x` lies inside the advance of `item`: from its origin to short
1141/// of its end, whichever way it reads.
1142fn inside_advance(item: &TextItem, x: f32) -> bool {
1143    let end = item.x + item.width;
1144    x >= item.x.min(end) && x < item.x.max(end)
1145}
1146
1147/// Sort a line's fragments along +x, keeping a dependent sign right after
1148/// the base it was shown on. A zero-width sign whose origin lies inside
1149/// the advance of the fragment shown before it — over that fragment, short
1150/// of its end — takes that fragment's x as its key and follows it; by its
1151/// own x it would land after the next base when that base is kerned in
1152/// ahead of the pen.
1153fn sort_along_x_keeping_marks(group: &mut [&TextItem]) {
1154    let mut keys: Vec<f32> = Vec::with_capacity(group.len());
1155    // The fragment the one before this sorts with: itself, or for a sign
1156    // kept after its base, that base.
1157    let mut previous_base: Option<usize> = None;
1158    for (index, item) in group.iter().enumerate() {
1159        let base = previous_base
1160            .filter(|&base| is_zero_width_mark(item) && inside_advance(group[base], item.x));
1161        keys.push(base.map_or(item.x, |base| keys[base]));
1162        previous_base = Some(base.unwrap_or(index));
1163    }
1164    let mut order: Vec<usize> = (0..group.len()).collect();
1165    order.sort_by(|&a, &b| keys[a].total_cmp(&keys[b]));
1166    let sorted: Vec<&TextItem> = order.iter().map(|&index| group[index]).collect();
1167    group.copy_from_slice(&sorted);
1168}
1169
1170fn is_standalone_bullet_text(text: &str) -> bool {
1171    matches!(text.trim(), "•" | "○" | "●" | "◦")
1172}
1173
1174fn first_text_char(text: &str) -> Option<char> {
1175    text.trim_start().chars().next()
1176}
1177
1178fn is_short_alpha_fragment(text: &str) -> bool {
1179    let trimmed = text.trim();
1180    let char_count = trimmed.chars().count();
1181    (1..=4).contains(&char_count) && trimmed.chars().all(char::is_alphabetic)
1182}
1183
1184fn has_phrase_continuation_shape(text: &str) -> bool {
1185    let trimmed = text.trim_start();
1186    trimmed
1187        .chars()
1188        .take(24)
1189        .any(|ch| ch.is_whitespace() || matches!(ch, '-'))
1190}
1191
1192/// The fraction of an em within which two baselines belong to one line
1193/// (see [`baseline_tolerance`]).
1194const LINE_BASELINE_TOLERANCE_EM: f32 = 0.6;
1195
1196/// The widest the line window gets, in points: the fixed window that
1197/// grouped lines before the window followed the type, kept as its ceiling
1198/// so that a junction of two fragments of ordinary size groups exactly as
1199/// it did.
1200const LINE_BASELINE_TOLERANCE_MAX_PT: f32 = 5.0;
1201
1202/// The type size at which [`baseline_tolerance`] reaches its ceiling: the
1203/// size a fragment without one is given, so that it keeps the window of old.
1204const LINE_BASELINE_LEGACY_EM: f32 = LINE_BASELINE_TOLERANCE_MAX_PT / LINE_BASELINE_TOLERANCE_EM;
1205
1206/// A fragment's type size for the line test: its rendered em size — the
1207/// box height of an unrotated run, the font size of a rotated one, whose
1208/// box mixes the advance into its height — else its font size, else the
1209/// size that keeps the 5 pt window of old, which an image gets too, its
1210/// height being the image's and not a type size.
1211fn line_em(item: &TextItem) -> f32 {
1212    let em = item.cross_extent();
1213    if matches!(item.item_type, crate::types::ItemType::Image) {
1214        LINE_BASELINE_LEGACY_EM
1215    } else if em > 0.0 {
1216        em
1217    } else if item.font_size > 0.0 {
1218        item.font_size
1219    } else {
1220        LINE_BASELINE_LEGACY_EM
1221    }
1222}
1223
1224/// How far apart two baselines may lie for fragments of type sizes `a` and
1225/// `b` to sit on one line: `LINE_BASELINE_TOLERANCE_EM` of the larger of
1226/// the two, no farther than the smaller, and never more than
1227/// `LINE_BASELINE_TOLERANCE_MAX_PT` — the fixed window of old, which two
1228/// fragments of 8⅓ pt and above therefore keep. Below that the window
1229/// follows the type. A raised or lowered mark is displaced by less than
1230/// its own em — a superscript of six tenths of its line's type rises a
1231/// third of that type, half its own size — so every such mark stays with
1232/// its line, while type of any size cannot pull a fragment of smaller type
1233/// farther than that fragment's em: a line of 4 pt labels beside a column
1234/// of 11 pt text 5 pt lower keeps its own line, and two lines of small
1235/// type on a pitch under 5 pt — a stacked table header at 4.7 pt — stay
1236/// apart, where the fixed window put them in one line and, shown glyph by
1237/// glyph as small kerned type is, interleaved their glyphs along the
1238/// baseline.
1239fn baseline_tolerance(a: f32, b: f32) -> f32 {
1240    (LINE_BASELINE_TOLERANCE_EM * a.max(b))
1241        .min(a.min(b))
1242        .min(LINE_BASELINE_TOLERANCE_MAX_PT)
1243}
1244
1245/// How far apart two baselines must lie to be recorded separately on a
1246/// line, in points.
1247const LINE_BASELINE_RECORD_SPACING: f32 = 0.05;
1248
1249/// The most distinct baselines a line records: as many as the record
1250/// spacing admits inside the fixed window either side of the line's first
1251/// fragment, so no fragment inside that window is ever measured against a
1252/// stale baseline — glyph-by-glyph type set on a slight slope records one
1253/// per glyph — while the walk over them stays bounded.
1254const LINE_BASELINES_MAX: usize =
1255    (2.0 * LINE_BASELINE_TOLERANCE_MAX_PT / LINE_BASELINE_RECORD_SPACING) as usize;
1256
1257/// A line as the fragments are grouped: its page, the baseline of the
1258/// fragment it was seeded with, the distinct baselines its fragments sit
1259/// on (at most `LINE_BASELINES_MAX` of them, each with the largest type
1260/// size seen on it, so that a small glyph shown first on a baseline — a
1261/// bullet, a mark — does not narrow the window for the text that follows
1262/// it), and the fragments.
1263struct FragmentLine {
1264    page: u32,
1265    y: f32,
1266    baselines: Vec<(f32, f32)>,
1267    /// Indices into the fragments being grouped, in stream order.
1268    fragments: Vec<usize>,
1269}
1270
1271impl FragmentLine {
1272    /// Whether a fragment at baseline `y` with type size `em` belongs to
1273    /// this line: within the fixed window of the line's first fragment, as
1274    /// always, and within [`baseline_tolerance`] of a baseline a fragment
1275    /// of the line already sits on, each at its own type size — so a
1276    /// subscript joins the base text it hangs from though the line's first
1277    /// fragment lies a little higher, a mark joins the text it is raised
1278    /// over though a smaller fragment sits nearer to it, and a line of
1279    /// small type a whole pitch away does not join. The fixed window is
1280    /// tested first, so only the baselines of lines that start within it
1281    /// are walked.
1282    fn admits(&self, page: u32, y: f32, em: f32) -> bool {
1283        page == self.page
1284            && (y - self.y).abs() < LINE_BASELINE_TOLERANCE_MAX_PT
1285            && self
1286                .baselines
1287                .iter()
1288                .any(|&(line_y, line_em)| (y - line_y).abs() < baseline_tolerance(em, line_em))
1289    }
1290
1291    fn push(&mut self, index: usize, item: &TextItem, em: f32) {
1292        let known = self
1293            .baselines
1294            .iter()
1295            .position(|&(line_y, _)| (line_y - item.y).abs() < LINE_BASELINE_RECORD_SPACING);
1296        match known {
1297            Some(i) => self.baselines[i].1 = self.baselines[i].1.max(em),
1298            None if self.baselines.len() < LINE_BASELINES_MAX => {
1299                self.baselines.push((item.y, em));
1300            }
1301            None => {}
1302        }
1303        self.fragments.push(index);
1304    }
1305}
1306
1307/// The lines the fragments of `items` fall into — by page, then by
1308/// baseline (see [`FragmentLine::admits`]) — as the fragments' indices,
1309/// lines in the order their first fragment was walked and fragments in
1310/// stream order within each. The walk is one pass in stream order, as it
1311/// always was: the fragment that seeds a line anchors its fixed window,
1312/// and a baseline's type size grows as larger type lands on it, so text
1313/// shown after a small glyph on its baseline still gets its full window.
1314/// The subscript pass buckets its rough lines with it too, so the order
1315/// it fixes agrees with the lines made here.
1316fn group_indices_into_lines(items: &[TextItem]) -> Vec<Vec<usize>> {
1317    let mut lines: Vec<FragmentLine> = Vec::new();
1318    for (index, item) in items.iter().enumerate() {
1319        let em = line_em(item);
1320        match lines
1321            .iter_mut()
1322            .find(|line| line.admits(item.page, item.y, em))
1323        {
1324            Some(line) => line.push(index, item, em),
1325            None => lines.push(FragmentLine {
1326                page: item.page,
1327                y: item.y,
1328                baselines: vec![(item.y, em)],
1329                fragments: vec![index],
1330            }),
1331        }
1332    }
1333    lines.into_iter().map(|line| line.fragments).collect()
1334}
1335
1336/// [`group_indices_into_lines`] as (page, baseline of the first fragment,
1337/// fragments).
1338fn group_fragments_into_lines(items: &[TextItem]) -> Vec<(u32, f32, Vec<&TextItem>)> {
1339    group_indices_into_lines(items)
1340        .into_iter()
1341        .map(|indices| {
1342            let first = &items[indices[0]];
1343            (
1344                first.page,
1345                first.y,
1346                indices.iter().map(|&i| &items[i]).collect(),
1347            )
1348        })
1349        .collect()
1350}
1351
1352fn should_preserve_overlapping_stream_order(group: &[&TextItem]) -> bool {
1353    if group.len() < 3 {
1354        return false;
1355    }
1356
1357    let Some(first) = group.iter().find(|item| !item.text.trim().is_empty()) else {
1358        return false;
1359    };
1360    if group.iter().all(|item| item.mcid.is_none()) {
1361        return false;
1362    }
1363
1364    let mut nonempty_count = 0;
1365    let mut saw_backtrack = false;
1366    let mut nonspace_chars = 0;
1367    let mut math_symbol_chars = 0;
1368    let mut max_font_size = first.font_size;
1369
1370    for item in group {
1371        if !item.text.trim().is_empty() {
1372            nonempty_count += 1;
1373        }
1374        if (item.font_size - first.font_size).abs() > first.font_size * 0.25 {
1375            return false;
1376        }
1377        max_font_size = max_font_size.max(item.font_size);
1378        for ch in item.text.chars().filter(|ch| !ch.is_whitespace()) {
1379            nonspace_chars += 1;
1380            if matches!(
1381                ch,
1382                '*' | 'ˆ' | '^' | '=' | '+' | '_' | '[' | ']' | '{' | '}' | '|' | '<' | '>'
1383            ) {
1384                math_symbol_chars += 1;
1385            }
1386        }
1387    }
1388
1389    if nonempty_count < 2 {
1390        return false;
1391    }
1392    if nonspace_chars > 0 && math_symbol_chars * 4 > nonspace_chars {
1393        return false;
1394    }
1395
1396    let mut sorted_by_x = group.to_vec();
1397    sorted_by_x.sort_by(|a, b| a.x.total_cmp(&b.x));
1398    let cluster_start = sorted_by_x[0].x;
1399    let mut cluster_end = cluster_start + order_extent(sorted_by_x[0]);
1400    for item in sorted_by_x.iter().skip(1) {
1401        let gap = item.x - cluster_end;
1402        if gap > max_font_size * 2.5 {
1403            return false;
1404        }
1405        cluster_end = cluster_end.max(item.x + order_extent(item));
1406    }
1407    if cluster_end - cluster_start > max_font_size * 36.0 {
1408        return false;
1409    }
1410
1411    for index in 0..group.len() - 1 {
1412        let previous = group[index];
1413        let next = group[index + 1];
1414        let font_size = previous.font_size.max(next.font_size);
1415        let backtrack_threshold = font_size * 0.25;
1416        let previous_start = previous.x;
1417        let next_start = next.x;
1418        let next_end = next.x + order_extent(next);
1419        if next_start < previous_start - backtrack_threshold
1420            && next_end > previous_start + backtrack_threshold
1421        {
1422            let has_near_prefix = group[..=index].iter().rev().take(4).any(|item| {
1423                is_short_alpha_fragment(&item.text)
1424                    && item.x >= next_start - font_size * 0.5
1425                    && item.x <= next_start + font_size * 4.0
1426            });
1427            let starts_lowercase = first_text_char(&next.text).is_some_and(char::is_lowercase);
1428            let phrase_continuation = has_phrase_continuation_shape(&next.text);
1429            let has_near_bullet = group[..=index]
1430                .iter()
1431                .position(|item| {
1432                    is_standalone_bullet_text(&item.text) && next_start <= item.x + font_size * 3.0
1433                })
1434                .is_some_and(|bullet_index| {
1435                    if bullet_index >= index {
1436                        return false;
1437                    }
1438                    group[bullet_index + 1..=index]
1439                        .iter()
1440                        .rev()
1441                        .find(|item| !item.text.trim().is_empty())
1442                        .is_some_and(|item| {
1443                            item.text.trim().chars().count() <= 8
1444                                && has_phrase_continuation_shape(&next.text)
1445                        })
1446                });
1447            if (has_near_prefix && starts_lowercase && phrase_continuation) || has_near_bullet {
1448                saw_backtrack = true;
1449                break;
1450            }
1451        }
1452    }
1453
1454    saw_backtrack
1455}
1456
1457/// Detect a tracked (letter-spaced) run of single-glyph items and derive its
1458/// run-local space floor.
1459///
1460/// Display type set with tracking renders one glyph per show op; the merge
1461/// loop's fixed thresholds (0.08-0.13 em) then read every letter gap as a
1462/// word boundary and emit "H O W" instead of "HOW". Within such a run the
1463/// gaps carry the real signal: letter gaps cluster tightly just above the
1464/// fixed threshold, word gaps sit clearly higher. Returns (run_end_index,
1465/// space_floor) when the run starting at `start` is tracked — spaces are
1466/// then inserted only at gaps above the floor (infinity = single word).
1467/// Normal text (multi-char items, or single-char runs with sub-threshold
1468/// gaps) returns None and keeps the existing behavior.
1469/// Han/Kana scripts write without inter-word spaces. Hangul (Korean) DOES
1470/// space between words and deliberately stays out of this set — a Korean
1471/// tracked run keeps normal word-boundary handling.
1472pub(crate) fn is_spaceless_cjk(c: char) -> bool {
1473    matches!(c,
1474        '\u{3000}'..='\u{303F}'   // CJK Symbols and Punctuation
1475        | '\u{3040}'..='\u{309F}' // Hiragana
1476        | '\u{30A0}'..='\u{30FF}' // Katakana
1477        | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs
1478        | '\u{F900}'..='\u{FAFF}' // CJK Compatibility Ideographs
1479        | '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms
1480    )
1481}
1482
1483fn tracked_run_space_floor(group: &[&TextItem], start: usize) -> Option<(usize, f32)> {
1484    const MIN_GAPS: usize = 4;
1485    let first = group[start];
1486    if first.text.trim().chars().count() != 1 {
1487        return None;
1488    }
1489    let fs = first.font_size;
1490    if fs <= 0.0 {
1491        return None;
1492    }
1493
1494    // Walk the run under the SAME break conditions as the merge loop
1495    // (size band, style equality, mergeable gap) so indices stay aligned.
1496    let mut gaps: Vec<f32> = Vec::new();
1497    let mut end_x = first.x + effective_merge_width(first);
1498    let mut end = start;
1499    for (offset, next) in group[start + 1..].iter().enumerate() {
1500        if (next.font_size - fs).abs() > fs * 0.20 {
1501            break;
1502        }
1503        if next.is_bold != first.is_bold
1504            || next.is_italic != first.is_italic
1505            || next.is_underline != first.is_underline
1506            || next.is_strikeout != first.is_strikeout
1507        {
1508            break;
1509        }
1510        let next_end = next.x + effective_merge_width(next);
1511        // A dependent sign over the letter before it — of a character or
1512        // two — is no letter of the run, and the line's right edge does
1513        // not move for it (as in the merge loop): the next letter's gap
1514        // is measured from the pen the letter under the sign left.
1515        if is_zero_width_mark(next) {
1516            end_x = end_x.max(next_end);
1517            end = start + 1 + offset;
1518            continue;
1519        }
1520        if next.text.trim().chars().count() != 1 {
1521            break;
1522        }
1523        let gap = next.x - end_x;
1524        if gap > fs * 0.5 || gap < -fs * 0.5 {
1525            break;
1526        }
1527        gaps.push(gap / fs);
1528        end_x = next_end;
1529        end = start + 1 + offset;
1530    }
1531    if gaps.len() < 2 {
1532        return None;
1533    }
1534
1535    // Tracked signature: the run's TYPICAL gap clears the fixed space
1536    // threshold (0.08) — the merge loop would break almost every letter
1537    // pair into "words". Short runs (2-3 gaps: "H O W") demand a stricter
1538    // shape — clearly wide, uniform, ALL-CAPS — because a genuine spaced
1539    // sequence of single letters ("x y z" variables) has the same gap
1540    // count; display tracking is a caps convention.
1541    let mut sorted = gaps.clone();
1542    sorted.sort_by(|a, b| a.total_cmp(b));
1543    let median = sorted[sorted.len() / 2];
1544    // Typographic convention gate, both tiers: display tracking is an
1545    // all-caps convention, and Han/Kana never space between glyphs. Mixed-
1546    // or lowercase Latin runs keep their boundaries because geometry alone
1547    // cannot distinguish spaced singles ("A b c d e") from a tracked
1548    // title-case word ("B u f f a l o").
1549    let run_chars = || {
1550        group[start..=end]
1551            .iter()
1552            .flat_map(|it| it.text.trim().chars())
1553    };
1554    let spaceless_cjk = run_chars().all(|c| is_spaceless_cjk(c) || !c.is_alphanumeric())
1555        && run_chars().any(is_spaceless_cjk);
1556    let all_caps = run_chars().all(|c| c.is_uppercase() || is_cjk_char(c) || !c.is_alphabetic());
1557    if !(spaceless_cjk || all_caps) {
1558        return None;
1559    }
1560
1561    if gaps.len() >= MIN_GAPS {
1562        if median <= 0.075 {
1563            return None;
1564        }
1565    } else {
1566        let uniform = sorted[sorted.len() - 1] <= sorted[0].max(0.01) * 1.4;
1567        if median < 0.09 || !uniform {
1568            return None;
1569        }
1570    }
1571
1572    // Han/Kana: no inter-glyph spaces, period — a nonuniform gap
1573    // distribution (punctuation spacing, justification) must not
1574    // manufacture word boundaries.
1575    if spaceless_cjk {
1576        return Some((end, f32::INFINITY));
1577    }
1578
1579    // Word gaps, if present, form a second mode above the letter-gap
1580    // cluster: split at the largest relative jump. Unimodal → one word.
1581    let mut best_jump = 1.0f32;
1582    let mut floor = f32::INFINITY;
1583    for pair in sorted.windows(2) {
1584        let (lo, hi) = (pair[0].max(0.01), pair[1].max(0.01));
1585        let jump = hi / lo;
1586        if jump > best_jump {
1587            best_jump = jump;
1588            floor = (lo + hi) / 2.0;
1589        }
1590    }
1591    if best_jump < 1.4 {
1592        floor = f32::INFINITY;
1593    }
1594    Some((end, floor * fs))
1595}
1596
1597/// Fractional font-size band within which `merge_text_items` treats two runs as
1598/// the same size. Shared with `is_small_caps_continuation`, which exists only to
1599/// rescue junctions this band would otherwise break.
1600const MERGE_FONT_SIZE_BAND: f32 = 0.20;
1601
1602/// Detect a small-caps continuation: typesetters render small caps as a
1603/// full-size capital immediately followed by shrunken capitals in the same
1604/// font (`(R) Tj` at 9.98pt, then `(OLANDO) Tj` at 6.74pt). Those runs are one
1605/// word, but the font-size band in `merge_text_items` would split them,
1606/// leaving "R" and "OLANDO" as separate items — which then read as separate
1607/// table columns, since column boundaries cluster on item start positions.
1608///
1609/// Gated tightly so it cannot absorb the other reasons a smaller run follows a
1610/// larger one:
1611///   - runs the size band already accepts — excluded by requiring the junction
1612///     to *cross* the band, so within-band pairs keep the normal word-spacing
1613///     logic instead of having their space suppressed
1614///   - superscripts / footnote markers — excluded by requiring an uppercase
1615///     *letter* on both sides, so digits never qualify
1616///   - drop caps — excluded because the body text that follows is mixed case
1617///   - adjacent table cells or separate words — excluded by requiring the runs
1618///     to be visually contiguous (essentially no gap)
1619fn is_small_caps_continuation(
1620    text_so_far: &str,
1621    first: &TextItem,
1622    next: &TextItem,
1623    gap: f32,
1624) -> bool {
1625    // Must shrink. Real small caps sit near 0.7-0.8 of the full cap height;
1626    // anything smaller is a superscript or a different run entirely.
1627    if first.font_size <= 0.0 || next.font_size >= first.font_size {
1628        return false;
1629    }
1630    // Only rescue junctions the size band would have broken. Within-band pairs
1631    // merge on their own, and suppressing their space would swallow real word
1632    // gaps between two similarly-sized uppercase words.
1633    if (next.font_size - first.font_size).abs() <= first.font_size * MERGE_FONT_SIZE_BAND {
1634        return false;
1635    }
1636    if next.font_size / first.font_size < 0.55 {
1637        return false;
1638    }
1639    // Visually contiguous: the capital and its small caps touch. A real word
1640    // space or a column gap disqualifies.
1641    if !(-first.font_size * 0.2..=first.font_size * 0.15).contains(&gap) {
1642        return false;
1643    }
1644    // The continuation must be all-uppercase letters (digits and lowercase
1645    // both disqualify), and must contain at least one letter.
1646    let mut saw_letter = false;
1647    for ch in next.text.chars() {
1648        if ch.is_alphabetic() {
1649            saw_letter = true;
1650            if !ch.is_uppercase() {
1651                return false;
1652            }
1653        } else if ch.is_numeric() {
1654            return false;
1655        }
1656    }
1657    if !saw_letter {
1658        return false;
1659    }
1660    // What we are continuing must itself end in a capital. Check the actual
1661    // trailing character rather than skipping back to the nearest letter: after
1662    // "ANGELA M. MAZZARELLI1" the run to continue is the footnote marker, not
1663    // the "I" before it.
1664    let trimmed = text_so_far.trim_end();
1665    if trimmed.chars().last().is_some_and(|c| c.is_numeric()) {
1666        // One legitimate exception: an ordinal suffix set as a smaller run,
1667        // e.g. "JULY 4" + "TH". Only the four English suffixes qualify —
1668        // anything else after a digit is a footnote marker or numeric suffix.
1669        return matches!(trimmed_suffix(next), "TH" | "ST" | "ND" | "RD");
1670    }
1671    trimmed
1672        .chars()
1673        .rev()
1674        .find(|c| c.is_alphabetic())
1675        .is_some_and(|c| c.is_uppercase())
1676}
1677
1678/// The continuation run's text, trimmed — used to spot ordinal suffixes.
1679fn trimmed_suffix(next: &TextItem) -> &str {
1680    next.text.trim()
1681}
1682
1683/// The combining mark a spacing accent stands for. These are the accents
1684/// the standard Latin encodings carry as glyphs of their own, each with an
1685/// advance: macron, acute, grave, circumflex, tilde, dieresis, caron,
1686/// breve, ring, cedilla, ogonek, dot accent and double acute. `None` for
1687/// any other character.
1688fn combining_mark_of_spacing_accent(c: char) -> Option<char> {
1689    Some(match c {
1690        '\u{00AF}' => '\u{0304}', // macron
1691        '\u{00B4}' => '\u{0301}', // acute
1692        '\u{0060}' => '\u{0300}', // grave
1693        '\u{02C6}' => '\u{0302}', // circumflex
1694        '\u{02DC}' => '\u{0303}', // tilde
1695        '\u{00A8}' => '\u{0308}', // dieresis
1696        '\u{02C7}' => '\u{030C}', // caron
1697        '\u{02D8}' => '\u{0306}', // breve
1698        '\u{02DA}' => '\u{030A}', // ring
1699        '\u{00B8}' => '\u{0327}', // cedilla
1700        '\u{02DB}' => '\u{0328}', // ogonek
1701        '\u{02D9}' => '\u{0307}', // dot accent
1702        '\u{02DD}' => '\u{030B}', // double acute
1703        _ => return None,
1704    })
1705}
1706
1707/// The combining mark of a fragment that is one spacing accent and nothing
1708/// else: a show operator of the single accent glyph.
1709fn lone_spacing_accent(item: &TextItem) -> Option<char> {
1710    let mut chars = item.text.chars();
1711    let accent = chars.next()?;
1712    if chars.next().is_some() {
1713        return None;
1714    }
1715    combining_mark_of_spacing_accent(accent)
1716}
1717
1718/// The end of a run a detached accent is matched against: the glyph that
1719/// begins the run or the glyph that closes it.
1720#[derive(Clone, Copy)]
1721enum RunEnd {
1722    First,
1723    Last,
1724}
1725
1726/// The glyph at `end` of `text`: its character, the byte range it takes in
1727/// the text and the number of whitespace characters between it and that
1728/// end of the run. `None` for a run of nothing but whitespace.
1729fn end_glyph(text: &str, end: RunEnd) -> Option<(char, std::ops::Range<usize>, usize)> {
1730    let found = match end {
1731        RunEnd::First => text
1732            .char_indices()
1733            .enumerate()
1734            .find(|(_, (_, c))| !c.is_whitespace()),
1735        RunEnd::Last => text
1736            .char_indices()
1737            .rev()
1738            .enumerate()
1739            .find(|(_, (_, c))| !c.is_whitespace()),
1740    };
1741    found.map(|(skipped, (offset, c))| (c, offset..offset + c.len_utf8(), skipped))
1742}
1743
1744/// How much of a detached accent lies over the glyph at `end` of `run`, in
1745/// points along the baseline; `None` when it does not stand over that
1746/// glyph. Both must be text runs of one page on a level baseline (a
1747/// `rotation` of exactly 0: the axis-aligned box of an oblique run is no
1748/// baseline), with measured advances and without right-to-left letters — a
1749/// run of those may have been painted under a mirrored matrix, which reads
1750/// as a level run whose glyphs advance leftwards, so which glyph stands at
1751/// which end of its box is not known from the item, and none of the accents
1752/// composes with such a letter anyway — their baselines within 0.3 em of
1753/// each other (an accent over a capital is set a little higher than one
1754/// over a small letter). The item keeps no advance per glyph, so the
1755/// glyph's window along the baseline is estimated: each whitespace
1756/// character is counted at 0.28 em (at the run's uniform advance for a
1757/// fixed-pitch face), the rest of the width is shared equally among the
1758/// other characters, and the window is that share or 0.6 em, whichever is
1759/// wider, so a capital W or M under the accent is covered although the
1760/// run's letters average half of it. The accent stands over the glyph when
1761/// its centre falls within the window, with a quarter of the accent's own
1762/// width of play beyond the run's edge for an accent overhanging a narrow
1763/// letter. An accent shown beside a glyph rather than over it, a circumflex
1764/// or grave that is text of its own, has its centre at least half its width
1765/// beyond that edge and is never matched.
1766fn accent_overlap(accent: &TextItem, run: &TextItem, end: RunEnd) -> Option<f32> {
1767    let level_text = |item: &TextItem| {
1768        matches!(item.item_type, crate::types::ItemType::Text)
1769            && item.rotation == 0.0
1770            && item.advance_known
1771            && item.width > 0.0
1772            && !item.text.chars().any(crate::text_utils::is_rtl_char)
1773    };
1774    if run.page != accent.page
1775        || !level_text(run)
1776        || !level_text(accent)
1777        || run.font_size <= 0.0
1778        || (accent.y - run.y).abs() > run.font_size * 0.3
1779    {
1780        return None;
1781    }
1782    let (_, _, skipped) = end_glyph(&run.text, end)?;
1783    let em = run.font_size;
1784    let chars = run.text.chars().count();
1785    let spaces = run.text.chars().filter(|c| c.is_whitespace()).count();
1786    let space_advance = if run.fixed_pitch == Some(true) {
1787        run.width / chars as f32
1788    } else {
1789        em * 0.28
1790    };
1791    let ink = run.width - space_advance * spaces as f32;
1792    if ink <= 0.0 {
1793        return None;
1794    }
1795    // `end_glyph` found a glyph, so the run has more characters than spaces.
1796    let advance = ink / (chars - spaces) as f32;
1797    let window = advance.max(em * 0.6);
1798    let (left, right) = match end {
1799        RunEnd::First => {
1800            let left = run.x + space_advance * skipped as f32;
1801            (left, left + window)
1802        }
1803        RunEnd::Last => {
1804            let right = run.x + run.width - space_advance * skipped as f32;
1805            (right - window, right)
1806        }
1807    };
1808    let centre = accent.x + accent.width / 2.0;
1809    let play = accent.width * 0.25;
1810    if centre < left - play || centre > right + play {
1811        return None;
1812    }
1813    Some(((accent.x + accent.width).min(right) - accent.x.max(left)).max(0.0))
1814}
1815
1816/// Composes a spacing accent shown as a text object of its own with the
1817/// letter it is painted over.
1818///
1819/// Some producers set an accented letter as three show operators: the run
1820/// up to the letter, one glyph of a spacing accent placed by its own text
1821/// matrix over the letter, and the run from the letter on. The standard
1822/// Latin encodings carry these accents (`macron`, `acute`, `caron` and
1823/// their kin) as glyphs with an advance of their own, and the producer
1824/// spells the letter by overprinting one on the other. Rendered, the accent
1825/// lands on the letter; read back, it is a fragment of one glyph that
1826/// starts a fraction of a point to the right of the run it decorates, and
1827/// the line's fragments sorted along the baseline put it after that whole
1828/// run: a stray accent a word on, and a letter without its mark.
1829///
1830/// So, before the line is ordered, a fragment that is nothing but a spacing
1831/// accent is matched against the fragments shown just before and just
1832/// after it: it stands over the last glyph of the one or the first glyph of
1833/// the other when their baselines lie within 0.3 em and the accent's centre
1834/// falls within that glyph's advance (`accent_overlap`, which also asks for
1835/// level, measured runs without right-to-left letters; the glyph with the
1836/// larger overlap wins when both qualify). That glyph and the accent's
1837/// combining mark are then replaced by their canonical composition, when
1838/// Unicode has one character for the pair, and the accent fragment is
1839/// dropped; a dotless i or j under the accent composes as the dotted letter,
1840/// the dot being what the accent replaces. An accent over neither
1841/// neighbour, or over a glyph its mark does not compose with, is left
1842/// exactly as shown: a lone circumflex or grave in code or mathematics has
1843/// no letter under it and stays a character of its own.
1844///
1845/// `clips` runs parallel to `items` (see `merge_text_items_with_clips`);
1846/// the entries of dropped fragments go with them. `replaced_text` marks,
1847/// parallel to `items` too, the runs whose text is a producer's ActualText
1848/// replacement rather than their glyphs' decoding: such a run's characters
1849/// need not stand for its glyphs one by one, so it neither gives nor takes
1850/// an accent.
1851fn compose_detached_spacing_accents<'a>(
1852    mut items: Vec<TextItem>,
1853    clips: &'a [Option<clip_boundaries::ClipRect>],
1854    replaced_text: &[bool],
1855) -> (Vec<TextItem>, Cow<'a, [Option<clip_boundaries::ClipRect>]>) {
1856    let replaced = |index: usize| replaced_text.get(index).copied().unwrap_or(false);
1857    let mut dropped: Vec<usize> = Vec::new();
1858    for i in 0..items.len() {
1859        if replaced(i) {
1860            continue;
1861        }
1862        let Some(mark) = lone_spacing_accent(&items[i]) else {
1863            continue;
1864        };
1865        let neighbours = [
1866            (i.checked_sub(1), RunEnd::Last),
1867            (
1868                Some(i + 1).filter(|&next| next < items.len()),
1869                RunEnd::First,
1870            ),
1871        ];
1872        // The neighbour whose end glyph the accent lies over and composes
1873        // with; when it lies over both, the one it overlaps more. A glyph
1874        // that has no composition with the mark is no candidate, so an
1875        // accent over a `t` and an `o` composes with the `o` however the
1876        // overlaps compare.
1877        let mut best: Option<(usize, std::ops::Range<usize>, char, f32)> = None;
1878        for (index, end) in neighbours {
1879            let Some(index) = index else {
1880                continue;
1881            };
1882            if replaced(index) || lone_spacing_accent(&items[index]).is_some() {
1883                continue;
1884            }
1885            let Some(overlap) = accent_overlap(&items[i], &items[index], end) else {
1886                continue;
1887            };
1888            let Some((glyph, range, _)) = end_glyph(&items[index].text, end) else {
1889                continue;
1890            };
1891            // A dotless i or j is the form a typesetter puts an accent over,
1892            // the dot being what the accent replaces: it composes as the
1893            // dotted letter.
1894            let base = match glyph {
1895                '\u{0131}' => 'i',
1896                '\u{0237}' => 'j',
1897                other => other,
1898            };
1899            let Some(composed) = unicode_normalization::char::compose(base, mark) else {
1900                continue;
1901            };
1902            if best
1903                .as_ref()
1904                .is_none_or(|(_, _, _, other)| overlap > *other)
1905            {
1906                best = Some((index, range, composed, overlap));
1907            }
1908        }
1909        let Some((index, range, composed, _)) = best else {
1910            continue;
1911        };
1912        items[index]
1913            .text
1914            .replace_range(range, composed.encode_utf8(&mut [0; 4]));
1915        dropped.push(i);
1916    }
1917    if dropped.is_empty() {
1918        return (items, Cow::Borrowed(clips));
1919    }
1920    let mut kept = vec![true; items.len()];
1921    for &index in &dropped {
1922        kept[index] = false;
1923    }
1924    let clips: Vec<Option<clip_boundaries::ClipRect>> = clips
1925        .iter()
1926        .zip(&kept)
1927        .filter(|(_, &keep)| keep)
1928        .map(|(clip, _)| *clip)
1929        .collect();
1930    let mut index = 0;
1931    items.retain(|_| {
1932        let keep = kept[index];
1933        index += 1;
1934        keep
1935    });
1936    (items, Cow::Owned(clips))
1937}
1938
1939#[cfg(test)]
1940pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
1941    merge_text_items_with_clips(items, &[], false, &[])
1942}
1943
1944/// The separators a number is written with: point, comma, colon, slash
1945/// and the Arabic decimal and thousands separators.
1946fn is_number_separator(c: char) -> bool {
1947    matches!(c, '.' | ',' | ':' | '/' | '\u{066B}' | '\u{066C}')
1948}
1949
1950/// A fragment of a number: digits of any script, at least one, with the
1951/// separators that join them and nothing else.
1952fn numeric_fragment(text: &str) -> bool {
1953    let text = text.trim();
1954    text.chars().any(char::is_numeric)
1955        && text
1956            .chars()
1957            .all(|c| c.is_numeric() || is_number_separator(c))
1958}
1959
1960/// A fragment of nothing but number separators: a comma or point shown
1961/// apart from its digits.
1962fn separator_fragment(text: &str) -> bool {
1963    let text = text.trim();
1964    !text.is_empty() && text.chars().all(is_number_separator)
1965}
1966
1967/// Whether the junction of two neighbouring fragments lies inside a
1968/// number: both are number material and at least one holds a digit — a
1969/// separator shown apart from its digits belongs to the number beside it,
1970/// while two lone separators make no number.
1971fn inside_number(a: &str, b: &str) -> bool {
1972    let material = |text: &str| numeric_fragment(text) || separator_fragment(text);
1973    material(a) && material(b) && (numeric_fragment(a) || numeric_fragment(b))
1974}
1975
1976/// Word-gap floor for a line of right-to-left text shown one glyph per
1977/// item, from its own gaps: the letter gaps of such a line cluster below
1978/// its word gaps. Declared advance widths are often off for these fonts,
1979/// so the fixed em fractions that serve word-by-word runs would put a space
1980/// after every narrow letter. The gaps are given and the floor returned in
1981/// em — of the smaller font beside each gap, so a word gap next to a
1982/// footnote mark or a run of a smaller font is measured by the glyphs it
1983/// separates. They are split into two classes where the variance between
1984/// them is largest (Otsu's threshold), and the floor sits between the
1985/// classes when the upper one is a space's worth apart from the lower —
1986/// glyphs of a second font with true widths, digits at zero gap beside
1987/// letters at a small one, form no such class. With one class of gaps the
1988/// floor is infinite when even the wide ones are mid-word gaps (under the
1989/// 0.13 em a junction inside a word may open to), so a line of one word
1990/// holds together; gaps of one class that are wider than that could as
1991/// well be the word gaps of one-letter words, and the fixed thresholds
1992/// decide them: `None`.
1993fn glyph_run_word_gap_floor(gaps: &[f32]) -> Option<f32> {
1994    if gaps.len() < 3 {
1995        return None;
1996    }
1997    let mut sorted: Vec<f32> = gaps.to_vec();
1998    sorted.sort_by(|a, b| a.total_cmp(b));
1999    let total: f32 = sorted.iter().sum();
2000    let n = sorted.len() as f32;
2001    let mut best: Option<(f32, f32, f32)> = None; // (between-class variance, low mean, high mean)
2002    let mut low_sum = 0.0f32;
2003    for (k, &value) in sorted.iter().enumerate().take(sorted.len() - 1) {
2004        low_sum += value;
2005        let low_n = (k + 1) as f32;
2006        let high_n = n - low_n;
2007        let low_mean = low_sum / low_n;
2008        let high_mean = (total - low_sum) / high_n;
2009        let variance = low_n * high_n * (high_mean - low_mean).powi(2);
2010        if best.is_none_or(|(v, _, _)| variance > v) {
2011            best = Some((variance, low_mean, high_mean));
2012        }
2013    }
2014    let upper_quartile = sorted[sorted.len() * 3 / 4];
2015    match best {
2016        Some((_, low_mean, high_mean)) if high_mean - low_mean >= 0.12 && high_mean >= 0.2 => {
2017            Some((low_mean + high_mean) / 2.0)
2018        }
2019        _ if upper_quartile <= 0.13 => Some(f32::INFINITY),
2020        _ => None,
2021    }
2022}
2023
2024/// Bold and plain runs are kept apart whatever said they were bold: with
2025/// `PositionOptions::bold_from_weight` the weight class has already had its
2026/// say in `is_bold` (see `content_stream::read_bold_from_weight`), so runs
2027/// of different weight split only where the bold verdict changes.
2028/// `visual_rtl` says the page's right-to-left runs are stored in visual
2029/// order (see `text_utils::fix_visual_order_rtl`): the lines holding them
2030/// are then read back into logical order here, as they merge.
2031/// `replaced_text` marks, parallel to `items` like `clips`, the runs whose
2032/// text is a producer's ActualText replacement rather than their glyphs'
2033/// decoding (see `compose_detached_spacing_accents`).
2034fn merge_text_items_with_clips(
2035    items: Vec<TextItem>,
2036    clips: &[Option<clip_boundaries::ClipRect>],
2037    visual_rtl: bool,
2038    replaced_text: &[bool],
2039) -> Vec<TextItem> {
2040    if items.is_empty() {
2041        return items;
2042    }
2043
2044    // A spacing accent shown as a fragment of its own over a letter of the
2045    // run before or after it is composed with that letter first, so the
2046    // line is ordered without it.
2047    let (items, owned_clips) = compose_detached_spacing_accents(items, clips, replaced_text);
2048    let clips: &[Option<clip_boundaries::ClipRect>] = &owned_clips;
2049
2050    // References into `items` remain stable throughout grouping and sorting.
2051    // Keep clipping provenance private rather than changing the public item type.
2052    let clip_by_item: HashMap<*const TextItem, clip_boundaries::ClipRect> = items
2053        .iter()
2054        .zip(clips)
2055        .filter_map(|(item, clip)| clip.map(|rect| (item as *const TextItem, rect)))
2056        .collect();
2057
2058    let line_groups = group_fragments_into_lines(&items);
2059
2060    // Each page's own direction: a line with right-to-left letters on a
2061    // right-to-left page reads right to left even when its Latin letters
2062    // outnumber them.
2063    let page_rtl: HashMap<u32, bool> = items
2064        .iter()
2065        .map(|item| item.page)
2066        .collect::<std::collections::HashSet<u32>>()
2067        .into_iter()
2068        .map(|page| {
2069            let rtl = is_rtl_text(items.iter().filter(|i| i.page == page).map(|i| &i.text));
2070            (page, rtl)
2071        })
2072        .collect();
2073
2074    /// One line's fragments, in the order they are walked.
2075    struct LineGroup<'a> {
2076        page: u32,
2077        y: f32,
2078        group: Vec<&'a TextItem>,
2079        /// Each fragment's text as it reads: its own, or on a visual-order
2080        /// page the stretch of the line's logical text that is its.
2081        texts: Vec<Cow<'a, str>>,
2082        preserve_stream_order: bool,
2083        /// Holds right-to-left letters: walked in reading order, not along +x.
2084        bidi: bool,
2085        /// For a `bidi` line, each fragment's position in screen order.
2086        display_index: Vec<usize>,
2087        /// For a `bidi` line, the gap between each pair of screen
2088        /// neighbours, in points, by screen position: from the line's right
2089        /// edge so far, which a dependent sign does not move.
2090        display_gaps: Vec<f32>,
2091        /// For a `bidi` line shown one glyph per fragment, the word-gap
2092        /// floor its own gaps give (`glyph_run_word_gap_floor`), in em of
2093        /// the smaller font at a junction.
2094        glyph_floor: Option<f32>,
2095    }
2096    let mut ordered_line_groups: Vec<LineGroup<'_>> = Vec::new();
2097
2098    // Sort each group by X position (direction-aware), except for lines whose
2099    // content stream intentionally backtracks to overlay ActualText fragments.
2100    for (page, y, mut group) in line_groups {
2101        // A line with right-to-left letters, whichever direction dominates
2102        // it, is walked in reading order below.
2103        let bidi = group
2104            .iter()
2105            .any(|i| i.text.chars().any(crate::text_utils::is_rtl_char));
2106        let preserve_stream_order = !bidi && should_preserve_overlapping_stream_order(&group);
2107        let texts: Vec<Cow<'_, str>>;
2108        let mut display_index: Vec<usize> = Vec::new();
2109        let mut display_gaps: Vec<f32> = Vec::new();
2110        let mut glyph_floor: Option<f32> = None;
2111        if bidi {
2112            // Screen order first; the fragments are then taken in the reading
2113            // order the Unicode Bidirectional Algorithm gives the line, so an
2114            // embedded Latin phrase or number keeps its own order when the
2115            // concatenation below bakes the order in. On a visual-order page
2116            // the fragments' glyphs are the display line itself, and each
2117            // fragment gets its stretch of the logical text back.
2118            let rtl_base = crate::text_utils::rtl_line_base(
2119                &group,
2120                |i| *i,
2121                page_rtl.get(&page).copied().unwrap_or(false),
2122            );
2123            // Screen order, with a dependent sign kept after the letter it
2124            // was shown on, as on a left-to-right line.
2125            sort_along_x_keeping_marks(&mut group);
2126            // The gaps between screen neighbours, taken once here: two
2127            // glyphs at one x (a mark over its letter) sort either way, and
2128            // the reading order below must index the same sequence. A
2129            // dependent sign sits behind the pen and does not move the
2130            // line's right edge: the fragment after it is measured from
2131            // where the letter under it left the pen.
2132            display_gaps = Vec::with_capacity(group.len().saturating_sub(1));
2133            let mut right_edge: Option<f32> = None;
2134            for item in &group {
2135                let end = item.x + effective_merge_width(item);
2136                if let Some(edge) = right_edge {
2137                    display_gaps.push(item.x - edge);
2138                }
2139                right_edge = Some(match right_edge {
2140                    Some(edge) if is_zero_width_mark(item) => edge.max(end),
2141                    _ => end,
2142                });
2143            }
2144            // Glyph-by-glyph positioned RTL text clusters into words by the
2145            // line's own gaps: adjacent glyphs abut, word gaps do not, with
2146            // the floor below where declared widths are off. Junctions
2147            // inside a number are never word gaps and say nothing about the
2148            // letters' gaps either (digits of another font keep true
2149            // widths), so they stay out of the sample, and so do the
2150            // junctions at a dependent sign, which is no letter of the
2151            // line. The same floor separates the words for the bidi
2152            // analysis and, below, for the merge.
2153            let single_glyphs = group
2154                .iter()
2155                .filter(|i| i.text.trim().chars().count() == 1)
2156                .count();
2157            if group.len() >= 4 && single_glyphs * 10 >= group.len() * 7 {
2158                let gaps: Vec<f32> = group
2159                    .windows(2)
2160                    .zip(&display_gaps)
2161                    .filter(|(pair, _)| {
2162                        !inside_number(&pair[0].text, &pair[1].text)
2163                            && !is_zero_width_mark(pair[0])
2164                            && !is_zero_width_mark(pair[1])
2165                    })
2166                    .map(|(pair, gap)| gap / pair[0].font_size.min(pair[1].font_size).max(1.0))
2167                    .collect();
2168                glyph_floor = glyph_run_word_gap_floor(&gaps);
2169            }
2170            let order = crate::bidi::logical_line_order(
2171                &group,
2172                |i| i.text.as_str(),
2173                |i| (i.x, i.width),
2174                |i| i.font_size,
2175                |_| visual_rtl,
2176                glyph_floor,
2177                rtl_base,
2178            );
2179            let reordered: Vec<&TextItem> = order.iter().map(|&(index, _)| group[index]).collect();
2180            display_index = order.iter().map(|&(index, _)| index).collect();
2181            texts = order
2182                .into_iter()
2183                .map(|(index, logical)| {
2184                    if visual_rtl {
2185                        Cow::Owned(logical)
2186                    } else {
2187                        Cow::Borrowed(group[index].text.as_str())
2188                    }
2189                })
2190                .collect();
2191            group = reordered;
2192        } else {
2193            if !preserve_stream_order {
2194                sort_along_x_keeping_marks(&mut group);
2195            }
2196            texts = group
2197                .iter()
2198                .map(|i| Cow::Borrowed(i.text.as_str()))
2199                .collect();
2200        }
2201        ordered_line_groups.push(LineGroup {
2202            page,
2203            y,
2204            group,
2205            texts,
2206            preserve_stream_order,
2207            bidi,
2208            display_index,
2209            display_gaps,
2210            glyph_floor,
2211        });
2212    }
2213
2214    // Sort groups by page then Y descending (top of page first)
2215    ordered_line_groups.sort_by(|a, b| a.page.cmp(&b.page).then_with(|| b.y.total_cmp(&a.y)));
2216
2217    let mut merged = Vec::new();
2218
2219    for line in &ordered_line_groups {
2220        let LineGroup {
2221            group,
2222            texts,
2223            preserve_stream_order,
2224            bidi,
2225            display_index,
2226            display_gaps,
2227            glyph_floor,
2228            ..
2229        } = line;
2230        // A line of right-to-left text is walked in reading order, which
2231        // runs leftwards through its RTL words and rightwards through an
2232        // embedded Latin phrase or number, but its word gaps are gaps between
2233        // neighbours on the page. The gap at a junction of the walk is the
2234        // gap between the two fragments when they are neighbours in screen
2235        // order; where the walk jumps into or out of an embedded run, it is
2236        // the gap between that run's near end and the fragment it turned
2237        // from — the wider of the two screen gaps that flank the jump, the
2238        // other being inside a run.
2239        let junction_gap = |from: usize, to: usize| -> f32 {
2240            let (a, b) = (display_index[from], display_index[to]);
2241            if a.abs_diff(b) == 1 {
2242                return display_gaps[a.min(b)];
2243            }
2244            let towards = |at: usize, other: usize| {
2245                if other > at {
2246                    display_gaps.get(at).copied()
2247                } else {
2248                    at.checked_sub(1).and_then(|k| display_gaps.get(k).copied())
2249                }
2250            };
2251            match (towards(a, b), towards(b, a)) {
2252                (Some(x), Some(y)) => x.max(y),
2253                (Some(x), None) | (None, Some(x)) => x,
2254                (None, None) => 0.0,
2255            }
2256        };
2257        let glyph_floor = *glyph_floor;
2258        let mut i = 0;
2259        while i < group.len() {
2260            let first = group[i];
2261            let mut text = texts[i].to_string();
2262            let mut legacy_symbol_rewrite = first.legacy_symbol_rewrite;
2263            let mut end_x = first.x + effective_merge_width(first);
2264            let mut box_right = first.x + first.width;
2265            let mut box_left = first.x;
2266
2267            // Tracked display text: run-local space floor overrides the
2268            // fixed thresholds for this run's junctions (see helper).
2269            let tracked = if *preserve_stream_order || *bidi {
2270                None
2271            } else {
2272                tracked_run_space_floor(group, i)
2273            };
2274
2275            let mut j = i + 1;
2276            while j < group.len() {
2277                let next = group[j];
2278                let next_text: &str = &texts[j];
2279                let gap = if *bidi {
2280                    junction_gap(j - 1, j)
2281                } else {
2282                    next.x - end_x
2283                };
2284                // A small-caps junction is mid-word: it both survives the
2285                // font-size band below and must never take a space.
2286                let small_caps_join = is_small_caps_continuation(&text, first, next, gap);
2287                // Must be similar font size, except for genuine small-caps
2288                // runs, where the shrunken capitals are the same word as the
2289                // full-size initial (see helper).
2290                if (next.font_size - first.font_size).abs() > first.font_size * MERGE_FONT_SIZE_BAND
2291                    && !small_caps_join
2292                {
2293                    break;
2294                }
2295                // Preserve the existing join behavior at non-bold style
2296                // boundaries, including italic fragments within formulas.
2297                if next.is_italic != first.is_italic
2298                    || next.is_underline != first.is_underline
2299                    || next.is_strikeout != first.is_strikeout
2300                {
2301                    break;
2302                }
2303                // Merging walks along +x, which is only the reading direction
2304                // of upright runs. A vertical stamp whose box bottom shares a
2305                // baseline with a body line must not be glued onto it, two
2306                // side-by-side vertical runs are separate lines, and the
2307                // fragments of an upside-down run read towards -x, so an
2308                // ascending walk would concatenate them reversed.
2309                if !first.is_upright() || !next.is_upright() {
2310                    break;
2311                }
2312                // A merged item carries one `advance_known`: never fold a
2313                // measured run and an estimated one into the same item.
2314                if next.advance_known != first.advance_known {
2315                    break;
2316                }
2317                let x_gap_max = if *preserve_stream_order && is_standalone_bullet_text(&text) {
2318                    first.font_size * 1.2
2319                } else {
2320                    first.font_size * 0.5
2321                };
2322                if gap > x_gap_max {
2323                    break;
2324                }
2325                // A dependent sign is drawn over the glyph before it, as far
2326                // behind the pen as that glyph is wide: it stays with it.
2327                if gap < -first.font_size * 0.5
2328                    && !preserve_stream_order
2329                    && !is_zero_width_mark(next)
2330                {
2331                    break;
2332                }
2333                let previous = group[j - 1];
2334                if clip_boundaries::separated_runs(
2335                    previous,
2336                    clip_by_item.get(&(previous as *const TextItem)),
2337                    next,
2338                    clip_by_item.get(&(next as *const TextItem)),
2339                ) {
2340                    break;
2341                }
2342                // Vertically stacked DIGITS at different baselines — the
2343                // numerator over the denominator of a case fraction ("1"
2344                // over "3") — are not one number even though they share the
2345                // 5pt band and overlap in x. Letters keep merging: rotated
2346                // running headers and diagram labels stack letters too, and
2347                // splitting those only scatters fragments into body text.
2348                let digits = |t: &str| !t.is_empty() && t.chars().all(char::is_numeric);
2349                if !preserve_stream_order
2350                    && (next.y - first.y).abs() > first.font_size * 0.3
2351                    && gap < -effective_merge_width(next) * 0.5
2352                    && digits(text.trim())
2353                    && digits(next_text.trim())
2354                {
2355                    break;
2356                }
2357                // Insert space at word boundaries.
2358                // Base threshold 0.08; raised to 0.13 for lowercase→lowercase
2359                // junctions to accommodate Tc/Tw character-spacing adjustments
2360                // that shift advance widths relative to Td positioning.
2361                let threshold = {
2362                    let prev_last = text.trim_end().chars().last();
2363                    let next_first = next_text.trim_start().chars().next();
2364                    // Never insert space before joining punctuation
2365                    if next_first.is_some_and(|c| matches!(c, '.' | ',' | ';' | ')' | ']' | '}')) {
2366                        first.font_size * 0.25
2367                    } else if prev_last.is_some_and(|c| c.is_lowercase())
2368                        && next_first.is_some_and(|c| c.is_lowercase())
2369                    {
2370                        // Lowercase→lowercase: likely mid-word, use wider threshold
2371                        first.font_size * 0.13
2372                    } else if prev_last.is_some_and(crate::text_utils::is_rtl_char)
2373                        && next_first.is_some_and(crate::text_utils::is_rtl_char)
2374                    {
2375                        // Two pieces of one Hebrew or Arabic word, split where
2376                        // the producer kerned or rejoined a glyph run: as
2377                        // mid-word as a lowercase junction.
2378                        first.font_size * 0.13
2379                    } else {
2380                        first.font_size * 0.08
2381                    }
2382                };
2383                let needs_bullet_space = *preserve_stream_order
2384                    && is_standalone_bullet_text(&text)
2385                    && !next_text.trim().is_empty();
2386                let effective_threshold = match (tracked, glyph_floor) {
2387                    (Some((run_end, floor)), _) if j <= run_end => floor,
2388                    (_, Some(floor_em)) => {
2389                        floor_em * group[j - 1].font_size.min(next.font_size).max(1.0)
2390                    }
2391                    _ => threshold,
2392                };
2393                let bold_boundary = next.is_bold != first.is_bold;
2394                let explicit_bold_space = bold_boundary
2395                    && (text.ends_with(char::is_whitespace)
2396                        || next_text.starts_with(char::is_whitespace));
2397                // Numeric fragments have their own joining thresholds in
2398                // line assembly. Injecting a word space here would split a
2399                // number whose decimal point or digits use a bold font.
2400                let numeric_boundary = bold_boundary
2401                    && match (text.chars().last(), next_text.chars().next()) {
2402                        (Some(p), Some(c)) if p.is_ascii_digit() => {
2403                            c.is_ascii_digit() || matches!(c, '.' | ',' | '%')
2404                        }
2405                        (Some('.' | ','), Some(c)) if c.is_ascii_digit() => {
2406                            let prefix = &text[..text.len() - 1];
2407                            // A separate decimal glyph can start a fractional
2408                            // number even without a preceding integer run.
2409                            prefix.trim().is_empty()
2410                                || prefix.chars().last().is_some_and(|c| c.is_ascii_digit())
2411                        }
2412                        (Some('+' | '-'), Some(c)) => c.is_ascii_digit(),
2413                        _ => false,
2414                    };
2415                if !small_caps_join
2416                    && (needs_bullet_space || (gap > effective_threshold && !numeric_boundary))
2417                    && !explicit_bold_space
2418                    && !text.ends_with(char::is_whitespace)
2419                {
2420                    text.push(' ');
2421                }
2422                // Keep bold runs separate, but preserve the same word-space
2423                // decision as an unstyled merge. Otherwise the later line
2424                // assembler's wider joining threshold can glue words when
2425                // newly recovered font flags split a previously merged run.
2426                if bold_boundary {
2427                    break;
2428                }
2429                text.push_str(next_text);
2430                legacy_symbol_rewrite |= next.legacy_symbol_rewrite;
2431                box_right = box_right.max(next.x + next.width);
2432                box_left = box_left.min(next.x);
2433                let next_end = next.x + effective_merge_width(next);
2434                // The line's right edge does not move for a dependent sign
2435                // drawn behind the pen: the fragment after the sign is
2436                // measured from where the glyph under it left the pen, not
2437                // from the sign's origin.
2438                end_x = if *preserve_stream_order || is_zero_width_mark(next) {
2439                    end_x.max(next_end)
2440                } else {
2441                    next_end
2442                };
2443                j += 1;
2444            }
2445
2446            // Hebrew and Arabic presentation forms stand for letters; now
2447            // that the text reads in logical order, a ligature's letters
2448            // come out in reading order, and the joiners that held the
2449            // characters of one glyph together through the read-back have
2450            // done their work.
2451            let mut text = crate::bidi::normalize_presentation_forms(&text).into_owned();
2452            crate::bidi::strip_glyph_joiners(&mut text);
2453
2454            merged.push(TextItem {
2455                text,
2456                // An estimated run's item is the union of the estimated boxes
2457                // it merged, including any fragment that backtracked in x; so
2458                // is a run with right-to-left text, whose fragments were
2459                // walked in reading order rather than along +x.
2460                x: if first.advance_known && !*bidi {
2461                    first.x
2462                } else {
2463                    box_left
2464                },
2465                y: first.y,
2466                width: if first.advance_known && !*bidi {
2467                    end_x - first.x
2468                } else {
2469                    box_right - box_left
2470                },
2471                height: first.height,
2472                font: first.font.clone(),
2473                font_tag: first.font_tag.clone(),
2474                legacy_symbol_rewrite,
2475                font_size: first.font_size,
2476                page: first.page,
2477                is_bold: first.is_bold,
2478                is_italic: first.is_italic,
2479                font_weight: first.font_weight,
2480                bold_source: first.bold_source,
2481                fixed_pitch: first.fixed_pitch,
2482                fill_color: first.fill_color,
2483                stroke_color: first.stroke_color,
2484                render_mode: first.render_mode,
2485                is_underline: first.is_underline,
2486                is_strikeout: first.is_strikeout,
2487                rotation: first.rotation,
2488                advance_known: first.advance_known,
2489                item_type: first.item_type.clone(),
2490                mcid: first.mcid,
2491                baseline_shift: 0.0,
2492            });
2493
2494            i = j;
2495        }
2496    }
2497
2498    merged
2499}
2500
2501/// Helper to get f32 from Object
2502pub(crate) fn get_number(obj: &Object) -> Option<f32> {
2503    match obj {
2504        Object::Integer(i) => Some(*i as f32),
2505        Object::Real(r) => Some(*r),
2506        _ => None,
2507    }
2508}
2509
2510#[cfg(test)]
2511mod tests {
2512    use super::*;
2513    use crate::text_utils::{is_cjk_char, is_rtl_char, is_rtl_text, sort_line_items};
2514    use crate::types::{ItemType, PdfLine, TextLine};
2515    use layout::{detect_columns, is_newspaper_layout, ColumnRegion};
2516
2517    /// Glyph-per-item run at `fs`=12 with the given inter-glyph gap (pt).
2518    #[test]
2519    fn numeric_fragments_are_digits_of_any_script_with_their_separators() {
2520        assert!(numeric_fragment("12"));
2521        assert!(numeric_fragment(" 1,234.5 "));
2522        assert!(numeric_fragment("\u{0662}\u{0664}"));
2523        assert!(numeric_fragment("\u{0663}\u{066B}\u{0665}"));
2524        assert!(!numeric_fragment(""));
2525        assert!(!numeric_fragment("a1"));
2526        assert!(!numeric_fragment("\u{05D0}"));
2527        assert!(!numeric_fragment("-"));
2528        // A separator on its own is no number, but belongs to the number
2529        // beside it.
2530        assert!(!numeric_fragment(","));
2531        assert!(separator_fragment(","));
2532        assert!(inside_number("21", ","));
2533        assert!(inside_number(",", "847"));
2534        assert!(inside_number("1", "2"));
2535        assert!(!inside_number(".", "."));
2536        assert!(!inside_number("a", "1"));
2537        assert!(!inside_number("1", "\u{05D0}"));
2538    }
2539
2540    #[test]
2541    fn glyph_run_word_gap_floor_splits_letter_gaps_from_word_gaps() {
2542        // Letter gaps under a tenth of an em and two word gaps of a third:
2543        // the floor sits between the classes.
2544        let gaps = [0.06, 0.08, 0.07, 0.34, 0.09, 0.06, 0.32, 0.08];
2545        let floor = glyph_run_word_gap_floor(&gaps).expect("two classes");
2546        assert!(floor > 0.09 && floor < 0.32, "{floor}");
2547    }
2548
2549    #[test]
2550    fn glyph_run_word_gap_floor_holds_one_word_together() {
2551        // One class of gaps, all mid-word sized: the line is one word.
2552        let gaps = [0.06, 0.09, 0.11, 0.07, 0.10];
2553        assert_eq!(glyph_run_word_gap_floor(&gaps), Some(f32::INFINITY));
2554    }
2555
2556    #[test]
2557    fn glyph_run_word_gap_floor_leaves_uniform_wide_gaps_to_the_thresholds() {
2558        // One class of gaps as wide as word spaces (one-letter words, or a
2559        // short run whose gaps do not tell): no floor of its own.
2560        let gaps = [0.24, 0.26, 0.25, 0.25];
2561        assert_eq!(glyph_run_word_gap_floor(&gaps), None);
2562        // Too few gaps to read a distribution from.
2563        assert_eq!(glyph_run_word_gap_floor(&[0.05, 0.3]), None);
2564    }
2565
2566    fn glyph_run(chars: &str, start_x: f32, glyph_w: f32, gap: f32) -> Vec<TextItem> {
2567        let mut x = start_x;
2568        let mut out = Vec::new();
2569        for c in chars.chars() {
2570            out.push(make_merge_item(&c.to_string(), x, glyph_w));
2571            x += glyph_w + gap;
2572        }
2573        out
2574    }
2575
2576    #[test]
2577    fn tracked_caps_run_collapses_to_word() {
2578        // Display tracking: every letter gap (0.19 em) clears the fixed
2579        // space threshold — without the run-local floor this reads "H O W".
2580        let items = glyph_run("HOW", 100.0, 10.0, 2.3);
2581        let merged = merge_text_items(items);
2582        assert_eq!(merged.len(), 1);
2583        assert_eq!(merged[0].text, "HOW");
2584    }
2585
2586    #[test]
2587    fn tracked_run_keeps_word_gaps_bimodal() {
2588        // Letters at 0.19 em, word gaps at 0.42 em (below the 0.5 em item
2589        // break): the split must land between the modes. Needs >=4 gaps to
2590        // enter the bimodal tier — short runs use the strict uniform gate.
2591        let mut items = glyph_run("ITISOK", 100.0, 8.0, 2.3);
2592        for i in 2..6 {
2593            items[i].x += 2.8; // word gap at T|I
2594        }
2595        for i in 4..6 {
2596            items[i].x += 2.8; // word gap at S|O
2597        }
2598        let merged = merge_text_items(items);
2599        assert_eq!(merged.len(), 1);
2600        assert_eq!(merged[0].text, "IT IS OK");
2601    }
2602
2603    #[test]
2604    fn lowercase_spaced_singles_stay_words() {
2605        // "x y z" variables: same gap shape but lowercase — the short-run
2606        // caps requirement keeps genuine spaced singles apart.
2607        let items = glyph_run("xyz", 100.0, 6.0, 2.3);
2608        let merged = merge_text_items(items);
2609        assert_eq!(merged.len(), 1);
2610        assert_eq!(merged[0].text, "x y z");
2611    }
2612
2613    #[test]
2614    fn kerned_singles_unaffected() {
2615        // Tiny kerning gaps never triggered spaces before and still don't.
2616        let items = glyph_run("WORD", 100.0, 8.0, 0.3);
2617        let merged = merge_text_items(items);
2618        assert_eq!(merged.len(), 1);
2619        assert_eq!(merged[0].text, "WORD");
2620    }
2621
2622    #[test]
2623    fn long_lowercase_spaced_singles_keep_boundaries() {
2624        // Review: a 5+ single-letter lowercase list has the tracked gap
2625        // shape at any length — the convention gate must protect it in
2626        // the >=4-gap tier too.
2627        let items = glyph_run("abcde", 100.0, 6.0, 2.3);
2628        let merged = merge_text_items(items);
2629        assert_eq!(merged.len(), 1);
2630        assert_eq!(merged[0].text, "a b c d e");
2631    }
2632
2633    #[test]
2634    fn han_run_with_nonuniform_gaps_never_gains_spaces() {
2635        // Review: a bimodal gap distribution (justification, punctuation
2636        // spacing) must not manufacture word boundaries in Han text.
2637        let mut items = glyph_run("北京时事快报", 100.0, 12.0, 1.4);
2638        for item in items.iter_mut().skip(3) {
2639            item.x += 3.0; // wide gap after the third glyph
2640        }
2641        let merged = merge_text_items(items);
2642        assert_eq!(merged.len(), 1);
2643        assert_eq!(merged[0].text, "北京时事快报");
2644    }
2645
2646    #[test]
2647    fn uppercase_leading_spaced_singles_keep_boundaries() {
2648        // "A b c d e" is indistinguishable from a title-case tracked word
2649        // without reliable tracking metadata, so preserve its boundaries.
2650        let items = glyph_run("Abcde", 100.0, 7.0, 2.3);
2651        let merged = merge_text_items(items);
2652        assert_eq!(merged.len(), 1);
2653        assert_eq!(merged[0].text, "A b c d e");
2654    }
2655
2656    #[test]
2657    fn cjk_glyph_run_collapses_without_spaces() {
2658        // CJK sets one glyph per item with loose gaps; CJK uses no spaces,
2659        // and the non-alphabetic run passes the caps gate.
2660        let items = glyph_run("北京时事", 100.0, 12.0, 1.4);
2661        let merged = merge_text_items(items);
2662        assert_eq!(merged.len(), 1);
2663        assert_eq!(merged[0].text, "北京时事");
2664    }
2665
2666    fn make_merge_item(text: &str, x: f32, width: f32) -> TextItem {
2667        TextItem {
2668            text: text.into(),
2669            x,
2670            y: 700.0,
2671            width,
2672            height: 12.0,
2673            font: "F1".into(),
2674            font_tag: String::new(),
2675            legacy_symbol_rewrite: false,
2676            font_size: 12.0,
2677            page: 1,
2678            is_bold: false,
2679            is_italic: false,
2680            font_weight: None,
2681            bold_source: None,
2682            fixed_pitch: None,
2683            fill_color: None,
2684            stroke_color: None,
2685            render_mode: None,
2686            is_underline: false,
2687            is_strikeout: false,
2688            rotation: 0.0,
2689            advance_known: true,
2690            item_type: ItemType::Text,
2691            mcid: None,
2692            baseline_shift: 0.0,
2693        }
2694    }
2695
2696    #[test]
2697    fn explicit_trailing_space_is_not_doubled_across_a_word_gap() {
2698        // "for " already carries its space run; the 4pt gap (0.33 em) that
2699        // run left clears the word threshold but must not add a second one.
2700        let items = vec![
2701            make_merge_item("for ", 100.0, 21.6),
2702            make_merge_item("the", 125.6, 21.6),
2703        ];
2704        let merged = merge_text_items(items);
2705        assert_eq!(merged.len(), 1);
2706        assert_eq!(merged[0].text, "for the");
2707    }
2708
2709    fn with_mcid(mut item: TextItem) -> TextItem {
2710        item.mcid = Some(1);
2711        item
2712    }
2713
2714    fn make_line(x1: f32, y1: f32, x2: f32, y2: f32) -> PdfLine {
2715        PdfLine {
2716            x1,
2717            y1,
2718            x2,
2719            y2,
2720            page: 1,
2721        }
2722    }
2723
2724    #[test]
2725    fn trace_text_preview_truncates_on_char_boundary() {
2726        let text = format!("{}{}tail", "a".repeat(79), '\u{FFFD}');
2727        let preview = trace_text_preview(&text, 80);
2728
2729        assert_eq!(preview.chars().count(), 80);
2730        assert!(text.is_char_boundary(preview.len()));
2731        assert!(preview.ends_with('\u{FFFD}'));
2732    }
2733
2734    /// `text` shown glyph by glyph at `size`, each glyph its own fragment
2735    /// with the advance of Helvetica-Bold, as small kerned type is shown.
2736    fn glyph_fragments(text: &str, x0: f32, y: f32, size: f32) -> Vec<TextItem> {
2737        let advance = |ch: char| -> f32 {
2738            let units = match ch {
2739                'A' | 'U' => 722.0,
2740                'E' | 'P' | 'S' => 667.0,
2741                'M' => 833.0,
2742                'O' => 778.0,
2743                'W' => 944.0,
2744                'd' | 'g' | 'n' | 'o' | 'p' => 611.0,
2745                'i' | 'l' => 278.0,
2746                'r' => 389.0,
2747                't' => 333.0,
2748                _ => 556.0,
2749            };
2750            units * size / 1000.0
2751        };
2752        let mut items = Vec::new();
2753        let mut x = x0;
2754        for ch in text.chars() {
2755            let w = advance(ch);
2756            if ch != ' ' {
2757                let mut item = make_merge_item(&ch.to_string(), x, w);
2758                item.y = y;
2759                item.height = size;
2760                item.font_size = size;
2761                items.push(item);
2762            }
2763            x += if ch == ' ' { 278.0 * size / 1000.0 } else { w };
2764        }
2765        items
2766    }
2767
2768    /// The merged items' letters along each baseline, top line first,
2769    /// spaces left out: what a line reads as, whatever its word breaks.
2770    fn letters_by_baseline(merged: &[TextItem]) -> Vec<(f32, String)> {
2771        let mut lines: Vec<(f32, Vec<&TextItem>)> = Vec::new();
2772        for item in merged {
2773            match lines.iter_mut().find(|(y, _)| (*y - item.y).abs() < 0.01) {
2774                Some((_, line)) => line.push(item),
2775                None => lines.push((item.y, vec![item])),
2776            }
2777        }
2778        lines.sort_by(|a, b| b.0.total_cmp(&a.0));
2779        lines
2780            .into_iter()
2781            .map(|(y, mut line)| {
2782                line.sort_by(|a, b| a.x.total_cmp(&b.x));
2783                (y, line.iter().map(|i| i.text.replace(' ', "")).collect())
2784            })
2785            .collect()
2786    }
2787
2788    fn sized_item(text: &str, x: f32, width: f32, y: f32, size: f32) -> TextItem {
2789        let mut item = make_merge_item(text, x, width);
2790        item.y = y;
2791        item.height = size;
2792        item.font_size = size;
2793        item
2794    }
2795
2796    /// Two lines of 4.7 pt type on a 4.5 pt pitch, each shown glyph by
2797    /// glyph, keep their own lines and read in order; on a 6 pt pitch they
2798    /// always did.
2799    #[test]
2800    fn small_lines_under_five_points_apart_keep_their_own_lines() {
2801        let mut items = glyph_fragments("Apples Picked", 100.0, 700.0, 4.7);
2802        items.extend(glyph_fragments("Oranges Sold", 100.6, 695.5, 4.7));
2803        assert_eq!(
2804            letters_by_baseline(&merge_text_items(items)),
2805            vec![
2806                (700.0, "ApplesPicked".to_string()),
2807                (695.5, "OrangesSold".to_string())
2808            ]
2809        );
2810        let mut items = glyph_fragments("Water Usage", 100.0, 650.0, 4.7);
2811        items.extend(glyph_fragments("Energy Mix", 101.2, 644.0, 4.7));
2812        assert_eq!(
2813            letters_by_baseline(&merge_text_items(items)),
2814            vec![
2815                (650.0, "WaterUsage".to_string()),
2816                (644.0, "EnergyMix".to_string())
2817            ]
2818        );
2819    }
2820
2821    /// The line window follows the type where either fragment is small and
2822    /// is the 5 pt of old between two fragments of ordinary size: markers
2823    /// raised a third of an em stay with their line, a run raised 5 pt over
2824    /// 12 pt type keeps its own line as it always did, a line of small type
2825    /// under larger type keeps its own, a small glyph shown first does not
2826    /// narrow the window for the text after it, sloped glyph-by-glyph type
2827    /// stays one line past the baselines a line records, images keep the
2828    /// window of old without pulling small type, and fragments without a
2829    /// type size keep the 5 pt window.
2830    #[test]
2831    fn the_line_window_follows_the_type_size() {
2832        // A 7.97 pt affiliation marker raised 4.3 pt over an 11.96 pt name.
2833        let name = sized_item("Huo", 100.0, 20.0, 700.0, 11.96);
2834        let marker = sized_item("1", 120.5, 4.0, 704.3, 7.97);
2835        assert_eq!(group_fragments_into_lines(&[name, marker]).len(), 1);
2836        // A 5.5 pt marker raised 3 pt over 8 pt type.
2837        let word = sized_item("word", 100.0, 16.0, 700.0, 8.0);
2838        let mark = sized_item("2", 116.2, 3.0, 703.0, 5.5);
2839        assert_eq!(group_fragments_into_lines(&[word, mark]).len(), 1);
2840        // A run raised 5 pt over 12 pt type is not within the window,
2841        // exactly as before.
2842        let base = sized_item("base", 100.0, 24.0, 500.0, 12.0);
2843        let raised = sized_item("super", 124.0, 30.0, 505.0, 12.0);
2844        assert_eq!(group_fragments_into_lines(&[base, raised]).len(), 2);
2845        // A 7 pt reference mark raised 4 pt over 12 pt text stays with it.
2846        let word = sized_item("word", 100.0, 24.0, 700.0, 12.0);
2847        let mark = sized_item("1", 124.2, 3.5, 704.0, 7.0);
2848        assert_eq!(group_fragments_into_lines(&[word, mark]).len(), 1);
2849        // A 10 pt line 16 pt under a 40 pt heading, a 4 pt line 4.5 pt
2850        // under a 7 pt one, and a 4 pt line 4.5 pt under a 12 pt one: type
2851        // of any size pulls a fragment of smaller type no farther than that
2852        // fragment's em.
2853        let heading = sized_item("Title", 100.0, 100.0, 700.0, 40.0);
2854        let body = sized_item("body", 100.0, 20.0, 684.0, 10.0);
2855        assert_eq!(group_fragments_into_lines(&[heading, body]).len(), 2);
2856        let line = sized_item("line", 100.0, 14.0, 700.0, 7.0);
2857        let tiny = sized_item("tiny", 100.0, 8.0, 695.5, 4.0);
2858        assert_eq!(group_fragments_into_lines(&[line, tiny]).len(), 2);
2859        let line = sized_item("line", 100.0, 24.0, 700.0, 12.0);
2860        let tiny = sized_item("tiny", 100.0, 8.0, 695.5, 4.0);
2861        assert_eq!(group_fragments_into_lines(&[line, tiny]).len(), 2);
2862        // Forty 10 pt glyphs on a slope of a tenth of a point each are one
2863        // line, past the baselines the line records.
2864        let sloped: Vec<TextItem> = (0..40)
2865            .map(|i| {
2866                sized_item(
2867                    "g",
2868                    100.0 + 6.0 * i as f32,
2869                    6.0,
2870                    700.0 + 0.1 * i as f32,
2871                    10.0,
2872                )
2873            })
2874            .collect();
2875        assert_eq!(group_fragments_into_lines(&sloped).len(), 1);
2876        // An image keeps the window of old: a 2 pt tall one 3 pt under a
2877        // 10 pt line groups with it, as before, and a tall one 0.4 pt from
2878        // a line of 0.55 pt type does not pull a second such line, 0.4 pt
2879        // beyond the first, into the same line.
2880        let text = sized_item("text", 100.0, 20.0, 700.0, 10.0);
2881        let mut rule = sized_item("[Image]", 100.0, 200.0, 697.0, 2.0);
2882        rule.item_type = ItemType::Image;
2883        rule.font_size = 0.0;
2884        assert_eq!(group_fragments_into_lines(&[text, rule]).len(), 1);
2885        let mut figure = sized_item("[Image]", 28.7, 564.0, 435.1, 317.5);
2886        figure.item_type = ItemType::Image;
2887        figure.font_size = 0.0;
2888        let upper = sized_item("materials", 87.9, 20.0, 434.68, 0.55);
2889        let lower = sized_item("respective", 29.4, 20.0, 434.27, 0.55);
2890        let beside_figure = [figure, upper, lower];
2891        let lines = group_fragments_into_lines(&beside_figure);
2892        assert_eq!(lines.len(), 2);
2893        assert_eq!(lines[1].2.len(), 1);
2894        // A 3 pt bullet shown first on the baseline does not narrow the
2895        // window for the 10 pt text after it: its 6 pt marker raised 3.5 pt
2896        // still joins.
2897        let bullet = sized_item("•", 90.0, 3.0, 700.0, 3.0);
2898        let text = sized_item("text", 100.0, 20.0, 700.0, 10.0);
2899        let marker = sized_item("2", 120.2, 3.0, 703.5, 6.0);
2900        assert_eq!(group_fragments_into_lines(&[bullet, text, marker]).len(), 1);
2901        // A mark joins the text it is raised over though a smaller fragment
2902        // sits nearer to it: a 4 pt mark 3.8 pt over 12 pt text, with a 4 pt
2903        // glyph hanging 1 pt below that text.
2904        let text = sized_item("word", 100.0, 24.0, 700.0, 12.0);
2905        let hanging = sized_item("n", 125.0, 2.0, 701.0, 4.0);
2906        let mark = sized_item("1", 124.2, 2.0, 703.8, 4.0);
2907        assert_eq!(group_fragments_into_lines(&[text, hanging, mark]).len(), 1);
2908        // Forty-eight glyphs of 4.7 pt type on a slope of a tenth of a point each
2909        // stay one line as far as the fixed window reaches: the line records
2910        // every baseline inside it.
2911        let sloped: Vec<TextItem> = (0..48)
2912            .map(|i| {
2913                sized_item(
2914                    "g",
2915                    100.0 + 3.0 * i as f32,
2916                    3.0,
2917                    700.0 + 0.1 * i as f32,
2918                    4.7,
2919                )
2920            })
2921            .collect();
2922        assert_eq!(group_fragments_into_lines(&sloped).len(), 1);
2923        // Two 4.7 pt lines 4.5 pt apart, and two 6 pt apart.
2924        let a = sized_item("a", 100.0, 3.0, 700.0, 4.7);
2925        let b = sized_item("b", 100.0, 3.0, 695.5, 4.7);
2926        let c = sized_item("c", 100.0, 3.0, 694.0, 4.7);
2927        assert_eq!(group_fragments_into_lines(&[a.clone(), b]).len(), 2);
2928        assert_eq!(group_fragments_into_lines(&[a, c]).len(), 2);
2929        // The same two lines set 3° off level: each run's box is 10 pt
2930        // tall, but its type is still 4.7 pt.
2931        let mut a = sized_item("Apples Picked", 100.0, 100.0, 700.0, 4.7);
2932        let mut b = sized_item("Oranges Sold", 100.0, 100.0, 695.5, 4.7);
2933        for run in [&mut a, &mut b] {
2934            run.rotation = 3.0;
2935            run.height = 10.0;
2936        }
2937        assert_eq!(group_fragments_into_lines(&[a, b]).len(), 2);
2938        // A 4 pt subscript 2.3 pt under its 7 pt base text, on a line
2939        // whose first fragment sits 2 pt above that text: it joins by the
2940        // base text's baseline, though the first fragment is 4.3 pt away.
2941        let first = sized_item("and time horizons", 56.7, 56.0, 332.92, 7.0);
2942        let base = sized_item("tons of CO", 133.2, 130.0, 330.92, 7.0);
2943        let sub = sized_item("2", 262.7, 2.2, 328.63, 4.06);
2944        assert_eq!(group_fragments_into_lines(&[first, base, sub]).len(), 1);
2945        // No type size: the 5 pt window of old.
2946        let p = sized_item("p", 100.0, 5.0, 700.0, 0.0);
2947        let q = sized_item("q", 110.0, 5.0, 695.1, 0.0);
2948        let r = sized_item("r", 100.0, 5.0, 694.9, 0.0);
2949        assert_eq!(group_fragments_into_lines(&[p.clone(), q]).len(), 1);
2950        assert_eq!(group_fragments_into_lines(&[p, r]).len(), 2);
2951    }
2952
2953    #[test]
2954    fn merge_items_breaks_at_style_boundaries() {
2955        // A styled run adjacent to plain text must stay a separate item —
2956        // merging would erase the flags (italic) or stretch the span
2957        // (underline) before markdown emission sees them.
2958        let mut italic = make_merge_item("emphasis", 150.0, 40.0);
2959        italic.is_italic = true;
2960        let mut underlined = make_merge_item("term", 195.0, 20.0);
2961        underlined.is_underline = true;
2962        let items = vec![
2963            make_merge_item("plain lead", 100.0, 48.0),
2964            italic,
2965            underlined,
2966            make_merge_item("plain tail", 218.0, 45.0),
2967        ];
2968        let merged = merge_text_items(items);
2969        assert_eq!(merged.len(), 4);
2970        assert!(merged[1].is_italic && !merged[1].is_underline);
2971        assert!(merged[2].is_underline && !merged[2].is_italic);
2972        assert!(!merged[3].is_underline && !merged[3].is_italic);
2973    }
2974
2975    #[test]
2976    fn merge_follows_the_bold_verdict_not_the_weight_class() {
2977        use crate::types::BoldSource;
2978
2979        // A medium-weight label leading a light paragraph: neither is bold,
2980        // so the runs merge into one item whatever their weight classes,
2981        // and the item carries its first run's weight.
2982        let mut label = make_merge_item("Label:", 100.0, 36.0);
2983        label.font_weight = Some(500);
2984        let mut body = make_merge_item("body", 137.2, 24.0);
2985        body.font_weight = Some(300);
2986        let mut more = make_merge_item("text", 163.6, 24.0);
2987        more.font_weight = Some(300);
2988        let items = vec![label.clone(), body.clone(), more.clone()];
2989        let merged = merge_text_items_with_clips(items, &[], false, &[]);
2990        assert_eq!(merged.len(), 1);
2991        assert_eq!(merged[0].text, "Label: body text");
2992        assert_eq!(merged[0].font_weight, Some(500));
2993
2994        // Once the weight class has made the label bold (the option's pass,
2995        // `read_bold_from_weight`), the label stays its own item, and the
2996        // merge still decides the word space the way an unstyled merge
2997        // would, so the later line assembler does not glue "Label:" onto
2998        // "body".
2999        let mut items = vec![label, body, more];
3000        super::content_stream::read_bold_from_weight(&mut items, 500);
3001        let apart = merge_text_items_with_clips(items, &[], false, &[]);
3002        assert_eq!(apart.len(), 2);
3003        assert_eq!(apart[0].text, "Label: ");
3004        assert!(apart[0].is_bold);
3005        assert_eq!(apart[0].bold_source, Some(BoldSource::WeightClass));
3006        assert_eq!(apart[1].text, "body text");
3007        assert!(!apart[1].is_bold);
3008        assert_eq!(apart[1].bold_source, None);
3009
3010        // Runs of different weight that agree on bold merge: a 700 face
3011        // beside a 400 face whose name says bold are one item, which keeps
3012        // the first run's weight class and bold source.
3013        let mut heavy = make_merge_item("Heavy", 100.0, 30.0);
3014        heavy.font_weight = Some(700);
3015        let mut named = make_merge_item("named", 132.0, 30.0);
3016        named.font_weight = Some(400);
3017        named.is_bold = true;
3018        named.bold_source = Some(BoldSource::FontName);
3019        let mut items = vec![heavy, named];
3020        super::content_stream::read_bold_from_weight(&mut items, 600);
3021        let merged = merge_text_items_with_clips(items, &[], false, &[]);
3022        assert_eq!(merged.len(), 1);
3023        assert_eq!(merged[0].text, "Heavy named");
3024        assert_eq!(merged[0].font_weight, Some(700));
3025        assert_eq!(merged[0].bold_source, Some(BoldSource::WeightClass));
3026
3027        // Runs that know no weight merge as before.
3028        let unknown = vec![
3029            make_merge_item("no", 100.0, 12.0),
3030            make_merge_item("weight", 113.2, 36.0),
3031        ];
3032        assert_eq!(
3033            merge_text_items_with_clips(unknown, &[], false, &[]).len(),
3034            1
3035        );
3036    }
3037
3038    #[test]
3039    fn read_bold_from_weight_credits_the_weight_class_after_name_and_flags() {
3040        use crate::types::BoldSource;
3041
3042        let mut named = make_merge_item("named", 0.0, 10.0);
3043        named.font_weight = Some(700);
3044        named.is_bold = true;
3045        named.bold_source = Some(BoldSource::FontName);
3046        let mut painted = make_merge_item("painted", 20.0, 10.0);
3047        painted.font_weight = Some(650);
3048        painted.is_bold = true;
3049        painted.bold_source = Some(BoldSource::Painted);
3050        let mut light = make_merge_item("light", 40.0, 10.0);
3051        light.font_weight = Some(300);
3052        let unknown = make_merge_item("unknown", 60.0, 10.0);
3053        let mut items = vec![named, painted, light, unknown];
3054        super::content_stream::read_bold_from_weight(&mut items, 600);
3055        // The name outranks the weight class; the weight class outranks the
3056        // paint, since the face itself is heavy.
3057        assert_eq!(items[0].bold_source, Some(BoldSource::FontName));
3058        assert_eq!(items[1].bold_source, Some(BoldSource::WeightClass));
3059        assert!(!items[2].is_bold && items[2].bold_source.is_none());
3060        assert!(!items[3].is_bold && items[3].bold_source.is_none());
3061
3062        // The threshold is inclusive and honoured as given.
3063        let mut items = vec![make_merge_item("x", 0.0, 10.0)];
3064        items[0].font_weight = Some(500);
3065        super::content_stream::read_bold_from_weight(&mut items, 500);
3066        assert!(items[0].is_bold);
3067        assert_eq!(items[0].bold_source, Some(BoldSource::WeightClass));
3068    }
3069
3070    #[test]
3071    fn recovered_bold_preserves_word_spacing_at_style_boundary() {
3072        // A 0.1-em gap is a word boundary in merging, but the later line
3073        // assembler joins gaps below 0.15 em. Recovering bold must not lose
3074        // the space that the original all-plain merge would have emitted.
3075        let plain = vec![
3076            make_merge_item("KEY", 100.0, 24.0),
3077            make_merge_item("Body", 125.2, 24.0),
3078        ];
3079        let original = merge_text_items(plain.clone());
3080        let mut styled = plain;
3081        styled[0].is_bold = true;
3082        let recovered = merge_text_items(styled);
3083        assert_eq!(original[0].text, "KEY Body");
3084        assert_eq!(recovered.len(), 2);
3085        assert!(recovered[0].is_bold && !recovered[1].is_bold);
3086        assert_eq!(recovered[0].text, "KEY ");
3087        assert_eq!(recovered[1].text, "Body");
3088        assert_eq!(
3089            recovered
3090                .iter()
3091                .map(|item| item.text.as_str())
3092                .collect::<String>(),
3093            original[0].text
3094        );
3095        let line = TextLine {
3096            items: recovered,
3097            y: 100.0,
3098            page: 1,
3099            adaptive_threshold: 0.1,
3100        };
3101        assert_eq!(line.text(), "KEY Body");
3102        assert_eq!(
3103            line.text_with_formatting(true, false, false),
3104            "**KEY** Body"
3105        );
3106    }
3107
3108    #[test]
3109    fn recovered_bold_keeps_zero_gap_word_fragments_joined() {
3110        let plain = vec![
3111            make_merge_item("un", 100.0, 12.0),
3112            make_merge_item("known", 112.0, 30.0),
3113        ];
3114        let original = merge_text_items(plain.clone());
3115        let mut styled = plain;
3116        styled[0].is_bold = true;
3117        let recovered = merge_text_items(styled);
3118        assert_eq!(original[0].text, "unknown");
3119        assert_eq!(recovered.len(), 2);
3120        assert!(recovered[0].is_bold && !recovered[1].is_bold);
3121        assert_eq!(
3122            recovered
3123                .iter()
3124                .map(|item| item.text.as_str())
3125                .collect::<String>(),
3126            original[0].text
3127        );
3128        let line = TextLine {
3129            items: recovered,
3130            y: 100.0,
3131            page: 1,
3132            adaptive_threshold: 0.1,
3133        };
3134        assert_eq!(line.text(), "unknown");
3135        assert_eq!(line.text_with_formatting(true, false, false), "**un**known");
3136    }
3137
3138    #[test]
3139    fn bold_numeric_fragments_keep_line_assembly_spacing() {
3140        for (parts, gap, expected) in [
3141            (
3142                vec![("0", false), (".", true), ("86", false)],
3143                1.2,
3144                "0**.**86",
3145            ),
3146            (vec![("0.", true), ("86", false)], 1.2, "**0.**86"),
3147            (vec![(".", true), ("42", false)], 1.2, "**.**42"),
3148            (
3149                vec![("12 ", false), (".", true), ("55", false)],
3150                1.2,
3151                "12 **.**55",
3152            ),
3153            (
3154                vec![("12", false), (" .", true), ("55", false)],
3155                1.2,
3156                "12 **.**55",
3157            ),
3158            (vec![("1", false), ("23", true)], 1.2, "1**23**"),
3159            (vec![("12", true), ("%", false)], 1.2, "**12**%"),
3160            (vec![("+", true), ("12", false)], 1.2, "**+**12"),
3161            (
3162                vec![("1", false), (",", true), ("25", false)],
3163                1.2,
3164                "1**,**25",
3165            ),
3166            (vec![("Done.", true), ("2", false)], 1.2, "**Done.** 2"),
3167            (vec![("0. ", true), ("86", false)], 1.2, "**0.** 86"),
3168            (vec![("0.", true), ("86", false)], 4.8, "**0.** 86"),
3169            (vec![("KEY ", true), ("Body", false)], 1.2, "**KEY** Body"),
3170            (vec![("KEY", true), (" Body", false)], 1.2, "**KEY** Body"),
3171        ] {
3172            let mut x = 100.0;
3173            let items = parts
3174                .into_iter()
3175                .map(|(text, bold)| {
3176                    let width = text.len() as f32 * 6.0;
3177                    let mut item = make_merge_item(text, x, width);
3178                    item.is_bold = bold;
3179                    x += width + gap;
3180                    item
3181                })
3182                .collect();
3183            let line = TextLine {
3184                items: merge_text_items(items),
3185                y: 100.0,
3186                page: 1,
3187                adaptive_threshold: 0.1,
3188            };
3189            assert_eq!(line.text_with_formatting(true, false, false), expected);
3190            assert_eq!(line.text(), expected.replace("**", ""));
3191        }
3192    }
3193
3194    #[test]
3195    fn non_bold_style_boundaries_keep_existing_spacing() {
3196        for style in 0..3 {
3197            let mut first = make_merge_item("KEY", 100.0, 24.0);
3198            match style {
3199                0 => first.is_italic = true,
3200                1 => first.is_underline = true,
3201                _ => first.is_strikeout = true,
3202            }
3203            let merged = merge_text_items(vec![first, make_merge_item("Body", 125.2, 24.0)]);
3204            assert_eq!(merged.len(), 2);
3205            assert_eq!(merged[0].text, "KEY");
3206            assert_eq!(merged[1].text, "Body");
3207        }
3208    }
3209
3210    #[test]
3211    fn merge_items_no_space_before_period() {
3212        // Simulate Tc/Tw-adjusted width: "date" width is smaller than the gap
3213        // to "." due to negative Tc, but period should still join without space.
3214        let items = vec![
3215            make_merge_item("date", 227.25, 89.25), // end = 316.50
3216            make_merge_item(".", 318.00, 3.0),      // gap = 1.50 (0.125 × fs)
3217        ];
3218        let merged = merge_text_items(items);
3219        assert_eq!(merged.len(), 1);
3220        assert_eq!(merged[0].text, "date.");
3221    }
3222
3223    #[test]
3224    fn merge_items_lowercase_join_with_tc() {
3225        // Lowercase→lowercase junction: "deve" + "lopers" with Tc-affected gap
3226        // Gap of 0.12 × font_size should merge without space
3227        let items = vec![
3228            make_merge_item("deve", 100.0, 30.0),    // end = 130.0
3229            make_merge_item("lopers", 131.44, 40.0), // gap = 1.44 (0.12 × 12)
3230        ];
3231        let merged = merge_text_items(items);
3232        assert_eq!(merged.len(), 1);
3233        assert_eq!(merged[0].text, "developers");
3234    }
3235
3236    #[test]
3237    fn merge_items_space_at_word_boundary() {
3238        // Word boundary gap (> 0.13 × font_size) should insert space
3239        let items = vec![
3240            make_merge_item("hello", 100.0, 30.0),
3241            make_merge_item("world", 132.0, 30.0), // gap = 2.0 (0.167 × 12)
3242        ];
3243        let merged = merge_text_items(items);
3244        assert_eq!(merged.len(), 1);
3245        assert_eq!(merged[0].text, "hello world");
3246    }
3247
3248    #[test]
3249    fn merge_items_preserves_underline_from_later_fragment() {
3250        // Fragments with differing underline stay separate items — OR-merging
3251        // would stretch the eventual `<u>` span over the plain fragment.
3252        // Line-level text assembly still joins them without a space (tight
3253        // gap), so the rendered word is unchanged: `pre<u>fix</u>`.
3254        let mut items = vec![
3255            make_merge_item("pre", 100.0, 18.0),
3256            make_merge_item("fix", 119.0, 18.0),
3257        ];
3258        items[1].is_underline = true;
3259
3260        let merged = merge_text_items(items);
3261
3262        assert_eq!(merged.len(), 2);
3263        assert_eq!(merged[0].text, "pre");
3264        assert!(!merged[0].is_underline);
3265        assert_eq!(merged[1].text, "fix");
3266        assert!(merged[1].is_underline);
3267    }
3268
3269    #[test]
3270    fn merge_items_preserves_stream_order_for_backtracking_heading() {
3271        // Some tagged PDFs emit first-letter ActualText fragments, then reset
3272        // the text matrix and draw the rest of the word from the line start.
3273        let items = vec![
3274            with_mcid(make_merge_item("F", 79.4, 4.5)),
3275            with_mcid(make_merge_item("r", 83.9, 3.3)),
3276            with_mcid(make_merge_item("om tables to data-", 79.4, 89.7)),
3277            with_mcid(make_merge_item("", 168.9, 33.9)),
3278            with_mcid(make_merge_item("analytics-", 168.9, 75.5)),
3279            with_mcid(make_merge_item("ready content", 210.5, 60.8)),
3280        ];
3281
3282        let merged = merge_text_items(items);
3283
3284        assert_eq!(merged.len(), 1);
3285        assert_eq!(
3286            merged[0].text,
3287            "From tables to data-analytics-ready content"
3288        );
3289    }
3290
3291    #[test]
3292    fn merge_items_preserves_stream_order_for_reset_word_prefix() {
3293        let items = vec![
3294            with_mcid(make_merge_item("N", 68.0, 7.0)),
3295            with_mcid(make_merge_item("e", 75.1, 4.0)),
3296            with_mcid(make_merge_item("w fields created", 68.0, 82.0)),
3297        ];
3298
3299        let merged = merge_text_items(items);
3300
3301        assert_eq!(merged.len(), 1);
3302        assert_eq!(merged[0].text, "New fields created");
3303    }
3304
3305    #[test]
3306    fn merge_items_uses_x_order_for_untagged_backtracking_text() {
3307        let items = vec![
3308            make_merge_item("N", 68.0, 7.0),
3309            make_merge_item("e", 75.1, 4.0),
3310            make_merge_item("w fields created", 68.2, 82.0),
3311        ];
3312
3313        let merged = merge_text_items(items);
3314
3315        let texts: Vec<_> = merged.iter().map(|item| item.text.as_str()).collect();
3316        assert_eq!(texts, vec!["N", "w fields created", "e"]);
3317    }
3318
3319    #[test]
3320    fn merge_items_preserves_bullet_stream_order_with_backtracking() {
3321        let items = vec![
3322            with_mcid(make_merge_item("•", 79.4, 5.0)),
3323            with_mcid(make_merge_item("The MS", 91.0, 32.6)),
3324            with_mcid(make_merge_item("A LoS project", 84.4, 70.0)),
3325        ];
3326
3327        let merged = merge_text_items(items);
3328
3329        assert_eq!(merged.len(), 1);
3330        assert_eq!(merged[0].text, "• The MSA LoS project");
3331    }
3332
3333    #[test]
3334    fn merge_items_keeps_normal_bullet_gap_limit_without_stream_order() {
3335        let items = vec![
3336            make_merge_item("•", 79.4, 5.0),
3337            make_merge_item("Distant item", 91.0, 60.0),
3338        ];
3339
3340        let merged = merge_text_items(items);
3341
3342        let texts: Vec<_> = merged.iter().map(|item| item.text.as_str()).collect();
3343        assert_eq!(texts, vec!["•", "Distant item"]);
3344    }
3345
3346    #[test]
3347    fn suppress_table_underlines_clears_line_detected_table_items() {
3348        let mut items = vec![
3349            make_merge_item("H1", 125.0, 20.0),
3350            make_merge_item("H2", 225.0, 20.0),
3351            make_merge_item("A", 125.0, 20.0),
3352            make_merge_item("B", 225.0, 20.0),
3353        ];
3354        items[0].y = 490.0;
3355        items[1].y = 490.0;
3356        items[2].y = 470.0;
3357        items[3].y = 470.0;
3358        for item in &mut items {
3359            item.is_underline = true;
3360            item.is_strikeout = true;
3361        }
3362        let lines = vec![
3363            make_line(100.0, 500.0, 300.0, 500.0),
3364            make_line(100.0, 480.0, 300.0, 480.0),
3365            make_line(100.0, 460.0, 300.0, 460.0),
3366            make_line(100.0, 460.0, 100.0, 500.0),
3367            make_line(200.0, 460.0, 200.0, 500.0),
3368            make_line(300.0, 460.0, 300.0, 500.0),
3369        ];
3370
3371        suppress_table_underlines(&mut items, &[], &lines, 1);
3372
3373        assert!(items.iter().all(|item| !item.is_underline));
3374        assert!(items.iter().all(|item| !item.is_strikeout));
3375    }
3376
3377    #[test]
3378    fn test_group_into_lines() {
3379        let items = vec![
3380            TextItem {
3381                text: "Hello".into(),
3382                x: 100.0,
3383                y: 700.0,
3384                width: 50.0,
3385                height: 12.0,
3386                font: "F1".into(),
3387                font_tag: String::new(),
3388                legacy_symbol_rewrite: false,
3389                font_size: 12.0,
3390                page: 1,
3391                is_bold: false,
3392                is_italic: false,
3393                font_weight: None,
3394                bold_source: None,
3395                fixed_pitch: None,
3396                fill_color: None,
3397                stroke_color: None,
3398                render_mode: None,
3399                is_underline: false,
3400                is_strikeout: false,
3401                rotation: 0.0,
3402                advance_known: true,
3403                item_type: ItemType::Text,
3404                mcid: None,
3405                baseline_shift: 0.0,
3406            },
3407            TextItem {
3408                text: "World".into(),
3409                x: 160.0,
3410                y: 700.0,
3411                width: 50.0,
3412                height: 12.0,
3413                font: "F1".into(),
3414                font_tag: String::new(),
3415                legacy_symbol_rewrite: false,
3416                font_size: 12.0,
3417                page: 1,
3418                is_bold: false,
3419                is_italic: false,
3420                font_weight: None,
3421                bold_source: None,
3422                fixed_pitch: None,
3423                fill_color: None,
3424                stroke_color: None,
3425                render_mode: None,
3426                is_underline: false,
3427                is_strikeout: false,
3428                rotation: 0.0,
3429                advance_known: true,
3430                item_type: ItemType::Text,
3431                mcid: None,
3432                baseline_shift: 0.0,
3433            },
3434            TextItem {
3435                text: "Next line".into(),
3436                x: 100.0,
3437                y: 680.0,
3438                width: 80.0,
3439                height: 12.0,
3440                font: "F1".into(),
3441                font_tag: String::new(),
3442                legacy_symbol_rewrite: false,
3443                font_size: 12.0,
3444                page: 1,
3445                is_bold: false,
3446                is_italic: false,
3447                font_weight: None,
3448                bold_source: None,
3449                fixed_pitch: None,
3450                fill_color: None,
3451                stroke_color: None,
3452                render_mode: None,
3453                is_underline: false,
3454                is_strikeout: false,
3455                rotation: 0.0,
3456                advance_known: true,
3457                item_type: ItemType::Text,
3458                mcid: None,
3459                baseline_shift: 0.0,
3460            },
3461        ];
3462
3463        let lines = group_into_lines(items);
3464        assert_eq!(lines.len(), 2);
3465        assert_eq!(lines[0].text(), "Hello World");
3466        assert_eq!(lines[1].text(), "Next line");
3467    }
3468
3469    #[test]
3470    fn preserving_all_text_keeps_numeric_page_footer() {
3471        let mut page_number = make_merge_item("42", 100.0, 12.0);
3472        page_number.y = 50.0;
3473
3474        assert!(group_into_lines(vec![page_number.clone()]).is_empty());
3475
3476        let lines = group_into_lines_preserving_all_text(vec![page_number]);
3477        assert_eq!(lines.len(), 1);
3478        assert_eq!(lines[0].text(), "42");
3479    }
3480
3481    #[test]
3482    fn inline_numeric_run_near_page_edge_is_not_removed() {
3483        let mut items = vec![
3484            make_merge_item("Total", 100.0, 30.0),
3485            make_merge_item("730", 136.0, 18.0),
3486            make_merge_item("seats", 160.0, 30.0),
3487        ];
3488        for item in &mut items {
3489            item.y = 780.0;
3490        }
3491
3492        let lines = group_into_lines(items);
3493
3494        assert_eq!(lines.len(), 1);
3495        assert_eq!(lines[0].text(), "Total 730 seats");
3496    }
3497
3498    #[test]
3499    fn numeric_page_footer_separated_from_label_is_removed() {
3500        let mut page_number = make_merge_item("42", 25.0, 12.0);
3501        page_number.y = 50.0;
3502        let mut footer_label = make_merge_item("DOCUMENT FOOTER", 60.0, 100.0);
3503        footer_label.y = 50.0;
3504
3505        let lines = group_into_lines(vec![page_number, footer_label]);
3506
3507        assert_eq!(lines.len(), 1);
3508        assert_eq!(lines[0].text(), "DOCUMENT FOOTER");
3509    }
3510
3511    #[test]
3512    fn decorative_marker_does_not_contextualize_numeric_page_footer() {
3513        let mut marker = make_merge_item("•", 19.0, 6.0);
3514        marker.y = 30.0;
3515        let mut page_number = make_merge_item("42", 37.0, 10.0);
3516        page_number.y = 30.0;
3517        let mut footer_label = make_merge_item("Company report footer", 68.0, 120.0);
3518        footer_label.y = 30.0;
3519
3520        let lines = group_into_lines(vec![marker, page_number, footer_label]);
3521
3522        assert!(lines.iter().all(|line| !line.text().contains("42")));
3523        assert!(lines
3524            .iter()
3525            .any(|line| line.text().contains("Company report footer")));
3526    }
3527
3528    #[test]
3529    fn labeled_page_number_is_removed_in_a_short_document() {
3530        let mut label = make_merge_item("Page", 25.0, 28.0);
3531        label.y = 50.0;
3532        let mut page_number = make_merge_item("42", 57.0, 12.0);
3533        page_number.y = 50.0;
3534
3535        let lines = group_into_lines(vec![label, page_number]);
3536
3537        assert_eq!(lines.len(), 1);
3538        assert_eq!(lines[0].text(), "Page");
3539    }
3540
3541    #[test]
3542    fn labeled_page_number_with_running_header_suffix_is_removed() {
3543        let mut items = vec![
3544            make_merge_item("Page", 25.0, 28.0),
3545            make_merge_item("42", 57.0, 12.0),
3546            make_merge_item("of", 73.0, 12.0),
3547            make_merge_item("100", 89.0, 18.0),
3548            make_merge_item("Report header", 111.0, 78.0),
3549        ];
3550        for item in &mut items {
3551            item.y = 50.0;
3552        }
3553
3554        let lines = group_into_lines(items);
3555
3556        assert_eq!(lines.len(), 1);
3557        assert_eq!(lines[0].text(), "Report header");
3558    }
3559
3560    #[test]
3561    fn page_of_total_expression_is_removed_without_leaving_fragments() {
3562        let mut items = vec![
3563            make_merge_item("Page", 482.0, 27.0),
3564            make_merge_item("1", 513.0, 6.0),
3565            make_merge_item("of", 523.0, 10.0),
3566            make_merge_item("15", 537.0, 12.0),
3567        ];
3568        for item in &mut items {
3569            item.y = 46.0;
3570        }
3571
3572        let lines = group_into_lines(items);
3573
3574        assert!(lines.is_empty());
3575    }
3576
3577    #[test]
3578    fn document_folio_filter_survives_per_page_layout_splitting() {
3579        let mut items = Vec::new();
3580        for (page, value) in [(1, "42"), (2, "43"), (3, "44")] {
3581            let mut label = make_merge_item("Page", 25.0, 28.0);
3582            label.page = page;
3583            label.y = 50.0;
3584            let mut page_number = make_merge_item(value, 57.0, 12.0);
3585            page_number.page = page;
3586            page_number.y = 50.0;
3587            items.extend([label, page_number]);
3588        }
3589
3590        let filtered = filter_markdown_page_numbers(items, 3);
3591        assert!(filtered
3592            .iter()
3593            .all(|item| !matches!(item.text.as_str(), "42" | "43" | "44")));
3594        let mut lines = Vec::new();
3595        for page in 1..=3 {
3596            let page_items = filtered
3597                .iter()
3598                .filter(|item| item.page == page)
3599                .cloned()
3600                .collect();
3601            lines.extend(
3602                group_prefiltered_items_into_lines_with_thresholds_and_charts(
3603                    page_items,
3604                    &HashMap::new(),
3605                    &HashSet::new(),
3606                    &HashMap::new(),
3607                ),
3608            );
3609        }
3610
3611        assert_eq!(lines.len(), 3);
3612        assert!(lines.iter().all(|line| line.text() == "Page"));
3613    }
3614
3615    #[test]
3616    fn numeric_only_page_edge_runs_do_not_contextualize_folios() {
3617        let mut page_number = make_merge_item("42", 25.0, 12.0);
3618        page_number.y = 50.0;
3619        let mut long_number = make_merge_item("12345", 43.0, 30.0);
3620        long_number.y = 50.0;
3621
3622        let lines = group_into_lines(vec![page_number, long_number]);
3623
3624        assert_eq!(lines.len(), 1);
3625        assert_eq!(lines[0].text(), "12345");
3626    }
3627
3628    #[test]
3629    fn structured_and_dense_numeric_page_edge_runs_are_preserved() {
3630        let mut list_marker = make_merge_item("11)", 25.0, 18.0);
3631        list_marker.y = 50.0;
3632        let mut chapter = make_merge_item("13", 47.0, 12.0);
3633        chapter.y = 50.0;
3634
3635        let mut isbn_prefix = make_merge_item("9", 25.0, 6.0);
3636        isbn_prefix.page = 2;
3637        isbn_prefix.y = 50.0;
3638        let mut isbn_mid = make_merge_item("780113", 35.0, 36.0);
3639        isbn_mid.page = 2;
3640        isbn_mid.y = 50.0;
3641        let mut isbn_end = make_merge_item("227426", 75.0, 36.0);
3642        isbn_end.page = 2;
3643        isbn_end.y = 50.0;
3644
3645        let lines = group_into_lines(vec![list_marker, chapter, isbn_prefix, isbn_mid, isbn_end]);
3646
3647        assert_eq!(lines.len(), 2);
3648        assert_eq!(lines[0].text(), "11) 13");
3649        assert_eq!(lines[1].text(), "9 780113 227426");
3650    }
3651
3652    #[test]
3653    fn incrementing_numeric_body_column_is_not_treated_as_a_folio() {
3654        let mut items = Vec::new();
3655        for (page, value) in [(1, "13"), (2, "14"), (3, "15")] {
3656            let mut row_number = make_merge_item(value, 72.0, 12.0);
3657            row_number.page = page;
3658            row_number.y = 730.0;
3659            let mut name = make_merge_item("Person", 90.0, 42.0);
3660            name.page = page;
3661            name.y = 730.0;
3662            items.extend([row_number, name]);
3663        }
3664
3665        let lines = group_into_lines(items);
3666
3667        assert_eq!(lines.len(), 3);
3668        assert_eq!(lines[0].text(), "13 Person");
3669        assert_eq!(lines[1].text(), "14 Person");
3670        assert_eq!(lines[2].text(), "15 Person");
3671    }
3672
3673    #[test]
3674    fn advancing_number_in_repeated_deep_margin_footer_is_removed() {
3675        let mut items = Vec::new();
3676        for (page, value) in [(1, "2"), (2, "4"), (3, "6"), (4, "8")] {
3677            let mut page_number = make_merge_item(value, 25.0, 12.0);
3678            page_number.page = page;
3679            page_number.y = 30.0;
3680            let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3681            footer.page = page;
3682            footer.y = 30.0;
3683            items.extend([page_number, footer]);
3684        }
3685
3686        let lines = group_into_lines(items);
3687
3688        assert_eq!(lines.len(), 4);
3689        assert!(lines
3690            .iter()
3691            .all(|line| line.text() == "Company report footer"));
3692    }
3693
3694    #[test]
3695    fn repeated_substantive_page_number_prose_is_preserved() {
3696        let mut items = Vec::new();
3697        for (page, value) in [(1, "42"), (2, "43"), (3, "44"), (4, "45")] {
3698            let mut page_label = make_merge_item("Page", 25.0, 28.0);
3699            page_label.page = page;
3700            page_label.y = 30.0;
3701            let mut number = make_merge_item(value, 57.0, 12.0);
3702            number.page = page;
3703            number.y = 30.0;
3704            let mut explanation = make_merge_item("explains the result", 73.0, 108.0);
3705            explanation.page = page;
3706            explanation.y = 30.0;
3707            items.extend([page_label, number, explanation]);
3708        }
3709
3710        let lines = group_into_lines(items);
3711
3712        assert_eq!(lines.len(), 4);
3713        for (line, value) in lines.iter().zip(["42", "43", "44", "45"]) {
3714            assert_eq!(line.text(), format!("Page {value} explains the result"));
3715        }
3716    }
3717
3718    #[test]
3719    fn numeric_candidates_do_not_bridge_lexical_context() {
3720        let mut report = make_merge_item("Report", 25.0, 40.0);
3721        report.y = 30.0;
3722        let mut year = make_merge_item("2026", 69.0, 24.0);
3723        year.y = 30.0;
3724        let mut folio = make_merge_item("42", 97.0, 12.0);
3725        folio.y = 30.0;
3726
3727        let lines = group_into_lines(vec![report, year, folio]);
3728
3729        assert_eq!(lines.len(), 1);
3730        assert_eq!(lines[0].text(), "Report 2026");
3731    }
3732
3733    #[test]
3734    fn centered_folio_delimiters_are_removed_with_the_number() {
3735        let mut left = make_merge_item("-", 270.0, 6.0);
3736        left.y = 30.0;
3737        let mut number = make_merge_item("42", 280.0, 12.0);
3738        number.y = 30.0;
3739        let mut right = make_merge_item("-", 296.0, 6.0);
3740        right.y = 30.0;
3741
3742        let lines = group_into_lines(vec![left, number, right]);
3743
3744        assert!(lines.is_empty());
3745    }
3746
3747    #[test]
3748    fn centered_delimiters_inside_substantive_text_are_preserved() {
3749        let mut items = vec![
3750            make_merge_item("Result", 240.0, 36.0),
3751            make_merge_item("-", 280.0, 6.0),
3752            make_merge_item("42", 290.0, 12.0),
3753            make_merge_item("-", 306.0, 6.0),
3754            make_merge_item("approved", 316.0, 48.0),
3755        ];
3756        for item in &mut items {
3757            item.y = 30.0;
3758        }
3759
3760        let lines = group_into_lines(items);
3761
3762        assert_eq!(lines.len(), 1);
3763        assert_eq!(lines[0].text(), "Result-42-approved");
3764    }
3765
3766    #[test]
3767    fn changing_year_in_repeated_deep_margin_header_is_preserved() {
3768        let mut items = Vec::new();
3769        for (page, year) in [(1, "2020"), (2, "2021"), (3, "2022"), (4, "2023")] {
3770            let mut year = make_merge_item(year, 25.0, 24.0);
3771            year.page = page;
3772            year.y = 780.0;
3773            let mut header = make_merge_item("Annual report", 53.0, 78.0);
3774            header.page = page;
3775            header.y = 780.0;
3776            items.extend([year, header]);
3777        }
3778
3779        let lines = group_into_lines(items);
3780
3781        assert_eq!(lines.len(), 4);
3782        assert_eq!(lines[0].text(), "2020 Annual report");
3783        assert_eq!(lines[3].text(), "2023 Annual report");
3784    }
3785
3786    #[test]
3787    fn sparse_repeated_margin_numbers_do_not_meet_the_folio_evidence_floor() {
3788        let mut items = Vec::new();
3789        for page in 1..=4 {
3790            if page <= 2 {
3791                let value = if page == 1 { "2" } else { "4" };
3792                let mut number = make_merge_item(value, 25.0, 12.0);
3793                number.page = page;
3794                number.y = 30.0;
3795                let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3796                footer.page = page;
3797                footer.y = 30.0;
3798                items.extend([number, footer]);
3799            } else {
3800                let mut body = make_merge_item("Body text", 72.0, 54.0);
3801                body.page = page;
3802                body.y = 400.0;
3803                items.push(body);
3804            }
3805        }
3806
3807        let lines = group_into_lines(items);
3808
3809        assert!(lines
3810            .iter()
3811            .any(|line| line.text() == "2 Company report footer"));
3812        assert!(lines
3813            .iter()
3814            .any(|line| line.text() == "4 Company report footer"));
3815    }
3816
3817    #[test]
3818    fn sparse_document_pages_count_toward_repeated_folio_coverage() {
3819        let mut items = Vec::new();
3820        for (page, value) in [(1, "1"), (10, "10"), (19, "19"), (28, "28")] {
3821            let mut number = make_merge_item(value, 25.0, 12.0);
3822            number.page = page;
3823            number.y = 30.0;
3824            let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3825            footer.page = page;
3826            footer.y = 30.0;
3827            items.extend([number, footer]);
3828        }
3829
3830        let lines = group_into_lines(items);
3831
3832        assert!(lines
3833            .iter()
3834            .any(|line| line.text() == "1 Company report footer"));
3835        assert!(lines
3836            .iter()
3837            .any(|line| line.text() == "28 Company report footer"));
3838    }
3839
3840    #[test]
3841    fn trailing_blank_pages_count_toward_repeated_folio_coverage() {
3842        let mut items = Vec::new();
3843        for (page, value) in [(1, "1"), (2, "2"), (3, "3"), (4, "4")] {
3844            let mut number = make_merge_item(value, 25.0, 12.0);
3845            number.page = page;
3846            number.y = 30.0;
3847            let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3848            footer.page = page;
3849            footer.y = 30.0;
3850            items.extend([number, footer]);
3851        }
3852
3853        let filtered = filter_markdown_page_numbers(items, 20);
3854
3855        assert!(filtered.iter().any(|item| item.text == "1"));
3856        assert!(filtered.iter().any(|item| item.text == "4"));
3857    }
3858
3859    #[test]
3860    fn prefiltered_contextual_number_survives_layout_partitioning() {
3861        let mut items = vec![
3862            make_merge_item("Total", 100.0, 30.0),
3863            make_merge_item("730", 136.0, 18.0),
3864            make_merge_item("seats", 160.0, 30.0),
3865        ];
3866        for item in &mut items {
3867            item.y = 780.0;
3868        }
3869
3870        let filtered = filter_markdown_page_numbers(items, 1);
3871        let partitioned_number: Vec<TextItem> = filtered
3872            .into_iter()
3873            .filter(|item| item.text == "730")
3874            .collect();
3875        let lines = group_prefiltered_items_into_lines_with_thresholds_and_charts(
3876            partitioned_number,
3877            &HashMap::new(),
3878            &HashSet::new(),
3879            &HashMap::new(),
3880        );
3881
3882        assert_eq!(lines.len(), 1);
3883        assert_eq!(lines[0].text(), "730");
3884    }
3885
3886    #[test]
3887    fn numeric_only_partition_does_not_define_columns() {
3888        let mut items = Vec::new();
3889        for row in 0..20 {
3890            let y = 90.0 - row as f32 * 4.0;
3891            let mut left = make_merge_item(&(row + 1).to_string(), 50.0, 20.0);
3892            left.y = y;
3893            let mut right = make_merge_item(&(row + 101).to_string(), 350.0, 20.0);
3894            right.y = y;
3895            items.extend([left, right]);
3896        }
3897        assert_eq!(detect_columns(&items, 1, false).len(), 2);
3898
3899        let lines = group_prefiltered_items_into_lines_with_thresholds_and_charts(
3900            items,
3901            &HashMap::new(),
3902            &HashSet::new(),
3903            &HashMap::new(),
3904        );
3905
3906        assert_eq!(lines.len(), 20);
3907        assert!(lines.iter().all(|line| line.items.len() == 2));
3908    }
3909
3910    #[test]
3911    fn separated_content_is_not_treated_as_a_spread_folio_pair() {
3912        let mut value = make_merge_item("12", 100.0, 12.0);
3913        value.y = 30.0;
3914        let mut label = make_merge_item("Total", 116.0, 30.0);
3915        label.y = 30.0;
3916        let mut unrelated_number = make_merge_item("13", 300.0, 12.0);
3917        unrelated_number.y = 30.0;
3918
3919        let lines = group_into_lines(vec![value, label, unrelated_number]);
3920
3921        assert_eq!(lines.len(), 1);
3922        assert_eq!(lines[0].text(), "12 Total");
3923    }
3924
3925    #[test]
3926    fn repeated_folio_uses_the_full_page_edge_band() {
3927        let mut items = Vec::new();
3928        for (page, value) in [(1, "2"), (2, "4"), (3, "6"), (4, "8")] {
3929            let mut page_number = make_merge_item(value, 25.0, 12.0);
3930            page_number.page = page;
3931            page_number.y = 80.0;
3932            let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3933            footer.page = page;
3934            footer.y = 80.0;
3935            items.extend([page_number, footer]);
3936        }
3937
3938        let lines = group_into_lines(items);
3939
3940        assert_eq!(lines.len(), 4);
3941        assert!(lines
3942            .iter()
3943            .all(|line| line.text() == "Company report footer"));
3944    }
3945
3946    #[test]
3947    fn contextual_folio_on_facing_page_spread_is_removed() {
3948        let mut marker = make_merge_item("•", 19.0, 6.0);
3949        marker.y = 30.0;
3950        let mut left_folio = make_merge_item("326", 35.0, 17.0);
3951        left_folio.y = 30.0;
3952        let mut footer = make_merge_item("Company report footer", 61.0, 120.0);
3953        footer.y = 30.0;
3954        let mut right_folio = make_merge_item("327", 1148.0, 17.0);
3955        right_folio.y = 30.0;
3956
3957        let lines = group_into_lines(vec![marker, left_folio, footer, right_folio]);
3958
3959        assert!(lines
3960            .iter()
3961            .all(|line| !line.text().contains("326") && !line.text().contains("327")));
3962        assert!(lines
3963            .iter()
3964            .any(|line| line.text().contains("Company report footer")));
3965    }
3966
3967    #[test]
3968    fn contextual_folios_alternating_across_pages_are_removed() {
3969        let headers = [
3970            "Letter to shareholders",
3971            "Corporate governance report",
3972            "Business environment overview",
3973            "Consolidated financial statements",
3974        ];
3975        let mut items = Vec::new();
3976        for page in 1..=8 {
3977            let mut body = make_merge_item("Body text", 50.0, 500.0);
3978            body.page = page;
3979            body.y = 400.0;
3980            items.push(body);
3981
3982            let mut folio = make_merge_item(&(page + 22).to_string(), 0.0, 14.0);
3983            folio.page = page;
3984            folio.y = 780.0;
3985            if page % 2 == 0 {
3986                folio.x = 50.0;
3987                items.push(folio);
3988            } else {
3989                let mut header = make_merge_item(headers[(page / 2) as usize], 350.0, 180.0);
3990                header.page = page;
3991                header.y = 780.0;
3992                folio.x = 536.0;
3993                items.extend([header, folio]);
3994            }
3995        }
3996
3997        let filtered = filter_markdown_page_numbers(items, 8);
3998
3999        assert!(filtered.iter().all(|item| {
4000            !matches!(
4001                item.text.as_str(),
4002                "23" | "24" | "25" | "26" | "27" | "28" | "29" | "30"
4003            )
4004        }));
4005        assert!(headers
4006            .iter()
4007            .all(|header| filtered.iter().any(|item| item.text == *header)));
4008    }
4009
4010    #[test]
4011    fn one_isolated_neighbor_does_not_remove_contextual_number() {
4012        let mut body_one = make_merge_item("Body text", 50.0, 500.0);
4013        body_one.y = 400.0;
4014        let mut label = make_merge_item("Report", 450.0, 70.0);
4015        label.y = 780.0;
4016        let mut contextual = make_merge_item("1", 526.0, 7.0);
4017        contextual.y = 780.0;
4018
4019        let mut body_two = body_one.clone();
4020        body_two.page = 2;
4021        let mut isolated = make_merge_item("2", 50.0, 7.0);
4022        isolated.page = 2;
4023        isolated.y = 780.0;
4024
4025        let filtered =
4026            filter_markdown_page_numbers(vec![body_one, label, contextual, body_two, isolated], 2);
4027
4028        assert!(filtered.iter().any(|item| item.text == "Report"));
4029        assert!(filtered.iter().any(|item| item.text == "1"));
4030        assert!(filtered.iter().all(|item| item.text != "2"));
4031    }
4032
4033    #[test]
4034    fn narrow_content_span_does_not_establish_adjacent_page_edges() {
4035        let mut body_one = make_merge_item("Body text", 100.0, 120.0);
4036        body_one.y = 400.0;
4037        let mut label = make_merge_item("Report", 170.0, 60.0);
4038        label.y = 780.0;
4039        let mut contextual = make_merge_item("1", 235.0, 7.0);
4040        contextual.y = 780.0;
4041
4042        let mut body_two = body_one.clone();
4043        body_two.page = 2;
4044        let mut isolated_two = make_merge_item("2", 100.0, 7.0);
4045        isolated_two.page = 2;
4046        isolated_two.y = 780.0;
4047
4048        let mut body_four = body_one.clone();
4049        body_four.page = 4;
4050        let mut isolated_four = make_merge_item("4", 100.0, 7.0);
4051        isolated_four.page = 4;
4052        isolated_four.y = 780.0;
4053
4054        let filtered = filter_markdown_page_numbers(
4055            vec![
4056                body_one,
4057                label,
4058                contextual,
4059                body_two,
4060                isolated_two,
4061                body_four,
4062                isolated_four,
4063            ],
4064            4,
4065        );
4066
4067        assert!(filtered.iter().any(|item| item.text == "Report"));
4068        assert!(filtered.iter().any(|item| item.text == "1"));
4069    }
4070
4071    #[test]
4072    fn same_edge_number_on_an_adjacent_page_is_not_folio_evidence() {
4073        let mut body_one = make_merge_item("Body text", 50.0, 500.0);
4074        body_one.y = 400.0;
4075        let mut isolated = make_merge_item("42", 50.0, 14.0);
4076        isolated.y = 780.0;
4077
4078        let mut body_two = body_one.clone();
4079        body_two.page = 2;
4080        let mut contextual = make_merge_item("43", 50.0, 14.0);
4081        contextual.page = 2;
4082        contextual.y = 780.0;
4083        let mut label = make_merge_item("cases reviewed", 70.0, 90.0);
4084        label.page = 2;
4085        label.y = 780.0;
4086
4087        let filtered =
4088            filter_markdown_page_numbers(vec![body_one, isolated, body_two, contextual, label], 2);
4089
4090        assert!(filtered.iter().any(|item| item.text == "43"));
4091        assert!(filtered.iter().any(|item| item.text == "cases reviewed"));
4092    }
4093
4094    #[test]
4095    fn constant_number_in_repeated_deep_margin_header_is_preserved() {
4096        let mut items = Vec::new();
4097        for page in 1..=4 {
4098            let mut year = make_merge_item("2026", 25.0, 24.0);
4099            year.page = page;
4100            year.y = 780.0;
4101            let mut header = make_merge_item("Annual report", 53.0, 78.0);
4102            header.page = page;
4103            header.y = 780.0;
4104            items.extend([year, header]);
4105        }
4106
4107        let lines = group_into_lines(items);
4108
4109        assert_eq!(lines.len(), 4);
4110        assert!(lines.iter().all(|line| line.text() == "2026 Annual report"));
4111    }
4112
4113    #[test]
4114    fn page_number_prefix_does_not_remove_substantive_text_during_layout() {
4115        let mut items = vec![
4116            make_merge_item("Page", 25.0, 28.0),
4117            make_merge_item("42", 57.0, 12.0),
4118            make_merge_item("explains", 73.0, 44.0),
4119            make_merge_item("the result", 121.0, 55.0),
4120        ];
4121        for item in &mut items {
4122            item.y = 50.0;
4123        }
4124
4125        let lines = group_into_lines(items);
4126
4127        assert_eq!(lines.len(), 1);
4128        assert_eq!(lines[0].text(), "Page 42 explains the result");
4129    }
4130
4131    #[test]
4132    fn repeated_page_number_prefix_with_substantive_text_is_preserved_during_layout() {
4133        let mut items = Vec::new();
4134        for (page, value, chapter) in [(1, "42", "Chapter 1"), (2, "43", "Chapter 2")] {
4135            let mut label = make_merge_item("Page", 25.0, 28.0);
4136            label.page = page;
4137            label.y = 50.0;
4138            let mut page_number = make_merge_item(value, 57.0, 12.0);
4139            page_number.page = page;
4140            page_number.y = 50.0;
4141            let mut suffix = make_merge_item(chapter, 73.0, 58.0);
4142            suffix.page = page;
4143            suffix.y = 50.0;
4144            items.extend([label, page_number, suffix]);
4145        }
4146
4147        let lines = group_into_lines(items);
4148
4149        assert_eq!(lines.len(), 2);
4150        assert_eq!(lines[0].text(), "Page 42 Chapter 1");
4151        assert_eq!(lines[1].text(), "Page 43 Chapter 2");
4152    }
4153
4154    #[test]
4155    fn page_number_phrase_in_the_page_body_is_preserved() {
4156        let items = vec![
4157            make_merge_item("Page", 25.0, 28.0),
4158            make_merge_item("42", 57.0, 12.0),
4159        ];
4160
4161        let lines = group_into_lines(items);
4162
4163        assert_eq!(lines.len(), 1);
4164        assert_eq!(lines[0].text(), "Page 42");
4165    }
4166
4167    #[test]
4168    fn short_numeric_context_near_page_edge_is_preserved() {
4169        let mut chapter = make_merge_item("Chapter", 100.0, 45.0);
4170        chapter.y = 760.0;
4171        let mut chapter_number = make_merge_item("1", 151.0, 6.0);
4172        chapter_number.y = 760.0;
4173
4174        let chapter_lines = group_into_lines(vec![chapter, chapter_number]);
4175        assert_eq!(chapter_lines.len(), 1);
4176        assert_eq!(chapter_lines[0].text(), "Chapter 1");
4177
4178        let mut year = make_merge_item("2026", 100.0, 24.0);
4179        year.y = 760.0;
4180        let mut report = make_merge_item("Report", 130.0, 36.0);
4181        report.y = 760.0;
4182
4183        let report_lines = group_into_lines(vec![year, report]);
4184        assert_eq!(report_lines.len(), 1);
4185        assert_eq!(report_lines[0].text(), "2026 Report");
4186
4187        let mut chapter = make_merge_item("Chapter", 100.0, 45.0);
4188        chapter.y = 760.0;
4189        let mut chapter_number = make_merge_item("1", 151.0, 6.0);
4190        chapter_number.y = 760.0;
4191        let mut edition_year = make_merge_item("2026", 163.0, 24.0);
4192        edition_year.y = 760.0;
4193
4194        let chained_lines = group_into_lines(vec![chapter, chapter_number, edition_year]);
4195        assert_eq!(chained_lines.len(), 1);
4196        assert_eq!(chained_lines[0].text(), "Chapter 1 2026");
4197    }
4198
4199    #[test]
4200    fn test_bold_italic_detection() {
4201        // Test bold detection
4202        assert!(is_bold_font("Arial-Bold"));
4203        assert!(is_bold_font("TimesNewRoman-Bold"));
4204        assert!(is_bold_font("Helvetica-BoldOblique"));
4205        assert!(is_bold_font("ABCDEF+ArialMT-Bold"));
4206        assert!(is_bold_font("NotoSans-Black"));
4207        assert!(is_bold_font("Roboto-SemiBold"));
4208        assert!(!is_bold_font("Arial"));
4209        assert!(!is_bold_font("TimesNewRoman-Italic"));
4210
4211        // Test italic detection
4212        assert!(is_italic_font("Arial-Italic"));
4213        assert!(is_italic_font("TimesNewRoman-Italic"));
4214        assert!(is_italic_font("Helvetica-Oblique"));
4215        assert!(is_italic_font("ABCDEF+ArialMT-Italic"));
4216        assert!(is_italic_font("Helvetica-BoldOblique"));
4217        assert!(!is_italic_font("Arial"));
4218        assert!(!is_italic_font("TimesNewRoman-Bold"));
4219
4220        // Test bold-italic detection
4221        assert!(is_bold_font("Arial-BoldItalic"));
4222        assert!(is_italic_font("Arial-BoldItalic"));
4223        assert!(is_bold_font("Helvetica-BoldOblique"));
4224        assert!(is_italic_font("Helvetica-BoldOblique"));
4225    }
4226
4227    #[test]
4228    fn test_word_level_items_get_spaces() {
4229        // Simulate CID font per-word items touching with gap=0
4230        let items = vec![
4231            TextItem {
4232                text: "the".into(),
4233                x: 100.0,
4234                y: 500.0,
4235                width: 19.5,
4236                height: 12.0,
4237                font: "C2_0".into(),
4238                font_tag: String::new(),
4239                legacy_symbol_rewrite: false,
4240                font_size: 12.0,
4241                page: 1,
4242                is_bold: false,
4243                is_italic: false,
4244                font_weight: None,
4245                bold_source: None,
4246                fixed_pitch: None,
4247                fill_color: None,
4248                stroke_color: None,
4249                render_mode: None,
4250                is_underline: false,
4251                is_strikeout: false,
4252                rotation: 0.0,
4253                advance_known: true,
4254                item_type: ItemType::Text,
4255                mcid: None,
4256                baseline_shift: 0.0,
4257            },
4258            TextItem {
4259                text: "Prague".into(),
4260                x: 119.5,
4261                y: 500.0,
4262                width: 42.0,
4263                height: 12.0,
4264                font: "C2_0".into(),
4265                font_tag: String::new(),
4266                legacy_symbol_rewrite: false,
4267                font_size: 12.0,
4268                page: 1,
4269                is_bold: false,
4270                is_italic: false,
4271                font_weight: None,
4272                bold_source: None,
4273                fixed_pitch: None,
4274                fill_color: None,
4275                stroke_color: None,
4276                render_mode: None,
4277                is_underline: false,
4278                is_strikeout: false,
4279                rotation: 0.0,
4280                advance_known: true,
4281                item_type: ItemType::Text,
4282                mcid: None,
4283                baseline_shift: 0.0,
4284            },
4285            TextItem {
4286                text: "Rules".into(),
4287                x: 161.5,
4288                y: 500.0,
4289                width: 35.0,
4290                height: 12.0,
4291                font: "C2_0".into(),
4292                font_tag: String::new(),
4293                legacy_symbol_rewrite: false,
4294                font_size: 12.0,
4295                page: 1,
4296                is_bold: false,
4297                is_italic: false,
4298                font_weight: None,
4299                bold_source: None,
4300                fixed_pitch: None,
4301                fill_color: None,
4302                stroke_color: None,
4303                render_mode: None,
4304                is_underline: false,
4305                is_strikeout: false,
4306                rotation: 0.0,
4307                advance_known: true,
4308                item_type: ItemType::Text,
4309                mcid: None,
4310                baseline_shift: 0.0,
4311            },
4312        ];
4313
4314        let lines = group_into_lines(items);
4315        assert_eq!(lines.len(), 1);
4316        assert_eq!(lines[0].text(), "the Prague Rules");
4317    }
4318
4319    #[test]
4320    fn test_single_char_items_still_join() {
4321        // Per-glyph positioning: single chars should join into words
4322        let items = vec![
4323            TextItem {
4324                text: "N".into(),
4325                x: 100.0,
4326                y: 500.0,
4327                width: 8.0,
4328                height: 12.0,
4329                font: "F1".into(),
4330                font_tag: String::new(),
4331                legacy_symbol_rewrite: false,
4332                font_size: 12.0,
4333                page: 1,
4334                is_bold: false,
4335                is_italic: false,
4336                font_weight: None,
4337                bold_source: None,
4338                fixed_pitch: None,
4339                fill_color: None,
4340                stroke_color: None,
4341                render_mode: None,
4342                is_underline: false,
4343                is_strikeout: false,
4344                rotation: 0.0,
4345                advance_known: true,
4346                item_type: ItemType::Text,
4347                mcid: None,
4348                baseline_shift: 0.0,
4349            },
4350            TextItem {
4351                text: "A".into(),
4352                x: 108.0,
4353                y: 500.0,
4354                width: 8.0,
4355                height: 12.0,
4356                font: "F1".into(),
4357                font_tag: String::new(),
4358                legacy_symbol_rewrite: false,
4359                font_size: 12.0,
4360                page: 1,
4361                is_bold: false,
4362                is_italic: false,
4363                font_weight: None,
4364                bold_source: None,
4365                fixed_pitch: None,
4366                fill_color: None,
4367                stroke_color: None,
4368                render_mode: None,
4369                is_underline: false,
4370                is_strikeout: false,
4371                rotation: 0.0,
4372                advance_known: true,
4373                item_type: ItemType::Text,
4374                mcid: None,
4375                baseline_shift: 0.0,
4376            },
4377            TextItem {
4378                text: "V".into(),
4379                x: 116.0,
4380                y: 500.0,
4381                width: 8.0,
4382                height: 12.0,
4383                font: "F1".into(),
4384                font_tag: String::new(),
4385                legacy_symbol_rewrite: false,
4386                font_size: 12.0,
4387                page: 1,
4388                is_bold: false,
4389                is_italic: false,
4390                font_weight: None,
4391                bold_source: None,
4392                fixed_pitch: None,
4393                fill_color: None,
4394                stroke_color: None,
4395                render_mode: None,
4396                is_underline: false,
4397                is_strikeout: false,
4398                rotation: 0.0,
4399                advance_known: true,
4400                item_type: ItemType::Text,
4401                mcid: None,
4402                baseline_shift: 0.0,
4403            },
4404        ];
4405
4406        let lines = group_into_lines(items);
4407        assert_eq!(lines.len(), 1);
4408        assert_eq!(lines[0].text(), "NAV");
4409    }
4410
4411    #[test]
4412    fn test_per_glyph_word_boundaries() {
4413        // Per-character PDF rendering (e.g. SEC filings): each glyph is a
4414        // separate TextItem. Intra-word gaps are ≈ 0, word gaps ≈ 2.0 at
4415        // font_size 13.3 (ratio 0.15). Must detect word boundaries correctly.
4416        fn char_item(ch: &str, x: f32, width: f32) -> TextItem {
4417            TextItem {
4418                text: ch.into(),
4419                x,
4420                y: 719.3,
4421                width,
4422                height: 13.3,
4423                font: "F4".into(),
4424                font_tag: String::new(),
4425                legacy_symbol_rewrite: false,
4426                font_size: 13.3,
4427                page: 1,
4428                is_bold: true,
4429                is_italic: false,
4430                font_weight: None,
4431                bold_source: Some(crate::types::BoldSource::FontName),
4432                fixed_pitch: None,
4433                fill_color: None,
4434                stroke_color: None,
4435                render_mode: None,
4436                is_underline: false,
4437                is_strikeout: false,
4438                rotation: 0.0,
4439                advance_known: true,
4440                item_type: ItemType::Text,
4441                mcid: None,
4442                baseline_shift: 0.0,
4443            }
4444        }
4445
4446        // "Item 2" — gap of 2.0 between 'm' and '2' at font_size 13.3
4447        let items = vec![
4448            char_item("I", 24.3, 3.1),
4449            char_item("t", 27.5, 2.7),
4450            char_item("e", 30.1, 3.5),
4451            char_item("m", 33.7, 6.7),
4452            char_item("2", 42.3, 4.0), // gap = 42.3 - 40.4 = 1.9
4453        ];
4454
4455        let lines = group_into_lines(items);
4456        assert_eq!(lines.len(), 1);
4457        assert_eq!(lines[0].text(), "Item 2");
4458    }
4459
4460    #[test]
4461    fn test_per_glyph_words_not_merged() {
4462        // Verify multiple words from per-character rendering get spaces between them
4463        fn char_item(ch: &str, x: f32, width: f32) -> TextItem {
4464            TextItem {
4465                text: ch.into(),
4466                x,
4467                y: 705.5,
4468                width,
4469                height: 13.3,
4470                font: "F5".into(),
4471                font_tag: String::new(),
4472                legacy_symbol_rewrite: false,
4473                font_size: 13.3,
4474                page: 1,
4475                is_bold: false,
4476                is_italic: false,
4477                font_weight: None,
4478                bold_source: None,
4479                fixed_pitch: None,
4480                fill_color: None,
4481                stroke_color: None,
4482                render_mode: None,
4483                is_underline: false,
4484                is_strikeout: false,
4485                rotation: 0.0,
4486                advance_known: true,
4487                item_type: ItemType::Text,
4488                mcid: None,
4489                baseline_shift: 0.0,
4490            }
4491        }
4492
4493        // "of the" — three words, each with ~2px word gaps
4494        let items = vec![
4495            char_item("o", 100.0, 4.0),
4496            char_item("f", 104.0, 2.7),
4497            // word gap: 108.7 → 110.7 (gap = 4.0)
4498            char_item("t", 110.7, 2.7),
4499            char_item("h", 113.4, 4.4),
4500            char_item("e", 117.8, 3.5),
4501        ];
4502
4503        let lines = group_into_lines(items);
4504        assert_eq!(lines.len(), 1);
4505        assert_eq!(lines[0].text(), "of the");
4506    }
4507
4508    #[test]
4509    fn test_cjk_items_join_without_spaces() {
4510        // Japanese text items touching at gap=0 should join without spaces
4511        let items = vec![
4512            TextItem {
4513                text: "である".into(),
4514                x: 100.0,
4515                y: 500.0,
4516                width: 24.0,
4517                height: 12.0,
4518                font: "C2_0".into(),
4519                font_tag: String::new(),
4520                legacy_symbol_rewrite: false,
4521                font_size: 12.0,
4522                page: 1,
4523                is_bold: false,
4524                is_italic: false,
4525                font_weight: None,
4526                bold_source: None,
4527                fixed_pitch: None,
4528                fill_color: None,
4529                stroke_color: None,
4530                render_mode: None,
4531                is_underline: false,
4532                is_strikeout: false,
4533                rotation: 0.0,
4534                advance_known: true,
4535                item_type: ItemType::Text,
4536                mcid: None,
4537                baseline_shift: 0.0,
4538            },
4539            TextItem {
4540                text: "履行義務".into(),
4541                x: 124.0,
4542                y: 500.0,
4543                width: 32.0,
4544                height: 12.0,
4545                font: "C2_0".into(),
4546                font_tag: String::new(),
4547                legacy_symbol_rewrite: false,
4548                font_size: 12.0,
4549                page: 1,
4550                is_bold: false,
4551                is_italic: false,
4552                font_weight: None,
4553                bold_source: None,
4554                fixed_pitch: None,
4555                fill_color: None,
4556                stroke_color: None,
4557                render_mode: None,
4558                is_underline: false,
4559                is_strikeout: false,
4560                rotation: 0.0,
4561                advance_known: true,
4562                item_type: ItemType::Text,
4563                mcid: None,
4564                baseline_shift: 0.0,
4565            },
4566            TextItem {
4567                text: "を識別す".into(),
4568                x: 156.0,
4569                y: 500.0,
4570                width: 32.0,
4571                height: 12.0,
4572                font: "C2_0".into(),
4573                font_tag: String::new(),
4574                legacy_symbol_rewrite: false,
4575                font_size: 12.0,
4576                page: 1,
4577                is_bold: false,
4578                is_italic: false,
4579                font_weight: None,
4580                bold_source: None,
4581                fixed_pitch: None,
4582                fill_color: None,
4583                stroke_color: None,
4584                render_mode: None,
4585                is_underline: false,
4586                is_strikeout: false,
4587                rotation: 0.0,
4588                advance_known: true,
4589                item_type: ItemType::Text,
4590                mcid: None,
4591                baseline_shift: 0.0,
4592            },
4593        ];
4594
4595        let lines = group_into_lines(items);
4596        assert_eq!(lines.len(), 1);
4597        assert_eq!(lines[0].text(), "である履行義務を識別す");
4598    }
4599
4600    fn make_item(text: &str, x: f32, y: f32, width: f32) -> TextItem {
4601        TextItem {
4602            text: text.into(),
4603            x,
4604            y,
4605            width,
4606            height: 12.0,
4607            font: "F1".into(),
4608            font_tag: String::new(),
4609            legacy_symbol_rewrite: false,
4610            font_size: 12.0,
4611            page: 1,
4612            is_bold: false,
4613            is_italic: false,
4614            font_weight: None,
4615            bold_source: None,
4616            fixed_pitch: None,
4617            fill_color: None,
4618            stroke_color: None,
4619            render_mode: None,
4620            is_underline: false,
4621            is_strikeout: false,
4622            rotation: 0.0,
4623            advance_known: true,
4624            item_type: ItemType::Text,
4625            mcid: None,
4626            baseline_shift: 0.0,
4627        }
4628    }
4629
4630    #[test]
4631    fn test_detect_two_columns() {
4632        let mut items = Vec::new();
4633        // Left column at x=72, right column at x=350, gutter ~278-350
4634        for i in 0..30 {
4635            let y = 700.0 - (i as f32) * 14.0;
4636            items.push(make_item("Left text here", 72.0, y, 200.0));
4637            items.push(make_item("Right text here", 350.0, y, 200.0));
4638        }
4639        let cols = detect_columns(&items, 1, false);
4640        assert_eq!(cols.len(), 2, "Expected 2 columns, got {:?}", cols);
4641        assert!(cols[0].x_min < cols[1].x_min);
4642    }
4643
4644    #[test]
4645    fn test_detect_three_columns() {
4646        let mut items = Vec::new();
4647        // Three columns at x=50, x=220, x=390
4648        for i in 0..30 {
4649            let y = 700.0 - (i as f32) * 14.0;
4650            items.push(make_item("Col one", 50.0, y, 140.0));
4651            items.push(make_item("Col two", 220.0, y, 140.0));
4652            items.push(make_item("Col three", 390.0, y, 140.0));
4653        }
4654        let cols = detect_columns(&items, 1, false);
4655        assert_eq!(cols.len(), 3, "Expected 3 columns, got {:?}", cols);
4656    }
4657
4658    #[test]
4659    fn test_width_bleed_tolerance() {
4660        let mut items = Vec::new();
4661        // Two columns with a clear gutter
4662        for i in 0..30 {
4663            let y = 700.0 - (i as f32) * 14.0;
4664            items.push(make_item("Left text", 72.0, y, 200.0));
4665            items.push(make_item("Right text", 350.0, y, 200.0));
4666        }
4667        // Add a few items that bleed across the gutter
4668        for i in 0..3 {
4669            let y = 700.0 - (i as f32) * 14.0;
4670            items.push(make_item("wide", 72.0, y, 320.0));
4671        }
4672        let cols = detect_columns(&items, 1, false);
4673        assert!(
4674            cols.len() >= 2,
4675            "Width bleed should not prevent column detection, got {:?}",
4676            cols
4677        );
4678    }
4679
4680    #[test]
4681    fn test_single_column_no_false_split() {
4682        let mut items = Vec::new();
4683        // Single column: items spanning full width
4684        for i in 0..30 {
4685            let y = 700.0 - (i as f32) * 14.0;
4686            items.push(make_item(
4687                "This is a full-width paragraph of text",
4688                72.0,
4689                y,
4690                468.0,
4691            ));
4692        }
4693        let cols = detect_columns(&items, 1, false);
4694        assert!(
4695            cols.len() <= 1,
4696            "Full-width text should not be split into columns, got {:?}",
4697            cols
4698        );
4699    }
4700
4701    #[test]
4702    fn test_is_rtl_char() {
4703        // Hebrew alef
4704        assert!(is_rtl_char('\u{05D0}'));
4705        // Arabic alif
4706        assert!(is_rtl_char('\u{0627}'));
4707        // Latin 'A' is not RTL
4708        assert!(!is_rtl_char('A'));
4709        // CJK is not RTL
4710        assert!(!is_rtl_char('\u{4E00}'));
4711    }
4712
4713    #[test]
4714    fn test_is_rtl_text() {
4715        // Majority Hebrew with digits → RTL
4716        assert!(is_rtl_text(["\u{05E9}\u{05DC}\u{05D5}\u{05DD} 123"].iter()));
4717        // Majority Latin → not RTL
4718        assert!(!is_rtl_text(["Hello world"].iter()));
4719        // Empty → not RTL
4720        assert!(!is_rtl_text(std::iter::empty::<&str>()));
4721    }
4722
4723    #[test]
4724    fn test_is_rtl_text_weak_chars_do_not_vote() {
4725        // Arabic-Indic digits (U+0660-0669) are bidi class AN, not strong RTL:
4726        // a digits-only line must stay neutral, like ASCII-digit lines.
4727        assert!(!is_rtl_text(["\u{0661}\u{0662}\u{0663}"].iter()));
4728        // Extended Arabic-Indic digits (U+06F0-06F9) likewise
4729        assert!(!is_rtl_text(["\u{06F1}\u{06F2}\u{06F3}"].iter()));
4730        // Arabic decimal/thousands separators (U+066B/U+066C) with digits
4731        assert!(!is_rtl_text(
4732            ["\u{0661}\u{066B}\u{0662}\u{0663}\u{066C}\u{0664}"].iter()
4733        ));
4734        // Arabic letters alongside Arabic-Indic digits → still RTL
4735        assert!(is_rtl_text(
4736            ["\u{0645}\u{0631}\u{062D}\u{0628}\u{0627} \u{0661}\u{0662}"].iter()
4737        ));
4738        // Arabic letters with combining marks (NSM) → still RTL
4739        assert!(is_rtl_text(["\u{0645}\u{064E}\u{0631}\u{064D}"].iter()));
4740        // Combining marks alone are NSM, not strong RTL, even though they are
4741        // Other_Alphabetic: harakat-only and niqqud-only lines stay neutral
4742        assert!(!is_rtl_text(["\u{064E}\u{064F}\u{0650}\u{0651}"].iter()));
4743        assert!(!is_rtl_text(["\u{05B8}\u{05B4}\u{05BC}"].iter()));
4744        // Marks + Arabic-Indic digits (the full weak-only mix) → still neutral
4745        assert!(!is_rtl_text(["\u{0661}\u{064E}\u{0662}"].iter()));
4746    }
4747
4748    #[test]
4749    fn test_rtl_line_sorting() {
4750        let mut items = vec![
4751            TextItem {
4752                text: "\u{05D0}".into(), // alef at x=100
4753                x: 100.0,
4754                y: 700.0,
4755                width: 10.0,
4756                height: 12.0,
4757                font: "F1".into(),
4758                font_tag: String::new(),
4759                legacy_symbol_rewrite: false,
4760                font_size: 12.0,
4761                page: 1,
4762                is_bold: false,
4763                is_italic: false,
4764                font_weight: None,
4765                bold_source: None,
4766                fixed_pitch: None,
4767                fill_color: None,
4768                stroke_color: None,
4769                render_mode: None,
4770                is_underline: false,
4771                is_strikeout: false,
4772                rotation: 0.0,
4773                advance_known: true,
4774                item_type: ItemType::Text,
4775                mcid: None,
4776                baseline_shift: 0.0,
4777            },
4778            TextItem {
4779                text: "\u{05D1}".into(), // bet at x=200 (rightmost)
4780                x: 200.0,
4781                y: 700.0,
4782                width: 10.0,
4783                height: 12.0,
4784                font: "F1".into(),
4785                font_tag: String::new(),
4786                legacy_symbol_rewrite: false,
4787                font_size: 12.0,
4788                page: 1,
4789                is_bold: false,
4790                is_italic: false,
4791                font_weight: None,
4792                bold_source: None,
4793                fixed_pitch: None,
4794                fill_color: None,
4795                stroke_color: None,
4796                render_mode: None,
4797                is_underline: false,
4798                is_strikeout: false,
4799                rotation: 0.0,
4800                advance_known: true,
4801                item_type: ItemType::Text,
4802                mcid: None,
4803                baseline_shift: 0.0,
4804            },
4805        ];
4806        sort_line_items(&mut items, false);
4807        // RTL: rightmost (higher X) comes first
4808        assert_eq!(items[0].x, 200.0);
4809        assert_eq!(items[1].x, 100.0);
4810    }
4811
4812    #[test]
4813    fn test_ltr_unaffected() {
4814        let mut items = vec![
4815            TextItem {
4816                text: "Hello".into(),
4817                x: 100.0,
4818                y: 700.0,
4819                width: 50.0,
4820                height: 12.0,
4821                font: "F1".into(),
4822                font_tag: String::new(),
4823                legacy_symbol_rewrite: false,
4824                font_size: 12.0,
4825                page: 1,
4826                is_bold: false,
4827                is_italic: false,
4828                font_weight: None,
4829                bold_source: None,
4830                fixed_pitch: None,
4831                fill_color: None,
4832                stroke_color: None,
4833                render_mode: None,
4834                is_underline: false,
4835                is_strikeout: false,
4836                rotation: 0.0,
4837                advance_known: true,
4838                item_type: ItemType::Text,
4839                mcid: None,
4840                baseline_shift: 0.0,
4841            },
4842            TextItem {
4843                text: "World".into(),
4844                x: 200.0,
4845                y: 700.0,
4846                width: 50.0,
4847                height: 12.0,
4848                font: "F1".into(),
4849                font_tag: String::new(),
4850                legacy_symbol_rewrite: false,
4851                font_size: 12.0,
4852                page: 1,
4853                is_bold: false,
4854                is_italic: false,
4855                font_weight: None,
4856                bold_source: None,
4857                fixed_pitch: None,
4858                fill_color: None,
4859                stroke_color: None,
4860                render_mode: None,
4861                is_underline: false,
4862                is_strikeout: false,
4863                rotation: 0.0,
4864                advance_known: true,
4865                item_type: ItemType::Text,
4866                mcid: None,
4867                baseline_shift: 0.0,
4868            },
4869        ];
4870        sort_line_items(&mut items, false);
4871        // LTR: leftmost comes first
4872        assert_eq!(items[0].x, 100.0);
4873        assert_eq!(items[1].x, 200.0);
4874    }
4875
4876    #[test]
4877    fn test_hangul_is_cjk() {
4878        // Hangul Jamo
4879        assert!(is_cjk_char('\u{1100}'));
4880        // Hangul Compatibility Jamo
4881        assert!(is_cjk_char('\u{3131}'));
4882        // Hangul Syllable '가'
4883        assert!(is_cjk_char('\u{AC00}'));
4884        // Latin is not CJK
4885        assert!(!is_cjk_char('A'));
4886    }
4887
4888    #[test]
4889    fn test_newspaper_layout_detection() {
4890        // Two dense columns (>15 lines each) with matching Y positions → newspaper
4891        let make_line = |y: f32, x: f32, page: u32| TextLine {
4892            y,
4893            page,
4894            adaptive_threshold: 0.10,
4895            items: vec![TextItem {
4896                text: "text".into(),
4897                x,
4898                y,
4899                width: 100.0,
4900                height: 12.0,
4901                font: "F1".into(),
4902                font_tag: String::new(),
4903                legacy_symbol_rewrite: false,
4904                font_size: 12.0,
4905                page,
4906                is_bold: false,
4907                is_italic: false,
4908                font_weight: None,
4909                bold_source: None,
4910                fixed_pitch: None,
4911                fill_color: None,
4912                stroke_color: None,
4913                render_mode: None,
4914                is_underline: false,
4915                is_strikeout: false,
4916                rotation: 0.0,
4917                advance_known: true,
4918                item_type: ItemType::Text,
4919                mcid: None,
4920                baseline_shift: 0.0,
4921            }],
4922        };
4923
4924        let col1: Vec<TextLine> = (0..20)
4925            .map(|i| make_line(700.0 - i as f32 * 14.0, 50.0, 1))
4926            .collect();
4927        let col2: Vec<TextLine> = (0..20)
4928            .map(|i| make_line(700.0 - i as f32 * 14.0, 350.0, 1))
4929            .collect();
4930
4931        let cols = vec![
4932            ColumnRegion {
4933                x_min: 0.0,
4934                x_max: 300.0,
4935            },
4936            ColumnRegion {
4937                x_min: 300.0,
4938                x_max: 600.0,
4939            },
4940        ];
4941        assert!(is_newspaper_layout(&[col1, col2], &cols));
4942    }
4943
4944    #[test]
4945    fn test_newspaper_layout_misaligned_baselines() {
4946        // Two dense balanced columns with non-aligned Y positions (e.g. government gazettes
4947        // where columns are independently typeset) → should still be newspaper
4948        let make_line = |y: f32, x: f32, page: u32| TextLine {
4949            y,
4950            page,
4951            adaptive_threshold: 0.10,
4952            items: vec![TextItem {
4953                text: "text".into(),
4954                x,
4955                y,
4956                width: 100.0,
4957                height: 12.0,
4958                font: "F1".into(),
4959                font_tag: String::new(),
4960                legacy_symbol_rewrite: false,
4961                font_size: 12.0,
4962                page,
4963                is_bold: false,
4964                is_italic: false,
4965                font_weight: None,
4966                bold_source: None,
4967                fixed_pitch: None,
4968                fill_color: None,
4969                stroke_color: None,
4970                render_mode: None,
4971                is_underline: false,
4972                is_strikeout: false,
4973                rotation: 0.0,
4974                advance_known: true,
4975                item_type: ItemType::Text,
4976                mcid: None,
4977                baseline_shift: 0.0,
4978            }],
4979        };
4980
4981        // Col1 starts at Y=700, col2 starts at Y=685 (15pt offset — no Y-collision)
4982        let col1: Vec<TextLine> = (0..20)
4983            .map(|i| make_line(700.0 - i as f32 * 14.0, 50.0, 1))
4984            .collect();
4985        let col2: Vec<TextLine> = (0..20)
4986            .map(|i| make_line(685.0 - i as f32 * 14.0, 350.0, 1))
4987            .collect();
4988
4989        let cols = vec![
4990            ColumnRegion {
4991                x_min: 0.0,
4992                x_max: 300.0,
4993            },
4994            ColumnRegion {
4995                x_min: 300.0,
4996                x_max: 600.0,
4997            },
4998        ];
4999        assert!(is_newspaper_layout(&[col1, col2], &cols));
5000    }
5001
5002    #[test]
5003    fn test_tabular_layout_detection() {
5004        // Sparse columns (<15 lines) → tabular, not newspaper
5005        let make_line = |y: f32, x: f32, page: u32| TextLine {
5006            y,
5007            page,
5008            adaptive_threshold: 0.10,
5009            items: vec![TextItem {
5010                text: "text".into(),
5011                x,
5012                y,
5013                width: 100.0,
5014                height: 12.0,
5015                font: "F1".into(),
5016                font_tag: String::new(),
5017                legacy_symbol_rewrite: false,
5018                font_size: 12.0,
5019                page,
5020                is_bold: false,
5021                is_italic: false,
5022                font_weight: None,
5023                bold_source: None,
5024                fixed_pitch: None,
5025                fill_color: None,
5026                stroke_color: None,
5027                render_mode: None,
5028                is_underline: false,
5029                is_strikeout: false,
5030                rotation: 0.0,
5031                advance_known: true,
5032                item_type: ItemType::Text,
5033                mcid: None,
5034                baseline_shift: 0.0,
5035            }],
5036        };
5037
5038        let col1: Vec<TextLine> = (0..5)
5039            .map(|i| make_line(700.0 - i as f32 * 14.0, 50.0, 1))
5040            .collect();
5041        let col2: Vec<TextLine> = (0..5)
5042            .map(|i| make_line(700.0 - i as f32 * 14.0, 350.0, 1))
5043            .collect();
5044
5045        let cols = vec![
5046            ColumnRegion {
5047                x_min: 0.0,
5048                x_max: 300.0,
5049            },
5050            ColumnRegion {
5051                x_min: 300.0,
5052                x_max: 600.0,
5053            },
5054        ];
5055        assert!(!is_newspaper_layout(&[col1, col2], &cols));
5056    }
5057
5058    fn make_item_fs(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextItem {
5059        TextItem {
5060            text: text.into(),
5061            x,
5062            y,
5063            width,
5064            height: font_size,
5065            font: "F1".into(),
5066            font_tag: String::new(),
5067            legacy_symbol_rewrite: false,
5068            font_size,
5069            page: 1,
5070            is_bold: false,
5071            is_italic: false,
5072            font_weight: None,
5073            bold_source: None,
5074            fixed_pitch: None,
5075            fill_color: None,
5076            stroke_color: None,
5077            render_mode: None,
5078            is_underline: false,
5079            is_strikeout: false,
5080            rotation: 0.0,
5081            advance_known: true,
5082            item_type: ItemType::Text,
5083            mcid: None,
5084            baseline_shift: 0.0,
5085        }
5086    }
5087
5088    #[test]
5089    fn stacked_fraction_glyphs_are_not_merged_into_one_run() {
5090        // "1" over "3" (a TeX case fraction): same x, baselines 8pt apart,
5091        // both inside the body line's 5pt band. They are two items; the
5092        // script detector then sees a superscript and a subscript.
5093        let items = vec![
5094            make_item_fs("about 3", 91.9, 511.3, 103.2, 10.0),
5095            make_item_fs("1", 196.3, 515.2, 3.7, 7.4),
5096            make_item_fs("3", 196.3, 507.3, 3.7, 7.4),
5097            make_item_fs("bits", 201.5, 511.3, 18.0, 10.0),
5098        ];
5099        let merged = merge_text_items(items);
5100        let texts: Vec<&str> = merged.iter().map(|i| i.text.as_str()).collect();
5101        assert!(texts.contains(&"1") && texts.contains(&"3"), "{texts:?}");
5102        // Side-by-side same-size raised symbol still joins its word.
5103        let items = vec![
5104            make_item_fs("Freon", 100.0, 500.0, 26.0, 10.0),
5105            make_item_fs("®", 126.0, 502.0, 6.0, 10.0),
5106        ];
5107        let merged = merge_text_items(items);
5108        assert_eq!(merged.len(), 1);
5109        assert_eq!(merged[0].text, "Freon®");
5110        // Stacked LETTERS in the same band (a rotated label beside body
5111        // text) keep merging as before — only digit pairs are fractions.
5112        let items = vec![
5113            make_item_fs("x", 91.9, 511.3, 5.0, 10.0),
5114            make_item_fs("a", 96.9, 515.2, 3.7, 7.4),
5115            make_item_fs("r", 96.9, 507.3, 3.7, 7.4),
5116        ];
5117        let merged = merge_text_items(items);
5118        assert!(
5119            merged.iter().any(|i| i.text.contains("ar")),
5120            "{:?}",
5121            merged.iter().map(|i| i.text.as_str()).collect::<Vec<_>>()
5122        );
5123    }
5124
5125    /// Small caps as typesetters emit them: a full-size capital at 9.98pt
5126    /// immediately followed by shrunken capitals at 6.74pt, touching.
5127    /// Modelled on `199AD3d.pdf` p.5 ("ROLANDO T. ACOSTA, P.J.").
5128    #[test]
5129    fn small_caps_run_merges_into_one_word() {
5130        let items = vec![
5131            make_item_fs("R", 144.36, 581.84, 7.20, 9.98),
5132            make_item_fs("OLANDO", 151.56, 581.84, 30.56, 6.74),
5133            make_item_fs("T. A", 185.45, 581.84, 17.58, 9.98),
5134            make_item_fs("COSTA", 203.94, 581.84, 23.15, 6.74),
5135            make_item_fs(", P.J.", 227.09, 581.84, 22.56, 9.98),
5136        ];
5137        let merged = merge_text_items(items);
5138        assert_eq!(merged.len(), 1, "got {:?}", merged);
5139        assert_eq!(merged[0].text, "ROLANDO T. ACOSTA, P.J.");
5140    }
5141
5142    /// The full two-column row: both names must merge independently and the
5143    /// 72pt column gap between them must survive as an item boundary.
5144    #[test]
5145    fn small_caps_merge_does_not_swallow_a_second_column() {
5146        let items = vec![
5147            // Column 1: "ROLANDO T. ACOSTA, P.J." ending at x=249.65
5148            make_item_fs("R", 144.36, 581.84, 7.20, 9.98),
5149            make_item_fs("OLANDO", 151.56, 581.84, 30.56, 6.74),
5150            make_item_fs("T. A", 185.45, 581.84, 17.58, 9.98),
5151            make_item_fs("COSTA", 203.94, 581.84, 23.15, 6.74),
5152            make_item_fs(", P.J.", 227.09, 581.84, 22.56, 9.98),
5153            // Column 2 starts at x=321.96 — a 72pt gap.
5154            make_item_fs("A", 321.96, 581.84, 7.20, 9.98),
5155            make_item_fs("NIL", 329.17, 581.84, 12.72, 6.74),
5156            make_item_fs("C. S", 345.04, 581.84, 19.59, 9.98),
5157            make_item_fs("INGH", 364.62, 581.84, 19.08, 6.74),
5158        ];
5159        let merged = merge_text_items(items);
5160        let texts: Vec<&str> = merged.iter().map(|i| i.text.as_str()).collect();
5161        assert_eq!(
5162            texts,
5163            vec!["ROLANDO T. ACOSTA, P.J.", "ANIL C. SINGH"],
5164            "column gap should keep the two names apart"
5165        );
5166    }
5167
5168    #[test]
5169    fn small_caps_merge_keeps_word_space_between_same_size_capitals() {
5170        // Two uppercase words at sizes the merge band already accepts (9.98 and
5171        // 9.0, a 10% drop) separated by a real word gap. The small-caps path
5172        // must not claim this junction and swallow the space.
5173        let items = vec![
5174            make_item_fs("SEE", 100.0, 500.0, 18.0, 9.98),
5175            make_item_fs("ALSO", 119.2, 500.0, 24.0, 9.0),
5176        ];
5177        let merged = merge_text_items(items);
5178        assert_eq!(merged.len(), 1, "got {:?}", merged);
5179        assert_eq!(merged[0].text, "SEE ALSO");
5180    }
5181
5182    #[test]
5183    fn trailing_digit_is_not_a_capital_awaiting_small_caps() {
5184        // "...MAZZARELLI1" ends in a footnote marker; the backward search for an
5185        // uppercase letter must not skip the digit and glue the next run.
5186        assert!(!is_small_caps_continuation(
5187            "ANGELA M. MAZZARELLI1",
5188            &make_item_fs("ANGELA", 100.0, 500.0, 40.0, 9.98),
5189            &make_item_fs("SHULMAN", 140.0, 500.0, 30.0, 6.74),
5190            0.0,
5191        ));
5192    }
5193
5194    #[test]
5195    fn ordinal_suffix_after_a_digit_still_merges() {
5196        // "TUESDAY, JULY 4" + "TH" is one word in the source; the digit guard
5197        // must not block the four English ordinal suffixes.
5198        for suffix in ["TH", "ST", "ND", "RD"] {
5199            assert!(
5200                is_small_caps_continuation(
5201                    "TUESDAY, JULY 4",
5202                    &make_item_fs("JULY", 100.0, 500.0, 30.0, 12.0),
5203                    &make_item_fs(suffix, 130.0, 500.0, 8.0, 8.0),
5204                    0.0,
5205                ),
5206                "{suffix} should merge after a digit"
5207            );
5208        }
5209    }
5210
5211    #[test]
5212    fn superscript_footnote_marker_is_not_a_small_caps_continuation() {
5213        // A digit must never qualify — otherwise footnote markers get glued on
5214        // without the superscript handling.
5215        assert!(!is_small_caps_continuation(
5216            "MAZZARELLI",
5217            &make_item_fs("MAZZARELLI", 100.0, 500.0, 50.0, 9.98),
5218            &make_item_fs("1", 150.0, 503.0, 3.0, 6.74),
5219            0.0,
5220        ));
5221    }
5222
5223    #[test]
5224    fn drop_cap_is_not_a_small_caps_continuation() {
5225        // Mixed-case body text after a large initial is a drop cap, not small
5226        // caps.
5227        assert!(!is_small_caps_continuation(
5228            "T",
5229            &make_item_fs("T", 100.0, 500.0, 20.0, 30.0),
5230            &make_item_fs("he court held", 120.0, 500.0, 60.0, 10.0),
5231            0.0,
5232        ));
5233    }
5234
5235    #[test]
5236    fn separate_word_is_not_a_small_caps_continuation() {
5237        // A real word space disqualifies even when both runs are uppercase.
5238        let first = make_item_fs("SEE", 100.0, 500.0, 20.0, 9.98);
5239        let next = make_item_fs("ALSO", 128.0, 500.0, 25.0, 6.74);
5240        assert!(!is_small_caps_continuation("SEE", &first, &next, 8.0));
5241    }
5242
5243    #[test]
5244    fn lowercase_continuation_is_not_small_caps() {
5245        assert!(!is_small_caps_continuation(
5246            "SMALL",
5247            &make_item_fs("SMALL", 100.0, 500.0, 30.0, 9.98),
5248            &make_item_fs("caps", 130.0, 500.0, 20.0, 6.74),
5249            0.0,
5250        ));
5251    }
5252
5253    #[test]
5254    fn too_small_a_ratio_is_not_small_caps() {
5255        // 0.4 ratio is a superscript/sub-run, outside the small-caps band.
5256        assert!(!is_small_caps_continuation(
5257            "A",
5258            &make_item_fs("A", 100.0, 500.0, 7.0, 10.0),
5259            &make_item_fs("BC", 107.0, 500.0, 8.0, 4.0),
5260            0.0,
5261        ));
5262    }
5263
5264    #[test]
5265    fn vertical_run_never_merges_with_body_text_sharing_its_baseline() {
5266        // A 12pt margin stamp whose box bottom lands on a body line's
5267        // baseline, 2pt left of the body text: same y-group, tiny gap, same
5268        // font — every merge criterion but orientation says "join". The same
5269        // items set upright DO merge, so orientation is what blocks it.
5270        let mut stamp = make_merge_item("arXiv:2301.00001", 12.0, 12.0);
5271        stamp.height = 120.0;
5272        stamp.rotation = 90.0;
5273        let body = make_merge_item("Body text", 26.0, 54.0);
5274
5275        let merged = merge_text_items(vec![stamp.clone(), body.clone()]);
5276        assert_eq!(merged.len(), 2, "{merged:?}");
5277        assert_eq!(merged[0].text, "arXiv:2301.00001");
5278        assert_eq!(merged[1].text, "Body text");
5279
5280        stamp.height = 12.0;
5281        stamp.rotation = 0.0;
5282        stamp.width = 96.0;
5283        let body_after_upright_stamp = make_merge_item("Body text", 110.0, 54.0);
5284        let merged = merge_text_items(vec![stamp, body_after_upright_stamp]);
5285        assert_eq!(merged.len(), 1);
5286        assert_eq!(merged[0].text, "arXiv:2301.00001 Body text");
5287    }
5288
5289    #[test]
5290    fn link_annotations_turn_with_a_rotated_page() {
5291        use crate::tounicode::FontCMaps;
5292        use lopdf::{dictionary, Object, Stream};
5293
5294        // Two 90° runs make the page rotated; the link annotation covering
5295        // the first one (page box x 188..200, y 100..140) must land in the
5296        // same turned frame as the text: x = old y, y = -(old right edge).
5297        let mut doc = lopdf::Document::new();
5298        let widths: Vec<Object> = (0..=255).map(|_| 600.into()).collect();
5299        let font_id = doc.add_object(dictionary! {
5300            "Type" => "Font",
5301            "Subtype" => "Type1",
5302            "BaseFont" => "Helvetica",
5303            "FirstChar" => 0,
5304            "LastChar" => 255,
5305            "Widths" => Object::Array(widths),
5306        });
5307        let content_id = doc.add_object(Object::Stream(Stream::new(
5308            dictionary! {},
5309            b"BT /F1 12 Tf 0 1 -1 0 200 100 Tm (HELLO) Tj ET
5310BT /F1 12 Tf 0 1 -1 0 240 100 Tm (WORLD) Tj ET"
5311                .to_vec(),
5312        )));
5313        let link_id = doc.add_object(dictionary! {
5314            "Type" => "Annot",
5315            "Subtype" => "Link",
5316            "Rect" => vec![188.into(), 100.into(), 200.into(), 140.into()],
5317            "A" => dictionary! {
5318                "S" => "URI",
5319                "URI" => Object::string_literal("https://example.com/"),
5320            },
5321        });
5322        // An AcroForm text field drawn over the second run (page box
5323        // x 228..240, y 100..150) must turn the same way.
5324        let widget_id = doc.add_object(dictionary! {
5325            "Type" => "Annot",
5326            "Subtype" => "Widget",
5327            "FT" => "Tx",
5328            "T" => Object::string_literal("field"),
5329            "V" => Object::string_literal("value"),
5330            "Rect" => vec![228.into(), 100.into(), 240.into(), 150.into()],
5331        });
5332        let page_id = doc.add_object(dictionary! {
5333            "Type" => "Page",
5334            "Contents" => Object::Reference(content_id),
5335            "Annots" => vec![Object::Reference(link_id), Object::Reference(widget_id)],
5336            "Resources" => dictionary! {
5337                "Font" => dictionary! { "F1" => Object::Reference(font_id) },
5338            },
5339            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
5340        });
5341        let pages_id = doc.add_object(dictionary! {
5342            "Type" => "Pages",
5343            "Count" => Object::Integer(1),
5344            "Kids" => vec![Object::Reference(page_id)],
5345        });
5346        let catalog_id = doc.add_object(dictionary! {
5347            "Type" => "Catalog",
5348            "Pages" => Object::Reference(pages_id),
5349            "AcroForm" => dictionary! {
5350                "Fields" => vec![Object::Reference(widget_id)],
5351            },
5352        });
5353        doc.trailer.set("Root", Object::Reference(catalog_id));
5354
5355        let font_cmaps = FontCMaps::from_doc(&doc);
5356        let ((items, _, _), _, _, page_rotations, _) =
5357            extract_positioned_text_from_doc(&doc, &font_cmaps, None).unwrap();
5358        assert_eq!(page_rotations.get(&1), Some(&geometry::PageRotation::Ccw));
5359        let field = items
5360            .iter()
5361            .find(|i| matches!(i.item_type, ItemType::FormField))
5362            .expect("form field item");
5363        assert_eq!(
5364            (field.x, field.y, field.width, field.height),
5365            (100.0, -240.0, 50.0, 12.0)
5366        );
5367        let hello = items.iter().find(|i| i.text == "HELLO").unwrap();
5368        assert_eq!(hello.rotation, 0.0);
5369        assert!((hello.x - 100.0).abs() < 0.01 && (hello.y + 200.0).abs() < 0.01);
5370        let link = items
5371            .iter()
5372            .find(|i| matches!(i.item_type, ItemType::Link(_)))
5373            .expect("link item");
5374        assert_eq!(
5375            (link.x, link.y, link.width, link.height),
5376            (100.0, -200.0, 40.0, 12.0)
5377        );
5378        assert_eq!(link.rotation, 0.0);
5379    }
5380
5381    #[test]
5382    fn upside_down_fragments_are_never_concatenated_reversed() {
5383        // A 180° run shown as two operators: "HELLO" is painted first, at
5384        // the right, and "WORLD" continues towards -x. Walking x ascending
5385        // would merge them as "WORLD HELLO"; they must stay separate.
5386        let mut hello = make_merge_item("HELLO", 310.0, 50.0);
5387        hello.rotation = 180.0;
5388        let mut world = make_merge_item("WORLD", 258.0, 50.0);
5389        world.rotation = 180.0;
5390        let merged = merge_text_items(vec![hello, world]);
5391        assert_eq!(merged.len(), 2, "{merged:?}");
5392        assert!(merged.iter().any(|i| i.text == "HELLO"));
5393        assert!(merged.iter().any(|i| i.text == "WORLD"));
5394    }
5395
5396    #[test]
5397    fn measured_and_estimated_runs_never_merge() {
5398        // A width-less font's run (estimated box) next to a measured one on
5399        // the same baseline: the pair must stay two items, whichever comes
5400        // first, so `advance_known` keeps describing each item truthfully.
5401        let measured = make_merge_item("known", 100.0, 30.0);
5402        let mut estimated = make_merge_item("guess", 132.0, 30.0);
5403        estimated.advance_known = false;
5404        assert_eq!(
5405            merge_text_items(vec![measured.clone(), estimated.clone()]).len(),
5406            2
5407        );
5408        assert_eq!(merge_text_items(vec![estimated, measured]).len(), 2);
5409    }
5410
5411    #[test]
5412    fn merged_estimated_runs_keep_the_union_of_their_boxes() {
5413        // Two width-less runs positioned at their estimated advance (half an
5414        // em per glyph at 12pt) walk like measured ones — gap 0, no space —
5415        // and the merged item is the union of the two estimated boxes.
5416        let mut first = make_merge_item("ab", 100.0, 12.0);
5417        first.advance_known = false;
5418        let mut second = make_merge_item("cdefg", 112.0, 30.0);
5419        second.advance_known = false;
5420        let merged = merge_text_items(vec![first, second]);
5421        assert_eq!(merged.len(), 1, "{merged:?}");
5422        assert_eq!(merged[0].text, "abcdefg");
5423        assert!(!merged[0].advance_known);
5424        assert!((merged[0].x - 100.0).abs() < 1e-3, "x = {}", merged[0].x);
5425        assert!(
5426            (merged[0].width - 42.0).abs() < 1e-3,
5427            "width = {}",
5428            merged[0].width
5429        );
5430    }
5431
5432    #[test]
5433    fn estimated_fragments_that_backtrack_keep_the_union_box() {
5434        // Stream order "abc" (x 100), "de" (x 94, a backtrack), "fg" (x 118):
5435        // whichever order the merge walks, the estimated item spans from the
5436        // leftmost fragment to the rightmost box edge.
5437        let mut a = make_merge_item("abc", 100.0, 18.0);
5438        let mut b = make_merge_item("de", 94.0, 10.0);
5439        let mut c = make_merge_item("fg", 118.0, 12.0);
5440        for item in [&mut a, &mut b, &mut c] {
5441            item.advance_known = false;
5442            item.mcid = Some(1);
5443        }
5444        let merged = merge_text_items(vec![a, b, c]);
5445        assert_eq!(merged.len(), 1, "{merged:?}");
5446        assert!(!merged[0].advance_known);
5447        assert!((merged[0].x - 94.0).abs() < 1e-3, "x = {}", merged[0].x);
5448        assert!(
5449            (merged[0].width - 36.0).abs() < 1e-3,
5450            "width = {}",
5451            merged[0].width
5452        );
5453    }
5454
5455    #[test]
5456    fn side_by_side_vertical_runs_stay_separate_lines() {
5457        // Two lines of a vertical stamp are adjacent em columns with the
5458        // same bottom: walking x would glue them into one "line".
5459        let mut first = make_merge_item("line one", 12.0, 12.0);
5460        first.height = 80.0;
5461        first.rotation = 90.0;
5462        let mut second = make_merge_item("line two", 24.0, 12.0);
5463        second.height = 80.0;
5464        second.rotation = 90.0;
5465        let merged = merge_text_items(vec![first, second]);
5466        assert_eq!(merged.len(), 2, "{merged:?}");
5467    }
5468
5469    #[test]
5470    fn detached_accent_before_its_letter_composes_with_the_next_run() {
5471        // An accented letter set as three show operators: the run up to the
5472        // letter, the macron placed by its own text matrix over the "o"
5473        // that begins the next run (raised a fraction of a point, as
5474        // accents are), and the run from that letter on. Sorted along the
5475        // baseline the accent starts right of "ohoku" and would land after
5476        // it; composed, the line reads "Tōhoku".
5477        let mut accent = make_merge_item("\u{00AF}", 108.6, 4.0);
5478        accent.y = 700.2;
5479        let items = vec![
5480            make_merge_item("T", 100.0, 7.3),
5481            accent,
5482            make_merge_item("ohoku", 107.3, 33.0),
5483        ];
5484        let merged = merge_text_items(items);
5485        assert_eq!(merged.len(), 1, "{merged:?}");
5486        assert_eq!(merged[0].text, "T\u{014D}hoku");
5487    }
5488
5489    #[test]
5490    fn detached_accent_after_its_letter_composes_with_the_previous_run() {
5491        // A run positioned glyph by glyph, the accent shown after the letter
5492        // it stands over: "m", "a", the macron over the "a", "jas".
5493        let mut accent = make_merge_item("\u{00AF}", 111.3, 4.0);
5494        accent.y = 700.2;
5495        let items = vec![
5496            make_merge_item("m", 100.0, 10.0),
5497            make_merge_item("a", 110.0, 6.7),
5498            accent,
5499            make_merge_item("jas", 116.7, 15.0),
5500        ];
5501        let merged = merge_text_items(items);
5502        assert_eq!(merged.len(), 1, "{merged:?}");
5503        assert_eq!(merged[0].text, "m\u{0101}jas");
5504    }
5505
5506    #[test]
5507    fn detached_accent_over_the_last_glyph_of_a_longer_run_composes_with_it() {
5508        // "sta", the macron over its closing "a", "tus": the glyph's advance
5509        // is read as a third of the run's width.
5510        let mut accent = make_merge_item("\u{00AF}", 113.0, 4.0);
5511        accent.y = 700.2;
5512        let items = vec![
5513            make_merge_item("sta", 100.0, 18.0),
5514            accent,
5515            make_merge_item("tus", 118.0, 16.0),
5516        ];
5517        let merged = merge_text_items(items);
5518        assert_eq!(merged.len(), 1, "{merged:?}");
5519        assert_eq!(merged[0].text, "st\u{0101}tus");
5520    }
5521
5522    #[test]
5523    fn spacing_accent_over_no_letter_stays_a_character_of_its_own() {
5524        // A grave shown a word gap away from the runs on either side is
5525        // text of its own, a quotation mark or a code delimiter, not an
5526        // accent.
5527        let items = vec![
5528            make_merge_item("code", 100.0, 24.0),
5529            make_merge_item("\u{0060}", 128.0, 4.0),
5530            make_merge_item("more", 136.0, 24.0),
5531        ];
5532        let merged = merge_text_items(items);
5533        assert_eq!(merged.len(), 1, "{merged:?}");
5534        assert_eq!(merged[0].text, "code \u{0060} more");
5535    }
5536
5537    #[test]
5538    fn spacing_accent_shown_beside_a_letter_is_not_composed() {
5539        // "a", a circumflex whose advance follows the "a" exactly, "b": an
5540        // exponent operator set glyph by glyph. The accent stands beside
5541        // both letters and over neither, and keeps its place in "aˆb".
5542        let items = vec![
5543            make_merge_item("a", 100.0, 6.7),
5544            make_merge_item("\u{02C6}", 106.7, 4.0),
5545            make_merge_item("b", 110.7, 6.7),
5546        ];
5547        let merged = merge_text_items(items);
5548        assert_eq!(merged.len(), 1, "{merged:?}");
5549        assert_eq!(merged[0].text, "a\u{02C6}b");
5550    }
5551
5552    #[test]
5553    fn accent_over_two_glyphs_composes_with_the_one_that_can() {
5554        // The macron overlaps the `t` before it more than the `o` after it;
5555        // `t` has no composition with a macron, `o` has.
5556        let mut accent = make_merge_item("\u{00AF}", 104.5, 4.0);
5557        accent.y = 700.2;
5558        let items = vec![
5559            make_merge_item("t", 100.0, 7.3),
5560            accent,
5561            make_merge_item("ohoku", 107.3, 33.0),
5562        ];
5563        let merged = merge_text_items(items);
5564        assert_eq!(merged.len(), 1, "{merged:?}");
5565        assert_eq!(merged[0].text, "t\u{014D}hoku");
5566    }
5567
5568    #[test]
5569    fn accent_whose_pair_has_no_composed_character_is_left_as_shown() {
5570        // A macron over a "t": Unicode has no single character for the
5571        // pair, so nothing is rewritten and the accent fragment survives.
5572        let mut accent = make_merge_item("\u{00AF}", 107.0, 4.0);
5573        accent.y = 700.2;
5574        let items = vec![
5575            make_merge_item("u", 100.0, 6.7),
5576            accent,
5577            make_merge_item("tter", 106.7, 16.0),
5578        ];
5579        let merged = merge_text_items(items);
5580        assert!(merged.iter().any(|item| item.text == "utter"), "{merged:?}");
5581        assert!(
5582            merged.iter().any(|item| item.text == "\u{00AF}"),
5583            "{merged:?}"
5584        );
5585    }
5586
5587    #[test]
5588    fn accent_on_another_baseline_is_not_composed() {
5589        // The same glyphs with the accent a third of an em above the
5590        // letters' baseline: not an accent over them.
5591        let mut accent = make_merge_item("\u{00AF}", 108.6, 4.0);
5592        accent.y = 704.0;
5593        let items = vec![
5594            make_merge_item("T", 100.0, 7.3),
5595            accent,
5596            make_merge_item("ohoku", 107.3, 33.0),
5597        ];
5598        let merged = merge_text_items(items);
5599        assert!(
5600            merged.iter().any(|item| item.text == "Tohoku"),
5601            "{merged:?}"
5602        );
5603        assert!(
5604            merged.iter().any(|item| item.text == "\u{00AF}"),
5605            "{merged:?}"
5606        );
5607    }
5608
5609    #[test]
5610    fn detached_accent_over_a_dotless_i_composes_as_the_dotted_letter() {
5611        // "Garc", the acute over the dotless i that begins "ıa": the
5612        // typesetter's form of the letter under an accent reads "García".
5613        let mut accent = make_merge_item("\u{00B4}", 123.4, 4.0);
5614        accent.y = 700.2;
5615        let items = vec![
5616            make_merge_item("Garc", 100.0, 24.0),
5617            accent,
5618            make_merge_item("\u{0131}a", 124.0, 9.0),
5619        ];
5620        let merged = merge_text_items(items);
5621        assert_eq!(merged.len(), 1, "{merged:?}");
5622        assert_eq!(merged[0].text, "Garc\u{00ED}a");
5623    }
5624
5625    #[test]
5626    fn composing_an_accent_drops_its_clip_entry_with_it() {
5627        let mut accent = make_merge_item("\u{00AF}", 108.6, 4.0);
5628        accent.y = 700.2;
5629        let items = vec![
5630            make_merge_item("T", 100.0, 7.3),
5631            accent,
5632            make_merge_item("ohoku", 107.3, 33.0),
5633        ];
5634        let (items, clips) = compose_detached_spacing_accents(items, &[None, None, None], &[]);
5635        assert_eq!(items.len(), 2, "{items:?}");
5636        assert_eq!(clips.len(), 2);
5637        assert_eq!(items[1].text, "\u{014D}hoku");
5638    }
5639
5640    #[test]
5641    fn detached_accent_over_a_wide_capital_composes_with_it() {
5642        // "Herr", the circumflex centred over the W that begins "Willi": the
5643        // W is nearly an em wide where the run's letters average less than
5644        // half of one, so the endpoint window is held open to 0.6 em.
5645        let mut accent = make_merge_item("\u{02C6}", 130.37, 4.0);
5646        accent.y = 700.2;
5647        let items = vec![
5648            make_merge_item("Herr", 100.0, 23.34),
5649            accent,
5650            make_merge_item("Willi", 126.7, 22.01),
5651        ];
5652        let merged = merge_text_items(items);
5653        assert_eq!(merged.len(), 1, "{merged:?}");
5654        assert_eq!(merged[0].text, "Herr \u{0174}illi");
5655    }
5656
5657    #[test]
5658    fn detached_accent_over_the_last_letter_before_trailing_spaces_composes() {
5659        // "Kovac" with its word gap written into the run as two spaces, the
5660        // caron over the "c", then "and": the spaces are counted at their
5661        // own advance, not at the run's average, so the "c" is found where
5662        // it stands. The run keeps its own spaces.
5663        let mut accent = make_merge_item("\u{02C7}", 128.34, 4.0);
5664        accent.y = 700.2;
5665        let items = vec![
5666            make_merge_item("Kovac  ", 100.0, 40.06),
5667            accent,
5668            make_merge_item("and", 140.06, 20.0),
5669        ];
5670        let merged = merge_text_items(items);
5671        assert_eq!(merged.len(), 1, "{merged:?}");
5672        assert_eq!(merged[0].text, "Kova\u{010D}  and");
5673    }
5674
5675    #[test]
5676    fn accent_beside_a_replacement_text_run_is_not_composed() {
5677        // The run after the accent carries a producer's ActualText
5678        // replacement: its characters need not stand for its glyphs one by
5679        // one, so the accent is left as shown.
5680        let mut accent = make_merge_item("\u{00B4}", 108.6, 4.0);
5681        accent.y = 700.2;
5682        let items = vec![
5683            make_merge_item("T", 100.0, 7.3),
5684            accent,
5685            make_merge_item("eal", 107.3, 20.0),
5686        ];
5687        let merged = merge_text_items_with_clips(items, &[], false, &[false, false, true]);
5688        assert!(
5689            merged.iter().any(|item| item.text.contains('\u{00B4}')),
5690            "{merged:?}"
5691        );
5692        assert!(
5693            !merged.iter().any(|item| item.text.contains('\u{00E9}')),
5694            "{merged:?}"
5695        );
5696    }
5697
5698    #[test]
5699    fn accent_beside_a_run_with_right_to_left_letters_is_not_composed() {
5700        // A run holding Hebrew letters may have been painted under a
5701        // mirrored matrix, so which of its glyphs stands at which end of its
5702        // box is not known from the item: even the Latin "a" at its start
5703        // under the accent is left alone.
5704        let mut accent = make_merge_item("\u{00B4}", 108.6, 4.0);
5705        accent.y = 700.2;
5706        let items = vec![
5707            make_merge_item("T", 100.0, 7.3),
5708            accent,
5709            make_merge_item("a\u{05D0}\u{05D1}", 107.3, 20.0),
5710        ];
5711        let merged = merge_text_items(items);
5712        assert!(
5713            merged.iter().any(|item| item.text.contains('\u{00B4}')),
5714            "{merged:?}"
5715        );
5716        assert!(
5717            !merged.iter().any(|item| item.text.contains('\u{00E1}')),
5718            "{merged:?}"
5719        );
5720    }
5721
5722    #[test]
5723    fn accent_over_an_oblique_run_is_not_composed() {
5724        // A text matrix skewed by a degree and a half (a deskewed OCR layer)
5725        // makes an axis-aligned box that is no baseline: nothing is composed,
5726        // whichever of the two carries the skew.
5727        for skewed in 0..2 {
5728            let mut accent = make_merge_item("\u{00AF}", 108.6, 4.0);
5729            accent.y = 700.2;
5730            let mut run = make_merge_item("ohoku", 107.3, 33.0);
5731            if skewed == 0 {
5732                accent.rotation = 1.5;
5733            } else {
5734                run.rotation = 1.5;
5735            }
5736            let items = vec![make_merge_item("T", 100.0, 7.3), accent, run];
5737            let merged = merge_text_items(items);
5738            assert!(
5739                merged.iter().any(|item| item.text.contains('\u{00AF}')),
5740                "{merged:?}"
5741            );
5742            assert!(
5743                !merged.iter().any(|item| item.text.contains('\u{014D}')),
5744                "{merged:?}"
5745            );
5746        }
5747    }
5748
5749    /// The six glyphs of a word in a script whose subscript letters and
5750    /// vowel signs have zero advance, shown one `Tm` and `Tj` per glyph at
5751    /// 12 pt: each sign sits about 0.23 em behind the pen, over the glyph
5752    /// before it, and the glyph after it starts where the pen was.
5753    fn signed_word_glyphs() -> Vec<TextItem> {
5754        vec![
5755            make_merge_item("\u{1789}\u{17D2}", 20.0, 11.496),
5756            make_merge_item("\u{1789}", 28.82, 0.0),
5757            make_merge_item("\u{179C}", 31.496, 4.128),
5758            make_merge_item("\u{178F}\u{17D2}", 35.624, 9.9),
5759            make_merge_item("\u{1790}", 42.572, 0.0),
5760            make_merge_item("\u{17BB}", 45.524, 3.312),
5761        ]
5762    }
5763
5764    #[test]
5765    fn signs_behind_the_pen_open_no_gap_before_the_next_glyph() {
5766        // The glyph after a sign is measured from where the glyph under the
5767        // sign left the pen, not from the sign's origin 2.7 pt behind it;
5768        // the merged box ends where the last glyph does.
5769        let merged = merge_text_items(signed_word_glyphs());
5770        assert_eq!(merged.len(), 1);
5771        assert_eq!(
5772            merged[0].text,
5773            "\u{1789}\u{17D2}\u{1789}\u{179C}\u{178F}\u{17D2}\u{1790}\u{17BB}"
5774        );
5775        assert!((merged[0].x + merged[0].width - 48.836).abs() < 0.01);
5776    }
5777
5778    #[test]
5779    fn a_word_gap_after_a_sign_is_still_a_space() {
5780        // The second half of the word moved 4 pt (a third of an em) on
5781        // from where the pen was: a word gap after the sign.
5782        let mut items = signed_word_glyphs();
5783        for item in &mut items[3..] {
5784            item.x += 4.0;
5785        }
5786        let merged = merge_text_items(items);
5787        assert_eq!(merged.len(), 1);
5788        assert_eq!(
5789            merged[0].text,
5790            "\u{1789}\u{17D2}\u{1789}\u{179C} \u{178F}\u{17D2}\u{1790}\u{17BB}"
5791        );
5792    }
5793
5794    #[test]
5795    fn a_sign_within_its_glyphs_advance_keeps_its_place_past_a_kerned_glyph() {
5796        // Shown as glyph, sign, glyph, with the second glyph kerned in
5797        // 0.5 pt ahead of the pen and the sign 0.2 pt behind it: by x
5798        // alone the sign would follow the second glyph.
5799        let items = vec![
5800            make_merge_item("\u{1780}", 20.0, 11.496),
5801            make_merge_item("\u{17BB}", 31.3, 0.0),
5802            make_merge_item("\u{1781}", 31.0, 4.128),
5803        ];
5804        let merged = merge_text_items(items);
5805        assert_eq!(merged.len(), 1);
5806        assert_eq!(merged[0].text, "\u{1780}\u{17BB}\u{1781}");
5807
5808        // A sign shown before the glyph it is over sorts by its own x, as
5809        // any fragment does.
5810        let items = vec![
5811            make_merge_item("\u{1780}", 20.0, 11.496),
5812            make_merge_item("\u{17BB}", 40.0, 0.0),
5813            make_merge_item("\u{1781}", 31.496, 4.128),
5814            make_merge_item("\u{1782}", 35.624, 9.9),
5815        ];
5816        let merged = merge_text_items(items);
5817        assert_eq!(merged.len(), 1);
5818        assert_eq!(merged[0].text, "\u{1780}\u{1781}\u{1782}\u{17BB}");
5819    }
5820
5821    #[test]
5822    fn a_two_character_sign_inside_a_tracked_run_keeps_the_run_tracked() {
5823        // Display tracking with a sign that decodes to two combining marks
5824        // over the `O`: the sign is no letter of the run, the run reads its
5825        // tracking across it, and the sign stays in its place without a
5826        // space on either side.
5827        let mut items = glyph_run("HOW", 100.0, 10.0, 2.3);
5828        items.insert(2, make_merge_item("\u{0301}\u{0300}", 118.0, 0.0));
5829        let merged = merge_text_items(items);
5830        assert_eq!(merged.len(), 1);
5831        assert_eq!(merged[0].text, "HO\u{0301}\u{0300}W");
5832    }
5833
5834    /// A pointed right-to-left word shown one glyph per item by a producer
5835    /// walking the line right to left: the first letter, then the second
5836    /// with its vowel sign 0.3 em behind the pen, then the third.
5837    fn pointed_rtl_word() -> Vec<TextItem> {
5838        vec![
5839            make_merge_item("\u{05D1}", 130.0, 6.0),
5840            make_merge_item("\u{05D0}", 124.0, 6.0),
5841            make_merge_item("\u{05B8}", 126.4, 0.0),
5842            make_merge_item("\u{05DC}", 118.0, 6.0),
5843        ]
5844    }
5845
5846    #[test]
5847    fn a_sign_on_a_right_to_left_line_follows_its_letter_in_the_reading() {
5848        // The sign follows the letter it was shown on once the line is
5849        // read into logical order, with no space on either side, whether
5850        // the page stores its runs in visual or in logical order.
5851        for visual in [false, true] {
5852            let merged = merge_text_items_with_clips(pointed_rtl_word(), &[], visual, &[]);
5853            assert_eq!(merged.len(), 1, "visual={visual}: {merged:?}");
5854            assert_eq!(
5855                merged[0].text, "\u{05D1}\u{05D0}\u{05B8}\u{05DC}",
5856                "visual={visual}"
5857            );
5858        }
5859        // The next letter kerned in over the end of the pointed one, the
5860        // sign a hair past it in x: the sign still sorts after its letter.
5861        let mut items = pointed_rtl_word();
5862        items[0].x = 129.7;
5863        items[2].x = 129.8;
5864        let merged = merge_text_items(items);
5865        assert_eq!(merged.len(), 1, "{merged:?}");
5866        assert_eq!(merged[0].text, "\u{05D1}\u{05D0}\u{05B8}\u{05DC}");
5867        // A quarter em from the sign's letter to the next is a word gap,
5868        // measured from the letter, not from the sign behind the pen.
5869        let mut items = pointed_rtl_word();
5870        items[3].x = 115.0;
5871        let merged = merge_text_items(items);
5872        assert_eq!(merged.len(), 1, "{merged:?}");
5873        assert_eq!(merged[0].text, "\u{05D1}\u{05D0}\u{05B8} \u{05DC}");
5874    }
5875
5876    #[test]
5877    fn a_longer_run_without_advance_is_hidden_text_not_a_sign() {
5878        // Many zero-advance glyphs shown at the pen after a display-size
5879        // number, over which a line of body text starts: hidden text, not
5880        // a sign. It sorts by its own x, after the body text, and stays an
5881        // item of its own as before.
5882        let mut number = make_merge_item("24", 20.0, 120.0);
5883        number.font_size = 148.0;
5884        let mut hidden = make_merge_item("++3737++33", 139.0, 0.0);
5885        hidden.font_size = 148.0;
5886        let body = make_merge_item("tank with", 100.0, 60.0);
5887        let merged = merge_text_items(vec![number, hidden, body]);
5888        let texts: Vec<&str> = merged.iter().map(|item| item.text.as_str()).collect();
5889        assert_eq!(texts, ["24", "tank with", "++3737++33"]);
5890    }
5891
5892    #[test]
5893    fn a_sign_deep_behind_the_pen_stays_with_its_glyph() {
5894        // A sign over the middle of a wide glyph, 0.55 em behind the pen:
5895        // the backward-gap break that ends an item does not apply to it,
5896        // and the glyph after it is measured from the pen.
5897        let items = vec![
5898            make_merge_item("\u{1780}", 20.0, 14.4),
5899            make_merge_item("\u{17BB}", 27.8, 0.0),
5900            make_merge_item("\u{1781}", 34.4, 4.128),
5901        ];
5902        let merged = merge_text_items(items);
5903        assert_eq!(merged.len(), 1);
5904        assert_eq!(merged[0].text, "\u{1780}\u{17BB}\u{1781}");
5905    }
5906}