Skip to main content

pdf_engine/
text.rs

1//! Text extraction via a custom Device implementation.
2
3// Copyright (c) 2026 Innovation Trigger B.V.
4//
5// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
6// the PDFluent Commercial Licence. See the LICENSE file in this repository --
7// that file travels with the copy you received, which a URL does not.
8
9use kurbo::{Affine, BezPath, PathEl, Point, Shape};
10use pdf_render::pdf_interpret::cmap::BfString;
11use pdf_render::pdf_interpret::font::Glyph;
12use pdf_render::pdf_interpret::{
13    BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
14};
15use std::borrow::Cow;
16use std::cmp::Ordering;
17
18/// Minimum Y tolerance for grouping spans into horizontal bands. The
19/// effective tolerance is typically `median_font_size * BAND_Y_FRACTION`
20/// per ANN[r17/TEX4]; this constant acts as the absolute floor.
21const BAND_Y_TOLERANCE: f64 = 5.0;
22/// Fraction of the page's median font size used as the band-Y
23/// tolerance. Empirically 0.30× works across common typography —
24/// below typical leading (~1.2×) so adjacent lines never collapse,
25/// above sub-pixel baseline drift.
26const BAND_Y_FRACTION: f64 = 0.30;
27/// Multiplier applied to median line spacing to derive the horizontal
28/// paragraph-break cut threshold. Normal line-to-line progression is
29/// ~1.0× the median; paragraph breaks typically show 1.5× or more.
30const PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER: f64 = 1.8;
31
32// ANN[r17/TEX1] Multi-signal consensus thresholds.
33// The previous single-threshold scheme (gap > 0.15 * font_size) missed
34// word boundaries when kerning or narrow fonts produced small measured
35// gaps even though the PDF emitted an explicit TJ backward shift, and
36// over-emitted spaces for condensed fonts where 0.15em of kerning is
37// well below an actual word space. The consensus system weights three
38// signals and inserts a space when the combined confidence exceeds
39// SPACE_CONSENSUS_THRESHOLD.
40/// Raw TJ backward adjustment (positive in PDF TJ units) that is
41/// definitively a word break. Matches pdftotext / MuPDF heuristics —
42/// a space glyph is typically emitted as either a literal 0x20 or as
43/// a TJ adjustment of around 250 1/1000 em. 100 units is a safely
44/// conservative floor.
45const TJ_SPACE_THRESHOLD_UNITS: f32 = 100.0;
46/// Weight of the TJ offset signal when confidence is high.
47const TJ_SIGNAL_WEIGHT: f64 = 0.95;
48/// Weight of the purely geometric gap signal.
49const GAP_SIGNAL_WEIGHT: f64 = 0.80;
50/// Weight of character-heuristic signals (CamelCase, digit↔letter).
51const HEURISTIC_SIGNAL_WEIGHT: f64 = 0.60;
52/// Combined weight at which a space is inserted.
53const SPACE_CONSENSUS_THRESHOLD: f64 = 0.75;
54/// Fraction of a median character width above which a gap contributes
55/// to the geometric signal (pdf_oxide uses ~0.30).
56const GAP_TO_MEDIAN_CHAR_FRACTION: f64 = 0.30;
57/// Fallback gap fraction relative to `font_size` when the running
58/// median character width has not yet been established.
59const GAP_TO_FONT_SIZE_FALLBACK_FRACTION: f64 = 0.15;
60
61/// Minimum horizontal gap treated as a column gutter (adaptive fallback).
62const COLUMN_GAP_THRESHOLD_MIN: f64 = 10.0;
63/// Maximum adaptive column gap threshold.
64const COLUMN_GAP_THRESHOLD_MAX: f64 = 40.0;
65/// Multiplier applied to median inter-word gap to derive column threshold.
66const COLUMN_GAP_MEDIAN_MULTIPLIER: f64 = 3.0;
67/// Fallback column gap threshold when median cannot be computed.
68const COLUMN_GAP_THRESHOLD_FALLBACK: f64 = 20.0;
69/// Maximum drift allowed when matching gutters across neighboring bands.
70const COLUMN_GAP_MATCH_TOLERANCE: f64 = 12.0;
71/// Minimum number of gapped bands required before we enable column mode.
72const MIN_COLUMN_GAPPED_BANDS: usize = 3;
73/// Minimum fraction of bands in a region that must expose the shared gutters.
74const MIN_COLUMN_GAP_SUPPORT: f64 = 0.80;
75/// Minimum fraction of non-empty column slices that must look like prose.
76const MIN_DENSE_SLICE_RATIO: f64 = 0.35;
77
78/// Two span starts this far apart are the same cell edge (#434). A cell's
79/// wrapped line is set from the cell's own left edge, so the two lines start on
80/// the same coordinate up to the rounding of the text matrix.
81const CELL_EDGE_TOLERANCE: f64 = 2.0;
82/// How far below its row a line may sit and still be that row's continuation,
83/// as a multiple of the row's tallest glyph. One line of leading is ~1.2; the
84/// next row of a table sits further down than this.
85const WRAPPED_CELL_LINE_GAP_FACTOR: f64 = 1.8;
86/// How far above the row the band that corroborates the grid may sit, counted
87/// in bands. The lines between are the wrapped cells of the row above, so a
88/// couple of them is all a row can put in the way.
89const TABLE_GRID_MAX_LOOKBACK: usize = 4;
90/// The share of the grid's own row pitch a continuation may sit below the line
91/// it continues. A row's pitch is one line plus the cell's padding and a
92/// wrapped line is one line, so a line at the full pitch is the next row.
93const WRAPPED_CELL_MAX_PITCH_FRACTION: f64 = 0.9;
94/// A grid is a claim about columns, so the band that corroborates it has to
95/// carry at least this many of them. Two, because a row of the table is
96/// allowed an empty cell.
97const TABLE_GRID_MIN_COLUMNS: usize = 2;
98/// A drawn path this flat is a line and not a box (#545). The ruling lines of
99/// the Isartor manual's appendix are 0.18 pt of ink; a hairline stroke is a
100/// path of no height at all, and a table's cell shading is eighteen points.
101const RULE_MAX_THICKNESS: f64 = 3.0;
102/// A line shorter than this separates nothing: it is a tick, a bullet or the
103/// underline of a word.
104const RULE_MIN_LENGTH: f64 = 24.0;
105/// How many rules are kept. A chart draws thousands of short flat segments and
106/// none of them rules a row; the cap bounds the memory a pathological page can
107/// ask for without changing what any real table looks like.
108const MAX_RECORDED_RULES: usize = 512;
109/// The share of a region's width a rule has to cross before it counts as
110/// separating that region's rows. A rule between two rows runs the whole table;
111/// a rule under one cell does not.
112const RULE_SPAN_FRACTION: f64 = 0.9;
113/// How many of a region's band gaps have to carry a rule before the region is
114/// a ruled table. Two, so that a single horizontal line -- a header rule, the
115/// line above a footnote -- never speaks for the page, while a region the
116/// recursion has already cut down to three rows can still be recognised as the
117/// table it is. Each of the two has to cross the whole region and lie between
118/// two bands that follow each other, which one rule under a headline does not.
119const MIN_RULED_ROW_SEPARATORS: usize = 2;
120/// The share of a region's band gaps that have to carry such a rule. Half,
121/// because a row whose cell wraps puts a band inside itself, and that gap has
122/// no rule in it.
123const MIN_RULED_GAP_SUPPORT: f64 = 0.5;
124
125/// How many cells the row itself has to fill before a line under it is read as
126/// one of its cells running on.
127///
128/// Three, because two is what a page set in columns looks like: its lines
129/// start on the same two edges as a two-cell row does, one of its columns ends
130/// before the other, and the line left over then reads exactly like a wrapped
131/// cell. On `tracemonkey` p13 it is one -- and the page is two columns of
132/// prose, where moving a line into "its cell" is the column cut of #389 class
133/// O-B done on one band instead of the region. A two-column table pays for
134/// that: its wrapped cells are the other half of #545.
135const TABLE_ROW_MIN_CELLS: usize = 3;
136
137/// Whether a text span's width was computed from real font metrics or estimated.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139#[cfg_attr(feature = "serde", derive(serde::Serialize))]
140pub enum WidthSource {
141    /// Width derived from the font's actual glyph advance (hmtx, CFF, Type1 charstring).
142    Metric,
143    /// Width estimated at 50 % of font size — no glyph metric was available.
144    #[default]
145    Estimate,
146}
147
148impl WidthSource {
149    /// Stable label used across SDK bindings and the JSON wire form
150    /// (`"Metric"` / `"Estimate"`). Single source of truth for the string; the
151    /// `serde` derive on the enum produces identical values.
152    pub fn as_str(&self) -> &'static str {
153        match self {
154            WidthSource::Metric => "Metric",
155            WidthSource::Estimate => "Estimate",
156        }
157    }
158}
159
160/// Controls the richness of per-glyph geometry extraction.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
162pub enum GeometryMode {
163    /// Advance-based bounds only (current default).
164    #[default]
165    Basic,
166    /// Full tight glyph bounds from outline paths.
167    RichGeometry,
168}
169
170/// Provenance of per-glyph bounding boxes.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub enum BoundsSource {
173    /// Advance bounds.
174    #[default]
175    Advance,
176    /// Path-derived tight bounds.
177    Tight,
178    /// Fallback (no outline available).
179    Estimate,
180}
181
182/// Vertical font metrics in /1000 em (1000 units-per-em), sourced from the
183/// embedded font's OS/2 / hhea tables via skrifa. `ascent` is positive (above
184/// the baseline); `descent` is negative (below it). The whole struct is `None`
185/// on a span when no embedded font binary exposes metrics (non-embedded
186/// standard-14, CFF, Type1, Type3).
187#[derive(Debug, Clone, Copy, PartialEq, Default)]
188#[cfg_attr(feature = "serde", derive(serde::Serialize))]
189pub struct FontMetrics {
190    /// Ascent above the baseline, in /1000 em.
191    pub ascent: f64,
192    /// Descent below the baseline (negative), in /1000 em.
193    pub descent: f64,
194    /// Cap height in /1000 em, when present in the font.
195    #[cfg_attr(
196        feature = "serde",
197        serde(rename = "capHeight", skip_serializing_if = "Option::is_none")
198    )]
199    pub cap_height: Option<f64>,
200    /// x-height in /1000 em, when present in the font.
201    #[cfg_attr(
202        feature = "serde",
203        serde(rename = "xHeight", skip_serializing_if = "Option::is_none")
204    )]
205    pub x_height: Option<f64>,
206}
207
208/// A single text span at a specific position.
209#[derive(Debug, Clone, Default)]
210pub struct TextSpan {
211    /// The extracted text.
212    pub text: String,
213    /// X position in user space.
214    pub x: f64,
215    /// Y position in user space.
216    pub y: f64,
217    /// Approximate bounding-box width in user space.
218    pub width: f64,
219    /// Approximate bounding-box height in user space.
220    pub height: f64,
221    /// Font size (approximate, from transform).
222    pub font_size: f64,
223
224    // ---- G1 read-only metadata (added 2026-05; backward-compatible) ----
225    /// PostScript name of the font, with any 6-character subset prefix stripped
226    /// (e.g. `Helvetica-Bold`, `TimesNewRomanPS-BoldMT`). `None` for Type1
227    /// standard-14 fonts and Type3 fonts where no embedded font data is
228    /// available through the public `pdf-interpret` API.
229    pub font_name: Option<String>,
230    /// Inferred bold style: `weight >= 700` or PostScript name suggests bold
231    /// ("bold", "demi", "semibold", "heavy", "black"). Defaults `false` when
232    /// no descriptor data is reachable.
233    pub is_bold: bool,
234    /// Inferred italic style: FontDescriptor /Italic flag set or PostScript
235    /// name suggests italic/oblique/slant. Defaults `false` when no descriptor
236    /// data is reachable.
237    pub is_italic: bool,
238    /// Fill color as sRGB RGBA, derived from `Paint::Color(c).to_rgba().to_rgba8()`
239    /// at the moment of glyph paint. `None` for `Paint::Pattern` (tiling /
240    /// shading) — the editor falls back to "auto" in that case.
241    pub color: Option<[u8; 4]>,
242
243    // ---- G2 glyph-level metrics (added 2026-05) ----
244    /// Whether glyph widths were measured from real font advance data or estimated.
245    pub width_source: WidthSource,
246    /// Per-glyph bounding boxes in user-space, one entry per source glyph.
247    /// `[x0, y0, x1, y1]` with y0 < y1 (PDF coordinate frame).
248    pub char_bounds: Vec<[f64; 4]>,
249
250    // ---- M3 rich geometry (added 2026-06; backward-compatible) ----
251    #[doc(hidden)]
252    pub geometry_mode: GeometryMode,
253    #[doc(hidden)]
254    pub bounds_source: BoundsSource,
255    #[doc(hidden)]
256    pub tight_char_bounds: Vec<[f64; 4]>,
257    #[doc(hidden)]
258    pub glyph_advances: Vec<f64>,
259    #[doc(hidden)]
260    pub glyph_bounds_sources: Vec<BoundsSource>,
261
262    // ---- Golf 1 typographic metadata (added 2026-06; backward-compatible) ----
263    /// Full affine transform of the span's first glyph in user space,
264    /// `[a, b, c, d, e, f]` (kurbo coeffs of CTM × text-matrix). Captures
265    /// rotation and shear that `(x, y, font_size)` discards. Text placed by a
266    /// rectangle rather than drawn by a glyph (a form field's value with no
267    /// appearance stream, #549) carries the placement in the same form.
268    /// `None` for spans built from neither (e.g. synthetic markers).
269    pub transform: Option<[f64; 6]>,
270    /// Numeric font weight (~100–900) from the embedded font's OS/2 table when
271    /// available. `None` for non-embedded / standard-14 fonts.
272    pub font_weight: Option<u16>,
273    /// Whether the font is serif, when determinable from embedded font data.
274    /// `None` when no embedded descriptor is reachable.
275    pub is_serif: Option<bool>,
276    /// Whether the font is monospace, when determinable from embedded font data.
277    /// `None` when no embedded descriptor is reachable.
278    pub is_monospace: Option<bool>,
279    /// Coarse PDF text render mode: `0` fill, `1` stroke, `3` invisible — the
280    /// only three values the renderer's `GlyphDrawMode` expresses (the
281    /// fill+stroke / clip modes 2 and 4–7 are not distinguished). Reflects the
282    /// span's first glyph, and `0` for text placed by a rectangle rather than
283    /// drawn (#549). `None` for spans built from neither.
284    pub render_mode: Option<u8>,
285
286    // ---- Golf 2 font metrics (added 2026-06; backward-compatible) ----
287    /// Vertical font metrics (ascent/descent, optional cap/x-height) in /1000
288    /// em from the embedded font binary. `None` for non-embedded fonts.
289    pub font_metrics: Option<FontMetrics>,
290}
291
292impl TextSpan {
293    /// Conservative right edge using whichever is wider: measured or estimated.
294    /// Used by column detection to avoid underestimating span extent.
295    fn right(&self) -> f64 {
296        self.x + self.width.max(self.estimated_width())
297    }
298
299    /// Right edge from measured glyph positions only.
300    fn measured_right(&self) -> f64 {
301        self.x + self.width
302    }
303
304    fn estimated_width(&self) -> f64 {
305        let char_count = self.text.chars().count() as f64;
306        if char_count <= 0.0 {
307            self.font_size * 0.5
308        } else {
309            self.font_size * 0.5 * char_count
310        }
311    }
312}
313
314/// A block of text (grouped by reading order).
315#[derive(Debug, Clone)]
316pub struct TextBlock {
317    /// Spans within this block, sorted by position.
318    pub spans: Vec<TextSpan>,
319}
320
321impl TextBlock {
322    /// Concatenate all spans into a single string, in reading order.
323    ///
324    /// Spans that are close together are joined without a separator;
325    /// a space is inserted when the gap between spans exceeds half
326    /// the average character width.
327    ///
328    /// ANN[r17/TEX5] The spans are in the order the page draws them, left to
329    /// right, and for Arabic or Hebrew that is the reverse of the order they
330    /// are read in (#436, class R-H of #389). The assembled line therefore goes
331    /// through [`crate::bidi::logical_order`] before it is returned: a line
332    /// with no right-to-left character comes back untouched, and one that has
333    /// them comes back with each of its runs the way a reader would type it.
334    /// The spans themselves are left alone -- they carry the geometry, and the
335    /// geometry is visual.
336    pub fn text(&self) -> String {
337        crate::bidi::logical_order(&self.visual_text()).into_owned()
338    }
339
340    /// The line as the page draws it, left to right. The order every geometric
341    /// decision above this point was made in.
342    fn visual_text(&self) -> String {
343        if self.spans.is_empty() {
344            return String::new();
345        }
346        let mut result = self.spans[0].text.clone();
347        // ANN[r17/TEX5] Where the line got to, which is not always the span
348        // before this one: a combining mark is drawn on the glyph before it and
349        // moves the pen nowhere, so its right edge sits an advance short and
350        // the letter after it looks a word apart. A Hebrew word with points
351        // came out `שָׁל וֹם` -- one word, two "words" (#436). A span that is
352        // nothing but marks does not say where the pen is, so the last one that
353        // does answers instead.
354        let mut anchor = &self.spans[0];
355        for pair in self.spans.windows(2) {
356            let prev = &pair[0];
357            let curr = &pair[1];
358            if !crate::bidi::is_only_marks(&prev.text) {
359                anchor = prev;
360            }
361            // The gap is measured along the baseline the anchor was drawn on:
362            // for upright text that is `curr.x - anchor.measured_right()` to
363            // the last bit, for a rotated run it is the distance the reader
364            // sees between the two (#435).
365            let direction = span_direction(anchor);
366            let (curr_along, _) = baseline_coordinates(curr, direction);
367            let gap = curr_along - baseline_end(anchor, direction);
368            if gap <= prev.font_size * 0.12 && second_run_carries_on_from_the_first(prev, curr) {
369                if let Some(trimmed) = trim_overlapping_word_prefix(&prev.text, &curr.text) {
370                    result.push_str(&trimmed);
371                    continue;
372                }
373            }
374            if gap > prev.font_size * 0.25 {
375                result.push(' ');
376            }
377            result.push_str(&curr.text);
378        }
379        result
380    }
381}
382
383#[derive(Debug, Clone)]
384struct TextBand {
385    y: f64,
386    spans: Vec<TextSpan>,
387}
388
389impl TextBand {
390    fn new(span: TextSpan) -> Self {
391        Self {
392            y: span.y,
393            spans: vec![span],
394        }
395    }
396
397    fn sort_spans(&mut self) {
398        self.spans.sort_by(|a, b| {
399            a.x.partial_cmp(&b.x)
400                .unwrap_or(Ordering::Equal)
401                .then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
402        });
403        collapse_overprinted_spans(&mut self.spans);
404    }
405
406    fn row_block(&self) -> TextBlock {
407        let mut spans = self.spans.clone();
408        spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
409        TextBlock { spans }
410    }
411
412    fn left(&self) -> f64 {
413        self.spans
414            .iter()
415            .map(|span| span.x)
416            .fold(f64::INFINITY, f64::min)
417    }
418
419    fn right(&self) -> f64 {
420        self.spans
421            .iter()
422            .map(TextSpan::right)
423            .fold(f64::NEG_INFINITY, f64::max)
424    }
425
426    fn width(&self) -> f64 {
427        (self.right() - self.left()).max(0.0)
428    }
429
430    fn gap_midpoints(&self, column_gap_threshold: f64) -> Vec<f64> {
431        self.gaps(column_gap_threshold)
432            .into_iter()
433            .map(|gap| (gap.start + gap.end) * 0.5)
434            .collect()
435    }
436
437    fn gaps(&self, column_gap_threshold: f64) -> Vec<BandGap> {
438        if self.spans.len() < 2 {
439            return Vec::new();
440        }
441
442        let mut spans = self.spans.clone();
443        spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
444
445        let mut gaps = Vec::new();
446        let mut prev_right = spans[0].right();
447        for span in spans.iter().skip(1) {
448            let gap = span.x - prev_right;
449            if gap >= column_gap_threshold {
450                gaps.push(BandGap {
451                    start: prev_right,
452                    end: span.x,
453                });
454            }
455            prev_right = prev_right.max(span.right());
456        }
457
458        gaps
459    }
460
461    fn split_by_boundaries(&self, boundaries: &[f64]) -> Vec<Vec<TextSpan>> {
462        let mut columns = vec![Vec::new(); boundaries.len() + 1];
463        for span in &self.spans {
464            let center_x = span.x + span.width.max(span.estimated_width()) * 0.5;
465            let column_idx = boundaries
466                .iter()
467                .position(|boundary| center_x < *boundary)
468                .unwrap_or(boundaries.len());
469            columns[column_idx].push(span.clone());
470        }
471
472        for spans in &mut columns {
473            spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
474        }
475
476        columns
477    }
478
479    fn fits_single_column(
480        &self,
481        boundaries: &[f64],
482        region_left: f64,
483        region_right: f64,
484    ) -> Option<usize> {
485        let mut column_idx: Option<usize> = None;
486        for span in &self.spans {
487            let left = span.x;
488            let right = span.right();
489            if boundaries
490                .iter()
491                .any(|boundary| left < *boundary && right > *boundary)
492            {
493                return None;
494            }
495
496            let center_x = left + (right - left) * 0.5;
497            let idx = boundaries
498                .iter()
499                .position(|boundary| center_x < *boundary)
500                .unwrap_or(boundaries.len());
501            match column_idx {
502                Some(existing) if existing != idx => return None,
503                Some(_) => {}
504                None => column_idx = Some(idx),
505            }
506        }
507        let idx = column_idx?;
508        let mut edges = Vec::with_capacity(boundaries.len() + 2);
509        edges.push(region_left);
510        edges.extend_from_slice(boundaries);
511        edges.push(region_right);
512
513        let column_width = (edges[idx + 1] - edges[idx]).max(0.0);
514        if column_width <= 0.0 || self.width() > column_width * 0.8 {
515            return None;
516        }
517
518        Some(idx)
519    }
520}
521
522#[derive(Debug, Clone, Copy)]
523struct BandGap {
524    start: f64,
525    end: f64,
526}
527
528/// The points a path segment is built from, in the order the segment draws
529/// them. Control points included: a cubic lies inside the hull of its four
530/// points, so a bound taken over all of them is never too small.
531fn element_points(element: &PathEl) -> impl Iterator<Item = Point> {
532    let mut points = [Point::ZERO; 3];
533    let count = match *element {
534        PathEl::MoveTo(p) | PathEl::LineTo(p) => {
535            points[0] = p;
536            1
537        }
538        PathEl::QuadTo(a, b) => {
539            points[0] = a;
540            points[1] = b;
541            2
542        }
543        PathEl::CurveTo(a, b, c) => {
544            points[0] = a;
545            points[1] = b;
546            points[2] = c;
547            3
548        }
549        PathEl::ClosePath => 0,
550    };
551    points.into_iter().take(count)
552}
553
554/// A horizontal rule the page draws, in the page space the spans are measured
555/// in (#545).
556///
557/// This is the one thing on the page that says "table" without guessing at it
558/// from the text's own geometry. A width heuristic cannot tell a table from a
559/// paper set in two columns -- both are slices of a band with a gutter between
560/// them -- and the discriminator tried on slice width fixed the Isartor table
561/// by breaking `tracemonkey`. The ruling lines are drawn by the document
562/// itself, and a page of prose does not draw one between every pair of lines.
563#[derive(Clone, Copy, Debug)]
564struct HorizontalRule {
565    left: f64,
566    right: f64,
567    y: f64,
568}
569
570/// A Device implementation that captures text from draw_glyph calls.
571///
572/// ANN[r17/TEX1][r17/TEX3] Space detection uses a multi-signal consensus
573/// rather than a single geometric threshold. Three signals vote:
574///   1. `pending_tj_offset`  — raw TJ backward shift surfaced by the
575///      interpreter (confidence 0.95). This is the definitive word-break
576///      signal used by pdftotext / MuPDF.
577///   2. geometric gap        — measured horizontal distance between the
578///      previous glyph's right edge and this glyph's origin (confidence
579///      0.80). Compared against the running median glyph width rather
580///      than a flat em-fraction so condensed/wide fonts are handled
581///      uniformly.
582///   3. character heuristic  — CamelCase transition or digit↔letter
583///      transition at the merge point (confidence 0.60). Catches cases
584///      where the writer relied on typography (e.g. table cells glued
585///      with zero gap: `Qty1Price$5`).
586///
587/// A space is inserted when the weighted sum meets SPACE_CONSENSUS_THRESHOLD.
588/// Span accumulation still merges adjacent glyphs into one TextSpan (TEX3)
589/// so downstream reading-order logic sees logical text runs, not individual
590/// character positions.
591#[doc(hidden)]
592pub struct TextExtractionDevice {
593    spans: Vec<TextSpan>,
594    last_y: f64,
595    last_end_x: f64,
596    /// TJ adjustment in raw 1/1000 em units since the last glyph was
597    /// drawn. Positive values = backward shift (i.e., explicit horizontal
598    /// space). Reset every time a glyph is drawn.
599    pending_tj_offset: f32,
600    /// Running sample of measured glyph widths used as the adaptive
601    /// reference for the geometric gap signal. Cheap to maintain and
602    /// avoids having to re-walk all spans per decision.
603    glyph_widths: Vec<f64>,
604    /// Cached median glyph width (kept fresh every `MEDIAN_REFRESH`
605    /// insertions). Zero = not yet established, caller falls back to
606    /// font-size scaling.
607    cached_median_char_width: f64,
608    /// Geometry extraction mode — Basic (advance bounds) or RichGeometry (tight bounds).
609    geometry_mode: GeometryMode,
610    /// Per-glyph data recorded during extraction for deferred tight-bounds
611    /// computation. Only populated in RichGeometry mode. The index into this
612    /// vec corresponds 1:1 with the glyph order across all spans.
613    deferred_rich_glyphs: Vec<DeferredGlyph>,
614    /// Hand back the ligature code points a font declared instead of the
615    /// letters they stand for. Off, because a search box compares characters
616    /// and U+FB03 is not `ffi` (#437); see [`crate::ligatures`].
617    keep_ligatures: bool,
618    /// The horizontal rules the page draws, in page space (#545).
619    rules: Vec<HorizontalRule>,
620    /// The clip boxes in force, innermost last, in page space (#545). Each
621    /// entry is already intersected with the one below it, so the last is the
622    /// box a path drawn now can reach.
623    clip_stack: Vec<kurbo::Rect>,
624    /// The marked-content sequences open right now, innermost last, and for
625    /// each the `/ActualText` it carries -- `None` for the great majority
626    /// that carry none (#550). The stack exists because the sequences nest:
627    /// `6-2-11-7-3-t01-pass-e.pdf` opens a `/Span` with an MCID and a second
628    /// `/Span` with the replacement inside it.
629    marked_content: Vec<MarkedContent>,
630    /// How many of the open sequences carry an `/ActualText`. Zero on all but
631    /// a handful of documents, and the per-glyph bookkeeping below is skipped
632    /// while it is.
633    open_actual_texts: usize,
634}
635
636/// One open marked-content sequence.
637#[derive(Clone, Debug)]
638struct MarkedContent {
639    /// The `/ActualText` it carries -- `None` for the great majority that
640    /// carry none.
641    actual: Option<ActualText>,
642    /// Whether the content of this sequence has already been answered for --
643    /// by a glyph drawn while it was open (through
644    /// [`TextExtractionDevice::draw_glyph`]), or by a sequence nested in it
645    /// whose own replacement was read at its `EMC`. A sequence that neither
646    /// has is read at its own `EMC` (#549).
647    ///
648    /// The nested case is the one that is easy to miss: a glyphless
649    /// replacement inside a glyphless replacement resolves at the inner `EMC`,
650    /// and the outer sequence's characters stand for the same marks. Emitting
651    /// both puts two conflicting readings of one thing in the text, and the
652    /// innermost is the more specific statement. (codex, #549)
653    content_spoken_for: bool,
654}
655
656/// An `/ActualText` replacement, and whether it has been spent.
657///
658/// The characters stand for the whole sequence, not for each glyph in it, so
659/// they are emitted once -- at the first glyph, which is where the sequence
660/// starts on the page -- and every later glyph of the sequence contributes
661/// nothing to the text.
662///
663/// A sequence that draws no glyph at all is not a sequence with nothing to
664/// say. `8.10.1-t02-fail-a.pdf` is two text widgets sharing one appearance
665/// stream whose whole content is a filled rectangle inside
666/// `/Form <</ActualText (some_value)>> BDC ... EMC`: the value is in the
667/// replacement and nothing paints it, so a reader that waits for a glyph
668/// comes away with nothing (#549). Such a sequence is emitted at its `EMC`,
669/// at the place the page gives it.
670#[derive(Clone, Debug)]
671struct ActualText {
672    text: String,
673    spent: bool,
674}
675
676/// Lightweight glyph data recorded during extraction for deferred tight-bounds
677/// computation. Avoids computing bounds on the hot path.
678#[derive(Clone)]
679struct DeferredGlyph {
680    /// Transform coefficients [a,b,c,d,e,f] from composed (CTM * glyph_transform)
681    coeffs: [f64; 6],
682    /// Font size in points
683    font_size: f64,
684    /// Advance width in page-space points
685    glyph_width: f64,
686    /// Whether this glyph needs transform-then-bound (rotation/shear)
687    needs_exact: bool,
688    /// The glyph outline path in raw glyph space. Retained ONLY for
689    /// rotated/sheared glyphs (`needs_exact`), where a tight bound requires
690    /// bounding the transformed path; `None` for axis-aligned glyphs (the
691    /// `font_bbox` suffices) and for ink-less glyphs (spaces, Type3).
692    outline: Option<BezPath>,
693    /// Pre-computed bounding box of the outline in raw glyph space. `None` when
694    /// the glyph has no ink (space, Type3, or empty outline) → Estimate.
695    font_bbox: Option<kurbo::Rect>,
696}
697
698const MEDIAN_REFRESH: usize = 32;
699
700impl Default for TextExtractionDevice {
701    fn default() -> Self {
702        Self::new()
703    }
704}
705
706impl TextExtractionDevice {
707    /// Create a new text extraction device with Basic geometry (default).
708    pub fn new() -> Self {
709        Self::with_mode(GeometryMode::Basic)
710    }
711
712    /// Create a new text extraction device with the given geometry mode.
713    pub fn with_mode(geometry_mode: GeometryMode) -> Self {
714        Self {
715            spans: Vec::new(),
716            last_y: f64::NEG_INFINITY,
717            last_end_x: f64::NEG_INFINITY,
718            pending_tj_offset: 0.0,
719            glyph_widths: Vec::new(),
720            cached_median_char_width: 0.0,
721            geometry_mode,
722            deferred_rich_glyphs: Vec::new(),
723            keep_ligatures: false,
724            rules: Vec::new(),
725            clip_stack: Vec::new(),
726            marked_content: Vec::new(),
727            open_actual_texts: 0,
728        }
729    }
730
731    /// The `/ActualText` that stands in for the glyph about to be drawn, if
732    /// an open marked-content sequence carries one that has not been used yet.
733    ///
734    /// The innermost sequence wins: a replacement inside a replacement is the
735    /// more specific statement about those glyphs.
736    fn take_actual_text(&mut self) -> Option<String> {
737        let actual = self
738            .marked_content
739            .iter_mut()
740            .rev()
741            .find_map(|entry| entry.actual.as_mut())?;
742        if actual.spent {
743            return Some(String::new());
744        }
745        actual.spent = true;
746        Some(actual.text.clone())
747    }
748
749    /// Where to put text the page carries without drawing a glyph for it.
750    ///
751    /// The innermost clip box when there is one: a Form XObject clips to its
752    /// `/BBox`, so inside an annotation's appearance stream that box is the
753    /// widget's own rectangle, which is exactly where the value belongs and
754    /// what keeps two widgets sharing one appearance stream from landing on
755    /// top of each other.
756    ///
757    /// Failing that, the pen: the text goes where the last glyph left off,
758    /// which is where the document was drawing when the sequence opened.
759    /// Dropping it instead would be the silent loss this whole change is
760    /// about, and a page that has drawn nothing yet has no position to offer,
761    /// so that one is left alone.
762    fn place_for_glyphless_text(&self) -> Option<kurbo::Rect> {
763        if let Some(clip) = self.clip_stack.last() {
764            return Some(*clip);
765        }
766
767        let last = self.spans.last()?;
768
769        if !self.last_end_x.is_finite() || !self.last_y.is_finite() {
770            return None;
771        }
772
773        let height = last.font_size.max(1.0);
774
775        Some(kurbo::Rect::new(
776            self.last_end_x,
777            self.last_y,
778            self.last_end_x,
779            self.last_y + height,
780        ))
781    }
782
783    /// Record text that belongs to a place on the page rather than to a glyph.
784    ///
785    /// The box is all the geometry there is, so it is all the span claims: the
786    /// width is the box's and the font size its height, which is the only size
787    /// statement a rectangle makes. `char_bounds` stays empty -- there were no
788    /// glyphs to bound -- and the readers of that field already fall back to
789    /// the character count.
790    fn push_placed_span(&mut self, text: &str, rect: kurbo::Rect) {
791        if text.is_empty() {
792            return;
793        }
794
795        let height = if rect.height().is_finite() && rect.height() > 0.0 {
796            rect.height()
797        } else {
798            0.0
799        };
800        let width = if rect.width().is_finite() && rect.width() > 0.0 {
801            rect.width()
802        } else {
803            0.0
804        };
805
806        self.spans.push(TextSpan {
807            text: text.to_string(),
808            x: rect.x0,
809            y: rect.y0,
810            width,
811            height,
812            font_size: height,
813            font_name: None,
814            is_bold: false,
815            is_italic: false,
816            color: None,
817            width_source: WidthSource::Estimate,
818            char_bounds: Vec::new(),
819            // The placement is a real statement about where these characters
820            // are, in the same form a glyph's is -- scale on the diagonal,
821            // position in the last pair -- so a caller that lays the text back
822            // out has what it needs, and the coverage gate over these two
823            // fields stays a gate rather than acquiring an exception.
824            transform: Some([height / 1000.0, 0.0, 0.0, height / 1000.0, rect.x0, rect.y0]),
825            font_weight: None,
826            is_serif: None,
827            is_monospace: None,
828            // Filled: a field value is painted, not stroked and not hidden.
829            render_mode: Some(0),
830            font_metrics: None,
831            geometry_mode: self.geometry_mode,
832            bounds_source: BoundsSource::Estimate,
833            tight_char_bounds: Vec::new(),
834            glyph_advances: Vec::new(),
835            glyph_bounds_sources: Vec::new(),
836        });
837
838        // The pen follows, so that anything drawn after this reads its gap
839        // against where the placed text ended rather than against a glyph
840        // three widgets back.
841        self.last_y = rect.y0;
842        self.last_end_x = rect.x1;
843        self.pending_tj_offset = 0.0;
844    }
845
846    /// Hand back the ligature code points the font declared -- `ffi` rather than
847    /// `ffi` -- from [`Self::into_text`].
848    ///
849    /// The default folds, because the extracted string is read by a search
850    /// index, a RAG chunker or a person pasting a word into a search box, and
851    /// none of them will match a ligature code point against the letters
852    /// somebody types. Set this when the caller wants the file's own
853    /// characters back: a typesetting tool, or a comparison against the
854    /// content stream. Structured output ([`Self::into_blocks`]) is unfolded
855    /// either way.
856    #[must_use]
857    pub fn keep_ligatures(mut self, keep: bool) -> Self {
858        self.keep_ligatures = keep;
859        self
860    }
861
862    /// Refresh the cached median char width. Called lazily from
863    /// `draw_glyph` to keep the hot path cheap.
864    fn refresh_median_char_width(&mut self) {
865        if self.glyph_widths.is_empty() {
866            self.cached_median_char_width = 0.0;
867            return;
868        }
869        let mut sorted = self.glyph_widths.clone();
870        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
871        self.cached_median_char_width = sorted[sorted.len() / 2];
872    }
873
874    /// Move the pen to the end of the glyph just drawn.
875    ///
876    /// ANN[r17/TEX5] A combining mark -- a Hebrew point, an Arabic harakat --
877    /// is drawn on the glyph before it and leaves the pen where it was. Letting
878    /// it move the pen back to its own origin makes the next letter look an
879    /// advance away, and that gap then reads as a word break: `שָׁלוֹם` came
880    /// apart into `שָׁל` and `וֹם` (#436). The declared advance cannot answer
881    /// this, because `glyph_width_and_source` clamps a zero one up to a quarter
882    /// of the font size before anything sees it.
883    fn advance_pen(&mut self, text: &str, glyph_end_x: f64) {
884        if !crate::bidi::is_only_marks(text) {
885            self.last_end_x = glyph_end_x;
886        }
887    }
888
889    /// Decide whether a space should be glued between two glyphs within
890    /// the same span. Returns (insert_space, start_new_span).
891    fn evaluate_space_consensus(
892        &self,
893        gap: f64,
894        font_size: f64,
895        prev_text: &str,
896        next_text: &str,
897    ) -> bool {
898        let mut confidence = 0.0;
899
900        // Signal 1 — TJ offset (highest confidence). Raw units; a full
901        // space is ~250. Anything over TJ_SPACE_THRESHOLD_UNITS counts.
902        if self.pending_tj_offset.abs() >= TJ_SPACE_THRESHOLD_UNITS {
903            confidence += TJ_SIGNAL_WEIGHT;
904        }
905
906        // Signal 2 — geometric gap. Prefer the adaptive median-char-width
907        // reference; fall back to font-size when the median hasn't been
908        // established yet (first few glyphs on a page).
909        let gap_reference = if self.cached_median_char_width > 0.0 {
910            self.cached_median_char_width * GAP_TO_MEDIAN_CHAR_FRACTION
911        } else {
912            font_size * GAP_TO_FONT_SIZE_FALLBACK_FRACTION
913        };
914        if gap > gap_reference {
915            confidence += GAP_SIGNAL_WEIGHT;
916        }
917
918        // Signal 3 — character-class transitions. Only checked when the
919        // previous span ends with a character and the incoming text starts
920        // with one; avoids double-counting with punctuation.
921        if let (Some(prev_last), Some(next_first)) =
922            (prev_text.chars().last(), next_text.chars().next())
923        {
924            let camel = prev_last.is_lowercase() && next_first.is_uppercase();
925            let digit_to_letter = prev_last.is_ascii_digit() && next_first.is_alphabetic();
926            let letter_to_digit = prev_last.is_alphabetic() && next_first.is_ascii_digit();
927            if camel || digit_to_letter || letter_to_digit {
928                confidence += HEURISTIC_SIGNAL_WEIGHT;
929            }
930        }
931
932        confidence >= SPACE_CONSENSUS_THRESHOLD
933    }
934
935    /// Keep a drawn path when it is a horizontal rule (#545).
936    ///
937    /// The whole of the page's graphics arrives here and almost none of it is
938    /// kept: what is, is ink that is a line -- flat enough not to be a box and
939    /// long enough to separate two rows -- recorded in the page space the
940    /// spans already live in, so the two can be compared without a second
941    /// coordinate system to keep in step. The box is the transformed bounding
942    /// box, which for a rotated path is the box around it and therefore too
943    /// tall to be a rule: a rule on a turned page is not read as one, and that
944    /// is the safe way round.
945    ///
946    /// A stroke is measured on its centreline, since that is the path the
947    /// device is given and the weight is a property of the pen. A heavy rule is
948    /// still a rule, and it still lies where its centreline lies.
949    fn record_rule(&mut self, path: &BezPath, transform: Affine) {
950        // The cap is asked at the door AND at every push. One path may hold
951        // hundreds of subpaths, so a check that runs once per call is not a
952        // bound on anything: it lets a single compound path put thousands of
953        // entries in a vector `region_is_ruled` then walks per band.
954        if self.rules.len() >= MAX_RECORDED_RULES {
955            return;
956        }
957        // SUBPATH BY SUBPATH, because a producer is free to draw all of a
958        // table's rules in one path, and a box around the lot of them is a box
959        // and not a line. The bound is taken over the points a segment is made
960        // of, curve controls included -- a curve lies inside its own hull, so
961        // the box is never too small, and the arithmetic is a comparison per
962        // point rather than solving each curve for its extremes. On the slowest
963        // document of the gate set, which draws a figure of several thousand
964        // segments, exact curve bounds cost 7% of its 3.3 s; this costs 1.6%.
965        // WHAT IS DRAWN IS NOT WHAT IS SEEN. A Form XObject's `/BBox` reaches
966        // this device as a clip and the geometry inside it unclipped, so a line
967        // ruled across one cell of a form can arrive as a line across the page.
968        // Two of those are enough for `region_is_ruled` to refuse a column cut
969        // that should have been taken, so the extent recorded is the visible
970        // one.
971        let clip = self.clip_stack.last().copied();
972        let mut lo = (f64::INFINITY, f64::INFINITY);
973        let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
974        let flush = |lo: &mut (f64, f64), hi: &mut (f64, f64), rules: &mut Vec<HorizontalRule>| {
975            let (mut left, mut right) = (lo.0, hi.0);
976            let (mut bottom, mut top) = (lo.1, hi.1);
977            if let Some(clip) = clip {
978                left = left.max(clip.x0);
979                right = right.min(clip.x1);
980                bottom = bottom.max(clip.y0);
981                top = top.min(clip.y1);
982            }
983            if lo.0.is_finite()
984                && rules.len() < MAX_RECORDED_RULES
985                && bottom <= top
986                && hi.1 - lo.1 <= RULE_MAX_THICKNESS
987                && right - left >= RULE_MIN_LENGTH
988            {
989                rules.push(HorizontalRule {
990                    left,
991                    right,
992                    y: (lo.1 + hi.1) * 0.5,
993                });
994            }
995            *lo = (f64::INFINITY, f64::INFINITY);
996            *hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
997        };
998
999        for element in path.elements() {
1000            if matches!(element, PathEl::MoveTo(_) | PathEl::ClosePath) {
1001                flush(&mut lo, &mut hi, &mut self.rules);
1002            }
1003            for point in element_points(element) {
1004                let p = transform * point;
1005                lo = (lo.0.min(p.x), lo.1.min(p.y));
1006                hi = (hi.0.max(p.x), hi.1.max(p.y));
1007            }
1008        }
1009        flush(&mut lo, &mut hi, &mut self.rules);
1010    }
1011
1012    /// Consume the device and return extracted text as a single string.
1013    pub fn into_text(mut self) -> String {
1014        if self.geometry_mode == GeometryMode::RichGeometry {
1015            self.compute_tight_bounds();
1016        }
1017        let keep_ligatures = self.keep_ligatures;
1018        let rules = std::mem::take(&mut self.rules);
1019        let blocks = group_spans_into_blocks(self.spans, &rules);
1020        let lines: Vec<String> = blocks.iter().map(|b| b.text()).collect();
1021        let stitched = stitch_hyphenated_lines(&lines);
1022        let text = normalize_text_output(&stitched);
1023        if keep_ligatures {
1024            return text;
1025        }
1026        // Folding last, on the assembled page: the spans keep the characters
1027        // their glyphs declared, so `into_blocks` is untouched by any of this.
1028        match crate::ligatures::fold(&text) {
1029            Cow::Borrowed(_) => text,
1030            Cow::Owned(folded) => folded,
1031        }
1032    }
1033
1034    /// Consume the device and return text blocks.
1035    pub fn into_blocks(mut self) -> Vec<TextBlock> {
1036        if self.geometry_mode == GeometryMode::RichGeometry {
1037            self.compute_tight_bounds();
1038        }
1039        let rules = std::mem::take(&mut self.rules);
1040        group_spans_into_blocks(self.spans, &rules)
1041    }
1042
1043    /// Consume the device and return raw spans.
1044    #[allow(dead_code)]
1045    pub(crate) fn into_spans(mut self) -> Vec<TextSpan> {
1046        if self.geometry_mode == GeometryMode::RichGeometry {
1047            self.compute_tight_bounds();
1048        }
1049        self.spans
1050    }
1051
1052    /// Record glyph-level data for deferred tight-bounds computation.
1053    /// Called from draw_glyph in RichGeometry mode only.
1054    fn record_deferred_glyph(
1055        &mut self,
1056        glyph: &Glyph<'_>,
1057        composed: &Affine,
1058        font_size: f64,
1059        glyph_width: f64,
1060    ) {
1061        let coeffs = composed.as_coeffs();
1062        let is_rotated = coeffs[1].abs() > ROTATION_EPSILON || coeffs[2].abs() > ROTATION_EPSILON;
1063        let is_sheared = (coeffs[0] - coeffs[3]).abs() > SHEAR_EPSILON;
1064        let needs_exact = is_rotated || is_sheared;
1065
1066        // Capture the glyph's ink bbox in raw glyph space. The full outline path
1067        // is retained only when `needs_exact` (rotated/sheared), so a tight bound
1068        // can bound the transformed path; axis-aligned glyphs keep only the bbox
1069        // (enveloping its transformed corners is exact) to bound memory on large
1070        // pages. Ink-less glyphs (spaces, Type3) carry no bbox → Estimate later.
1071        let (outline, font_bbox) = match glyph {
1072            Glyph::Outline(o) => {
1073                let path = o.outline();
1074                let bb = path.bounding_box();
1075                if bb.width() <= 0.0 && bb.height() <= 0.0 {
1076                    (None, None)
1077                } else if needs_exact {
1078                    (Some(path), Some(bb))
1079                } else {
1080                    (None, Some(bb))
1081                }
1082            }
1083            Glyph::Type3(_) => (None, None),
1084        };
1085
1086        self.deferred_rich_glyphs.push(DeferredGlyph {
1087            coeffs,
1088            font_size,
1089            glyph_width,
1090            needs_exact,
1091            outline,
1092            font_bbox,
1093        });
1094    }
1095
1096    /// Compute tight bounds for all recorded glyphs and populate the spans.
1097    /// Called once after all glyphs have been extracted.
1098    fn compute_tight_bounds(&mut self) {
1099        let deferred = std::mem::take(&mut self.deferred_rich_glyphs);
1100        if deferred.is_empty() {
1101            return;
1102        }
1103
1104        let mut idx = 0usize;
1105
1106        for span in &mut self.spans {
1107            let n = span.char_bounds.len();
1108            if n == 0 {
1109                continue;
1110            }
1111
1112            let mut span_bounds_source = BoundsSource::Advance;
1113
1114            for _gi in 0..n {
1115                if idx >= deferred.len() {
1116                    break;
1117                }
1118                let dg = &deferred[idx];
1119                idx += 1;
1120
1121                let composed = Affine::new(dg.coeffs);
1122                let (tight_bound, source) = if let Some(font_bbox) = dg.font_bbox {
1123                    // Glyph has ink. The outline/bbox is in raw glyph space and
1124                    // `composed` (CTM * glyph_transform) maps it straight to page
1125                    // space — exactly as the renderer fills the raw outline, with
1126                    // NO upem pre-scaling. Applying any extra font_size/1000 here
1127                    // would double-scale the bound.
1128                    if dg.needs_exact {
1129                        // Rotated/sheared: bound the transformed outline path so
1130                        // the box stays tight (the transformed bbox would be
1131                        // looser). Falls back to enveloping the bbox corners if
1132                        // the path was not retained.
1133                        let page_bbox = match dg.outline {
1134                            Some(ref path) => (composed * path.clone()).bounding_box(),
1135                            None => {
1136                                let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
1137                                let b = transform_bbox_corners(&raw, &composed);
1138                                kurbo::Rect::new(b[0], b[1], b[2], b[3])
1139                            }
1140                        };
1141                        (
1142                            [page_bbox.x0, page_bbox.y0, page_bbox.x1, page_bbox.y1],
1143                            BoundsSource::Tight,
1144                        )
1145                    } else {
1146                        // Axis-aligned scale + translate: enveloping the
1147                        // transformed bbox corners is exact.
1148                        let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
1149                        (transform_bbox_corners(&raw, &composed), BoundsSource::Tight)
1150                    }
1151                } else {
1152                    // No ink (space, Type3, missing outline): fall back to the
1153                    // advance box in page space, in the same frame as the span's
1154                    // char_bounds ([x, y, x + advance, y + font_size]).
1155                    let x = dg.coeffs[4];
1156                    let y = dg.coeffs[5];
1157                    (
1158                        [x, y, x + dg.glyph_width, y + dg.font_size],
1159                        BoundsSource::Estimate,
1160                    )
1161                };
1162
1163                span.tight_char_bounds.push(tight_bound);
1164                span.glyph_advances.push(dg.glyph_width);
1165                span.glyph_bounds_sources.push(source);
1166
1167                if source == BoundsSource::Tight && span_bounds_source != BoundsSource::Estimate {
1168                    span_bounds_source = BoundsSource::Tight;
1169                } else if source == BoundsSource::Estimate {
1170                    span_bounds_source = BoundsSource::Estimate;
1171                }
1172            }
1173
1174            span.bounds_source = span_bounds_source;
1175        }
1176    }
1177}
1178
1179impl Device<'_> for TextExtractionDevice {
1180    fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
1181    fn set_blend_mode(&mut self, _: BlendMode) {}
1182    fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, _: &PathDrawMode) {
1183        self.record_rule(path, transform);
1184    }
1185    fn push_clip_path(&mut self, clip_path: &ClipPath) {
1186        // The clip arrives in page space already -- `render.rs` applies it
1187        // under the identity transform -- so its box needs no transform here.
1188        // The box and not the region: a clip shaped like a star still reaches
1189        // only its own bounding box, so this over-states what is visible and
1190        // never under-states it, which is the direction that cannot invent a
1191        // rule.
1192        let box_of_clip = clip_path.path.bounding_box();
1193        let effective = match self.clip_stack.last() {
1194            Some(outer) => outer.intersect(box_of_clip),
1195            None => box_of_clip,
1196        };
1197        self.clip_stack.push(effective);
1198    }
1199    fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
1200    fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
1201    fn pop_clip_path(&mut self) {
1202        self.clip_stack.pop();
1203    }
1204    fn pop_transparency_group(&mut self) {}
1205    fn begin_marked_content(&mut self, _tag: &[u8], _mcid: Option<i32>, actual_text: Option<&str>) {
1206        let actual = actual_text.map(|text| ActualText {
1207            text: text.to_string(),
1208            spent: false,
1209        });
1210
1211        if actual.is_some() {
1212            self.open_actual_texts += 1;
1213        }
1214
1215        self.marked_content.push(MarkedContent {
1216            actual,
1217            content_spoken_for: false,
1218        });
1219    }
1220    fn end_marked_content(&mut self) {
1221        // A content stream may close a sequence it never opened -- the
1222        // operators are not balanced by anything that checks. Popping an
1223        // empty stack is the only safe reading of that.
1224        let Some(entry) = self.marked_content.pop() else {
1225            return;
1226        };
1227
1228        let Some(actual) = entry.actual else {
1229            return;
1230        };
1231
1232        self.open_actual_texts -= 1;
1233
1234        // Said already by a glyph of this sequence, or by a sequence nested in
1235        // it -- in which case this replacement covers marks that have had
1236        // their say and a second copy here would double or contradict them.
1237        if actual.spent || entry.content_spoken_for {
1238            return;
1239        }
1240
1241        // This sequence's content is answered for here, so every sequence it
1242        // sits inside is too: their characters stand for the same marks, and
1243        // the innermost statement is the specific one. Marked before the empty
1244        // check below, because `/ActualText ()` is an answer -- "these marks
1245        // stand for nothing" -- and not an absence of one.
1246        for open in &mut self.marked_content {
1247            open.content_spoken_for = true;
1248        }
1249
1250        // `/ActualText ()` is a producer saying this content stands for no
1251        // characters at all, and that answer is kept.
1252        if actual.text.is_empty() {
1253            return;
1254        }
1255
1256        if let Some(rect) = self.place_for_glyphless_text() {
1257            self.push_placed_span(&actual.text, rect);
1258        }
1259    }
1260
1261    fn draw_placed_text(&mut self, text: &str, rect: kurbo::Rect) {
1262        self.push_placed_span(text, rect);
1263    }
1264
1265    fn draw_glyph(
1266        &mut self,
1267        glyph: &Glyph<'_>,
1268        transform: Affine,
1269        glyph_transform: Affine,
1270        paint: &Paint<'_>,
1271        draw_mode: &GlyphDrawMode,
1272    ) {
1273        // Every sequence open right now has a glyph in it, whichever of them
1274        // the replacement below is taken from. Skipped entirely while nothing
1275        // open carries a replacement, which is every page of almost every
1276        // document.
1277        if self.open_actual_texts > 0 {
1278            for entry in &mut self.marked_content {
1279                entry.content_spoken_for = true;
1280            }
1281        }
1282
1283        let own_text = || match glyph.as_unicode() {
1284            Some(BfString::Char(c)) => Some(c.to_string()),
1285            Some(BfString::String(s)) => Some(s),
1286            None => None,
1287        };
1288
1289        // A marked-content sequence carrying /ActualText says what its glyphs
1290        // stand for, and that statement outranks whatever they decode to
1291        // (#550). The first glyph of the sequence carries the whole
1292        // replacement; the rest of them carry nothing, so the characters are
1293        // not repeated once per glyph.
1294        //
1295        // A glyph carrying nothing is still a glyph. It was drawn, it took
1296        // width, and the text after the sequence is placed against where it
1297        // ended -- so it runs the pen and the TJ bookkeeping below and only
1298        // then leaves, rather than returning here. Measuring the next word's
1299        // gap from the first glyph of a three-glyph replacement is how a
1300        // spurious space gets into the text.
1301        let (text, suppressed) = match self.take_actual_text() {
1302            Some(replacement) if replacement.is_empty() => (own_text().unwrap_or_default(), true),
1303            Some(replacement) => (replacement, false),
1304            None => match own_text() {
1305                Some(text) => (text, false),
1306                None => return,
1307            },
1308        };
1309
1310        let composed = transform * glyph_transform;
1311        let coeffs = composed.as_coeffs();
1312        let x = coeffs[4];
1313        let y = coeffs[5];
1314        let glyph_scale = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt().abs();
1315        let font_size = glyph_scale * 1000.0;
1316
1317        // G2: distinguish real advance (Metric) from estimate (Estimate).
1318        let (glyph_width, glyph_ws) = glyph_width_and_source(glyph, font_size);
1319        let glyph_end_x = x + glyph_width;
1320        let glyph_bound = [x, y, glyph_end_x, y + font_size];
1321
1322        let style = derive_glyph_style(glyph);
1323        let color = paint_to_rgba(paint);
1324
1325        // M3: record glyph data for deferred tight-bounds computation.
1326        if self.geometry_mode == GeometryMode::RichGeometry {
1327            self.record_deferred_glyph(glyph, &composed, font_size, glyph_width);
1328        }
1329
1330        // ANN[r17/TEX4] Feed the running sample used to derive the adaptive
1331        // median character width. Capped to protect against pathological
1332        // pages with hundreds of thousands of glyphs.
1333        if self.glyph_widths.len() < 4096 {
1334            self.glyph_widths.push(glyph_width);
1335            if self.glyph_widths.len().is_multiple_of(MEDIAN_REFRESH) {
1336                self.refresh_median_char_width();
1337            }
1338        }
1339
1340        // The glyph whose characters an /ActualText already spoke for: the pen
1341        // moves, the TJ signal is consumed, and no span is touched.
1342        if suppressed {
1343            self.last_y = y;
1344            self.advance_pen(&text, glyph_end_x);
1345            self.pending_tj_offset = 0.0;
1346            return;
1347        }
1348
1349        let same_line = (y - self.last_y).abs() <= font_size.max(BAND_Y_TOLERANCE) * 0.35;
1350        let gap = x - self.last_end_x;
1351        let adjacent = same_line && gap >= -font_size * 0.25 && gap < font_size * 0.5;
1352
1353        // G1: only merge into the previous span when font + style + color
1354        // match. Otherwise the editor's style toolbar would render the wrong
1355        // state for the cursor position.
1356        let style_matches = self
1357            .spans
1358            .last()
1359            .map(|last| {
1360                last.font_name == style.font_name
1361                    && last.is_bold == style.is_bold
1362                    && last.is_italic == style.is_italic
1363                    && last.color == color
1364            })
1365            .unwrap_or(false);
1366
1367        if adjacent && !self.spans.is_empty() && style_matches {
1368            // ANN[r17/TEX1] Multi-signal consensus replaces the prior
1369            // single-threshold rule (`gap > 0.15 * font_size`). The
1370            // consensus evaluates TJ offset, geometric gap, and
1371            // character-class transitions; a space is inserted only
1372            // when the weighted sum meets SPACE_CONSENSUS_THRESHOLD.
1373            // Decision is computed before the mutable borrow of `last`
1374            // to keep the borrow checker happy.
1375            let want_space = {
1376                let last = self.spans.last().expect("checked non-empty");
1377                !last.text.ends_with(' ')
1378                    && !text.starts_with(' ')
1379                    && self.evaluate_space_consensus(gap, font_size, &last.text, &text)
1380            };
1381            let last = self.spans.last_mut().expect("checked non-empty");
1382            if want_space {
1383                last.text.push(' ');
1384            }
1385            last.text.push_str(&text);
1386            last.width = last.width.max(glyph_end_x - last.x);
1387            last.height = last.height.max(font_size);
1388            // G2: append char_bound; downgrade width_source if this glyph is Estimate.
1389            last.char_bounds.push(glyph_bound);
1390            if glyph_ws == WidthSource::Estimate {
1391                last.width_source = WidthSource::Estimate;
1392            }
1393            self.last_y = y;
1394            self.advance_pen(&text, glyph_end_x);
1395            // ANN[r17/TEX1] Consume the TJ signal: it only counts for
1396            // the one merge it preceded.
1397            self.pending_tj_offset = 0.0;
1398            return;
1399        }
1400
1401        self.last_y = y;
1402        self.advance_pen(&text, glyph_end_x);
1403        // ANN[r17/TEX1] Non-adjacent glyph starts a fresh span, so any
1404        // pending TJ offset is about within-span word breaks and no longer
1405        // meaningful here.
1406        self.pending_tj_offset = 0.0;
1407
1408        let span = TextSpan {
1409            text,
1410            x,
1411            y,
1412            width: glyph_width,
1413            height: font_size,
1414            font_size,
1415            font_name: style.font_name,
1416            is_bold: style.is_bold,
1417            is_italic: style.is_italic,
1418            color,
1419            width_source: glyph_ws,
1420            char_bounds: vec![glyph_bound],
1421            transform: Some(coeffs),
1422            font_weight: style.font_weight,
1423            is_serif: style.is_serif,
1424            is_monospace: style.is_monospace,
1425            render_mode: Some(render_mode_from_draw_mode(draw_mode)),
1426            font_metrics: style.font_metrics,
1427            geometry_mode: self.geometry_mode,
1428            bounds_source: BoundsSource::Advance,
1429            tight_char_bounds: Vec::new(),
1430            glyph_advances: Vec::new(),
1431            glyph_bounds_sources: Vec::new(),
1432        };
1433
1434        self.spans.push(span);
1435    }
1436
1437    // ANN[r17/TEX1] Record TJ offsets. Accumulate because a single
1438    // inter-substring gap may be expressed as multiple numeric entries
1439    // (rare, but legal per PDF §9.4.3). The next draw_glyph consumes
1440    // the sum.
1441    fn text_adjustment(&mut self, amount: f32) {
1442        self.pending_tj_offset += amount;
1443    }
1444}
1445
1446/// Style metadata derived from a `Glyph` for the G1 text-run extension.
1447#[derive(Debug, Default, Clone)]
1448struct GlyphStyle {
1449    font_name: Option<String>,
1450    is_bold: bool,
1451    is_italic: bool,
1452    /// Numeric weight from embedded font data, if available.
1453    font_weight: Option<u16>,
1454    /// Serif flag from embedded font data, if available.
1455    is_serif: Option<bool>,
1456    /// Monospace flag from embedded font data, if available.
1457    is_monospace: Option<bool>,
1458    /// Vertical font metrics from embedded font data, if available.
1459    font_metrics: Option<FontMetrics>,
1460}
1461
1462/// Strip a 6-character subset prefix (e.g. `AAAAAA+Helvetica` → `Helvetica`).
1463fn strip_subset_prefix(name: &str) -> &str {
1464    match name.split_once('+') {
1465        Some((prefix, rest)) if prefix.len() == 6 => rest,
1466        _ => name,
1467    }
1468}
1469
1470/// Heuristic style inference from a PostScript name when no descriptor
1471/// flags are reachable. Matches the same rules `pdf-interpret` uses in
1472/// `FallbackFontQuery::new`.
1473fn name_style_hints(name: &str) -> (bool, bool) {
1474    let lower = name.to_ascii_lowercase();
1475    let italic = lower.contains("italic") || lower.contains("oblique") || lower.contains("slant");
1476    let bold = lower.contains("bold")
1477        || lower.contains("demi")
1478        || lower.contains("semibold")
1479        || lower.contains("heavy")
1480        || lower.contains("black");
1481    (bold, italic)
1482}
1483
1484fn derive_glyph_style(glyph: &Glyph<'_>) -> GlyphStyle {
1485    match glyph {
1486        Glyph::Outline(outline) => {
1487            if let Some(data) = outline.font_data() {
1488                let raw = data.postscript_name.as_deref().unwrap_or("");
1489                let name = strip_subset_prefix(raw).to_string();
1490                let weight_bold = data.weight.is_some_and(|w| w >= 700);
1491                let (name_bold, name_italic) = name_style_hints(&name);
1492                GlyphStyle {
1493                    font_name: if name.is_empty() { None } else { Some(name) },
1494                    is_bold: weight_bold || name_bold,
1495                    is_italic: data.is_italic || name_italic,
1496                    font_weight: data.weight.map(|w| w.clamp(1, 1000) as u16),
1497                    is_serif: Some(data.is_serif),
1498                    is_monospace: Some(data.is_monospace),
1499                    font_metrics: match (data.ascent, data.descent) {
1500                        (Some(ascent), Some(descent)) => Some(FontMetrics {
1501                            ascent,
1502                            descent,
1503                            cap_height: data.cap_height,
1504                            x_height: data.x_height,
1505                        }),
1506                        _ => None,
1507                    },
1508                }
1509            } else {
1510                // Type1 / non-embedded font — descriptor not surfaced
1511                // via font_data(). Fall back to the name-only
1512                // accessor which works for standard-14 fallbacks,
1513                // and use the canonical Standard-14 AFM metrics
1514                // when the outline font resolves to a known face.
1515                let raw = outline.postscript_name().unwrap_or_default();
1516                let name = strip_subset_prefix(&raw).to_string();
1517                let (name_bold, name_italic) = name_style_hints(&name);
1518                let metrics = outline.font_metrics().map(|(a, d, c, x)| FontMetrics {
1519                    ascent: a,
1520                    descent: d,
1521                    cap_height: c,
1522                    x_height: x,
1523                });
1524                GlyphStyle {
1525                    font_name: if name.is_empty() { None } else { Some(name) },
1526                    is_bold: name_bold,
1527                    is_italic: name_italic,
1528                    // Non-embedded font: descriptor metrics are not reachable.
1529                    font_weight: None,
1530                    is_serif: None,
1531                    is_monospace: None,
1532                    font_metrics: metrics,
1533                }
1534            }
1535        }
1536        Glyph::Type3(_) => GlyphStyle::default(),
1537    }
1538}
1539
1540fn paint_to_rgba(paint: &Paint<'_>) -> Option<[u8; 4]> {
1541    match paint {
1542        Paint::Color(c) => Some(c.to_rgba().to_rgba8()),
1543        Paint::Pattern(_) => None,
1544    }
1545}
1546
1547/// Map the renderer's coarse glyph draw mode to a PDF text render-mode code.
1548///
1549/// NOTE: the interpreter collapses the eight PDF text render modes (Tr 0–7)
1550/// into three visibility classes, so only `{0, 1, 3}` are representable here —
1551/// fill (0), stroke (1), and invisible (3). The fill+stroke and clip modes
1552/// (2, 4–7) are not distinguished. This is sufficient to identify and filter
1553/// invisible (mode-3) OCR/overlay text; recovering the full `Tr` range would
1554/// require threading it through the `Device` trait (out of scope).
1555fn render_mode_from_draw_mode(mode: &GlyphDrawMode) -> u8 {
1556    match mode {
1557        GlyphDrawMode::Fill => 0,
1558        GlyphDrawMode::Stroke(_) => 1,
1559        GlyphDrawMode::Invisible => 3,
1560    }
1561}
1562
1563#[cfg(test)]
1564mod render_mode_tests {
1565    use super::render_mode_from_draw_mode;
1566    use pdf_render::pdf_interpret::{GlyphDrawMode, StrokeProps};
1567
1568    #[test]
1569    fn render_mode_is_only_zero_one_three() {
1570        assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Fill), 0);
1571        assert_eq!(
1572            render_mode_from_draw_mode(&GlyphDrawMode::Stroke(StrokeProps::default())),
1573            1
1574        );
1575        assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Invisible), 3);
1576        // Exhaustive: the mapping only ever yields 0, 1, or 3 — never 2 or 4–7.
1577        for m in [
1578            GlyphDrawMode::Fill,
1579            GlyphDrawMode::Stroke(StrokeProps::default()),
1580            GlyphDrawMode::Invisible,
1581        ] {
1582            assert!(matches!(render_mode_from_draw_mode(&m), 0 | 1 | 3));
1583        }
1584    }
1585}
1586
1587/// Returns `(advance_in_user_space, WidthSource)` for a glyph.
1588///
1589/// Uses the real advance from `OutlineGlyph::advance_width()` when available
1590/// (returns `WidthSource::Metric`); falls back to 50% em otherwise
1591/// (`WidthSource::Estimate`). The result is clamped to at least 25% em so
1592/// invisible-glyph outliers do not collapse spans.
1593fn glyph_width_and_source(glyph: &Glyph<'_>, font_size: f64) -> (f64, WidthSource) {
1594    match glyph {
1595        Glyph::Outline(outline) => {
1596            if let Some(w) = outline.advance_width() {
1597                let advance = (w as f64 / 1000.0 * font_size).max(font_size * 0.25);
1598                (advance, WidthSource::Metric)
1599            } else {
1600                (font_size * 0.5, WidthSource::Estimate)
1601            }
1602        }
1603        Glyph::Type3(_) => (font_size * 0.5, WidthSource::Estimate),
1604    }
1605}
1606
1607/// Two baselines run in the same direction when they are within half a degree
1608/// of each other. A page draws a run of text with one text matrix, so the
1609/// glyphs of one run agree to the last bit; the tolerance is there for the
1610/// rounding a document writes its matrix with (`0.7071 -0.7071`), not for
1611/// telling two angles apart.
1612const DIRECTION_TOLERANCE_RAD: f64 = 0.008_727; // 0.5 degrees
1613
1614/// A baseline direction: a unit vector in page space.
1615type Direction = (f64, f64);
1616
1617/// A span taken out of the page's blocks, carrying the position in the page's
1618/// order that the block it came from held.
1619type PlacedSpan = (usize, TextSpan);
1620
1621/// The direction the baseline of a glyph runs in, as a unit vector in page
1622/// space: the first column of its composed transform, normalised.
1623///
1624/// This is what the text matrix rotates. `(1, 0)` for upright text, `(0, 1)`
1625/// for a page's 90-degree marginalia, `(0.7071, -0.7071)` for the diagonal
1626/// watermark of pdfjs issue17069 -- and `(1, 0)` again for a degenerate
1627/// transform, which has no direction to speak of and may as well read as the
1628/// page's own (#435).
1629fn baseline_direction(coeffs: &[f64; 6]) -> Direction {
1630    let (a, b) = (coeffs[0], coeffs[1]);
1631    let length = (a * a + b * b).sqrt();
1632    if length <= f64::EPSILON {
1633        (1.0, 0.0)
1634    } else {
1635        (a / length, b / length)
1636    }
1637}
1638
1639/// The direction a span's baseline runs in. A span not built from a glyph
1640/// carries no transform and reads as upright.
1641fn span_direction(span: &TextSpan) -> Direction {
1642    span.transform
1643        .as_ref()
1644        .map(baseline_direction)
1645        .unwrap_or((1.0, 0.0))
1646}
1647
1648/// The angle between two unit vectors, in radians, without a sign.
1649fn direction_gap(lhs: Direction, rhs: Direction) -> f64 {
1650    let cross = lhs.0 * rhs.1 - lhs.1 * rhs.0;
1651    let dot = lhs.0 * rhs.0 + lhs.1 * rhs.1;
1652    cross.atan2(dot).abs()
1653}
1654
1655fn directions_agree(lhs: Direction, rhs: Direction) -> bool {
1656    direction_gap(lhs, rhs) <= DIRECTION_TOLERANCE_RAD
1657}
1658
1659/// How far along its own baseline, and how far across it, a span's origin sits.
1660///
1661/// The pair is the page's `(x, y)` seen from the baseline: `along` grows in
1662/// reading direction, `across` grows to the left of it, so a line of rotated
1663/// text has one `across` and a range of `along` exactly as an upright line has
1664/// one `y` and a range of `x`.
1665fn baseline_coordinates(span: &TextSpan, direction: Direction) -> (f64, f64) {
1666    (
1667        span.x * direction.0 + span.y * direction.1,
1668        -span.x * direction.1 + span.y * direction.0,
1669    )
1670}
1671
1672/// Where a span stops along its own baseline.
1673///
1674/// `width` is the widest a glyph of the span reached in PAGE x, which is the
1675/// along-baseline extent only while the text is upright: a run drawn at thirty
1676/// degrees advances `cos 30` of its length in x, and reading that as the length
1677/// leaves an eighth of the run behind it as phantom gap -- enough to put a
1678/// space in the middle of a word where a style change ends one span and starts
1679/// the next. So the last glyph's own bound says where the run stopped, and
1680/// `width` answers only when there are no bounds to ask (every hand-built span)
1681/// or when it reaches further, which is what an overprinted or backwards-drawn
1682/// run does and what the seam allowance of #432 is measured against (#435).
1683fn baseline_end(span: &TextSpan, direction: Direction) -> f64 {
1684    let start = baseline_coordinates(span, direction).0;
1685    let from_width = start + span.width;
1686    match span.char_bounds.last() {
1687        Some(bound) => {
1688            let last = bound[0] * direction.0 + bound[1] * direction.1;
1689            let advance = bound[2] - bound[0];
1690            (last + advance).max(from_width)
1691        }
1692        None => from_width,
1693    }
1694}
1695
1696/// Threshold for detecting rotation or shear in the composed transform.
1697/// Any |b| or |c| above this is treated as rotation; any difference between
1698/// a and d above this is treated as non-uniform scale (= shear potential).
1699const ROTATION_EPSILON: f64 = 1e-6;
1700const SHEAR_EPSILON: f64 = 1e-3;
1701
1702/// Transform a bounding-box `[x0,y0,x1,y1]` (in local space) to page space
1703/// by applying the affine to its four corners and taking the envelope.
1704fn transform_bbox_corners(local_bbox: &[f64; 4], affine: &Affine) -> [f64; 4] {
1705    use kurbo::Point;
1706    let corners = [
1707        *affine * Point::new(local_bbox[0], local_bbox[1]),
1708        *affine * Point::new(local_bbox[2], local_bbox[1]),
1709        *affine * Point::new(local_bbox[2], local_bbox[3]),
1710        *affine * Point::new(local_bbox[0], local_bbox[3]),
1711    ];
1712    let x0 = corners.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
1713    let y0 = corners.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
1714    let x1 = corners
1715        .iter()
1716        .map(|p| p.x)
1717        .fold(f64::NEG_INFINITY, f64::max);
1718    let y1 = corners
1719        .iter()
1720        .map(|p| p.y)
1721        .fold(f64::NEG_INFINITY, f64::max);
1722    [x0, y0, x1, y1]
1723}
1724
1725/// Collapse fake-bold / overprint duplicates inside one band.
1726///
1727/// Real-word corpus failures such as 0105.pdf draw the same text several times
1728/// with sub-point x drift to simulate heavier weight. Text extraction should
1729/// keep the most informative span once rather than concatenate every overprint.
1730fn collapse_overprinted_spans(spans: &mut Vec<TextSpan>) {
1731    if spans.len() < 2 {
1732        return;
1733    }
1734
1735    let mut deduped: Vec<TextSpan> = Vec::with_capacity(spans.len());
1736    for span in spans.drain(..) {
1737        if let Some(last) = deduped.last_mut() {
1738            if spans_are_overprint_duplicates(last, &span) {
1739                let choose_incoming = span.text.chars().count() > last.text.chars().count()
1740                    || (span.text.chars().count() == last.text.chars().count()
1741                        && span.width > last.width);
1742                let preferred_text = if choose_incoming {
1743                    span.text.clone()
1744                } else {
1745                    last.text.clone()
1746                };
1747                let left = last.x.min(span.x);
1748                let right = last.right().max(span.right());
1749                last.x = left;
1750                last.y = (last.y + span.y) * 0.5;
1751                last.width = (right - left).max(last.width).max(span.width);
1752                last.height = last.height.max(span.height);
1753                last.font_size = last.font_size.max(span.font_size);
1754                last.text = preferred_text;
1755                continue;
1756            }
1757        }
1758
1759        deduped.push(span);
1760    }
1761
1762    *spans = deduped;
1763}
1764
1765fn spans_are_overprint_duplicates(lhs: &TextSpan, rhs: &TextSpan) -> bool {
1766    let lhs_text = lhs.text.trim();
1767    let rhs_text = rhs.text.trim();
1768    if lhs_text.is_empty() || rhs_text.is_empty() {
1769        return false;
1770    }
1771
1772    let same_baseline = (lhs.y - rhs.y).abs() <= lhs.font_size.max(rhs.font_size) * 0.12;
1773    if !same_baseline {
1774        return false;
1775    }
1776
1777    let lhs_left = lhs.x;
1778    let lhs_right = lhs.right();
1779    let rhs_left = rhs.x;
1780    let rhs_right = rhs.right();
1781    let overlap = (lhs_right.min(rhs_right) - lhs_left.max(rhs_left)).max(0.0);
1782    let min_width = (lhs_right - lhs_left).min(rhs_right - rhs_left).max(1.0);
1783    let heavily_overlaps = overlap / min_width >= 0.85;
1784    if !heavily_overlaps {
1785        return false;
1786    }
1787
1788    lhs_text == rhs_text || lhs_text.starts_with(rhs_text) || rhs_text.starts_with(lhs_text)
1789}
1790
1791/// Does `curr` carry on from `prev`, or does it lie under it?
1792///
1793/// The join in `TextBlock::text` drops a word `curr` repeats from the end of
1794/// `prev`, because a document that draws that word twice at the seam between
1795/// two runs holds it once (0105.pdf, #1014). That reading needs `curr` to
1796/// begin where `prev` stopped. A run that begins a whole character *inside*
1797/// its predecessor is not the continuation of it: a text field draws its value
1798/// with the advance the font asks for, which is regularly wider than the field
1799/// box, so each of three fields on one row of a form reaches tens of points
1800/// into the next field's column. With the same value in all three -- a
1801/// repeated date, a column of `N/A`, or Acroform-PDFBOX-2333's own value -- the
1802/// second and the third read as the first one's tail said again, and dropping
1803/// them loses two filled fields. On a form that is the loss of entered data
1804/// (#432, class R-E of #389).
1805///
1806/// One glyph of `curr`'s own mean advance is the allowance, and it is there
1807/// for the seam itself: a measured width carries the last glyph's side bearing
1808/// and a rounding, never a column of another field.
1809///
1810/// The mean is per GLYPH, not per character, and `char_bounds` holds one entry
1811/// per glyph drawn. A glyph that decodes to several characters -- a `fi`
1812/// ligature, any `bfrange` that names a string -- would otherwise divide one
1813/// advance by two or three characters and shrink the allowance below the seam
1814/// it exists for. The character count answers only for a span that carries no
1815/// bounds, which is every hand-built one.
1816fn second_run_carries_on_from_the_first(prev: &TextSpan, curr: &TextSpan) -> bool {
1817    let glyphs = if curr.char_bounds.is_empty() {
1818        curr.text.chars().count()
1819    } else {
1820        curr.char_bounds.len()
1821    };
1822    if glyphs == 0 {
1823        return false;
1824    }
1825
1826    let mean_advance = curr.width / glyphs as f64;
1827    // Along the previous run's baseline, so that a rotated seam is measured in
1828    // the direction the two runs actually overlap in (#435).
1829    let direction = span_direction(prev);
1830    let (curr_along, _) = baseline_coordinates(curr, direction);
1831    baseline_end(prev, direction) - curr_along <= mean_advance
1832}
1833
1834fn trim_overlapping_word_prefix(prev: &str, curr: &str) -> Option<String> {
1835    let prev_chars: Vec<char> = prev.trim_end().chars().collect();
1836    let curr_chars: Vec<char> = curr.trim_start().chars().collect();
1837    let max = prev_chars.len().min(curr_chars.len());
1838
1839    for len in (4..=max).rev() {
1840        let prev_start = prev_chars.len() - len;
1841        if prev_chars[prev_start..] != curr_chars[..len] {
1842            continue;
1843        }
1844
1845        if !curr_chars[..len].iter().all(|ch| ch.is_alphanumeric()) {
1846            continue;
1847        }
1848
1849        let prev_boundary = prev_start == 0 || !prev_chars[prev_start - 1].is_alphanumeric();
1850        let curr_boundary = len == curr_chars.len() || !curr_chars[len].is_alphanumeric();
1851        if !prev_boundary || !curr_boundary {
1852            continue;
1853        }
1854
1855        return Some(curr_chars[len..].iter().collect());
1856    }
1857
1858    None
1859}
1860
1861/// Compute an adaptive column gap threshold from a set of bands.
1862///
1863/// Collects all positive inter-span gaps within each band, computes the
1864/// median, and returns `COLUMN_GAP_MEDIAN_MULTIPLIER × median`, clamped to
1865/// `[COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX]`.  Falls back to
1866/// `COLUMN_GAP_THRESHOLD_FALLBACK` when there are no measurable gaps.
1867fn compute_adaptive_column_gap(bands: &[TextBand]) -> f64 {
1868    let mut all_gaps: Vec<f64> = Vec::new();
1869
1870    for band in bands {
1871        if band.spans.len() < 2 {
1872            continue;
1873        }
1874        let mut sorted = band.spans.clone();
1875        sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
1876        let mut prev_right = sorted[0].right();
1877        for span in sorted.iter().skip(1) {
1878            let gap = span.x - prev_right;
1879            if gap > 0.0 {
1880                all_gaps.push(gap);
1881            }
1882            prev_right = prev_right.max(span.right());
1883        }
1884    }
1885
1886    if all_gaps.is_empty() {
1887        return COLUMN_GAP_THRESHOLD_FALLBACK;
1888    }
1889
1890    all_gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1891
1892    let min_gap = all_gaps[0];
1893
1894    // When all inter-span gaps are already large (> MIN threshold), they are
1895    // likely all column gaps — the draw_glyph merger absorbed word-level
1896    // spaces into span text.  Use a fraction of the smallest gap so that
1897    // ALL column gaps exceed the threshold.
1898    if min_gap > COLUMN_GAP_THRESHOLD_MIN {
1899        return (min_gap * 0.75).clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
1900    }
1901
1902    // Look for a natural break: the largest relative jump between consecutive
1903    // sorted gaps separates word-level gaps from column gaps.
1904    let mut best_break_threshold = 0.0f64;
1905    let mut best_ratio = 1.5f64; // require at least 1.5× jump
1906    for pair in all_gaps.windows(2) {
1907        if pair[0] > 0.5 {
1908            let ratio = pair[1] / pair[0];
1909            if ratio > best_ratio {
1910                best_ratio = ratio;
1911                best_break_threshold = (pair[0] + pair[1]) * 0.5;
1912            }
1913        }
1914    }
1915
1916    if best_break_threshold > 0.0 {
1917        return best_break_threshold.clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
1918    }
1919
1920    // Fallback: median × multiplier.
1921    let mid = all_gaps.len() / 2;
1922    let median = if all_gaps.len().is_multiple_of(2) {
1923        (all_gaps[mid - 1] + all_gaps[mid]) * 0.5
1924    } else {
1925        all_gaps[mid]
1926    };
1927
1928    (median * COLUMN_GAP_MEDIAN_MULTIPLIER)
1929        .clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX)
1930}
1931
1932/// Group spans into reading-order blocks, using column-aware reordering when
1933/// a contiguous region repeatedly exposes the same gutters.
1934/// Per-page adaptive parameters derived from the span set before any
1935/// grouping happens. Centralising these here (TEX4) means the rest of
1936/// the pipeline — band grouping, XY-Cut cuts, in-block space insertion
1937/// — all speak the same typographic baseline for this specific page,
1938/// rather than each helper reaching for an independent fixed constant.
1939#[derive(Debug, Clone, Copy)]
1940struct PageStats {
1941    /// Median font size across all spans (pt).
1942    median_font_size: f64,
1943    /// Median measured character width (pt). Zero-guarded fallback is
1944    /// 0.5 × median_font_size when there aren't enough samples.
1945    /// Currently populated for diagnostics / future tuning; allow dead_code
1946    /// under `-D warnings` until a reader is added.
1947    #[allow(dead_code)]
1948    median_char_width: f64,
1949    /// Tight line-to-line spacing (25th percentile of pairwise band
1950    /// gaps), representing the body-text leading on this page. The
1951    /// quartile is used instead of the median so large paragraph /
1952    /// zone gaps don't inflate the baseline. Zero if the page has
1953    /// only one band.
1954    median_line_spacing: f64,
1955}
1956
1957impl PageStats {
1958    fn from_spans(spans: &[TextSpan]) -> Self {
1959        if spans.is_empty() {
1960            return Self {
1961                median_font_size: 12.0,
1962                median_char_width: 6.0,
1963                median_line_spacing: 0.0,
1964            };
1965        }
1966
1967        // Median font size.
1968        let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
1969        sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1970        let median_font_size = sizes[sizes.len() / 2];
1971
1972        // Median char width — measured width / char count, per span.
1973        let mut char_widths: Vec<f64> = spans
1974            .iter()
1975            .filter_map(|s| {
1976                let chars = s.text.chars().count();
1977                if chars > 0 && s.width > 0.0 {
1978                    Some(s.width / chars as f64)
1979                } else {
1980                    None
1981                }
1982            })
1983            .collect();
1984        let median_char_width = if char_widths.is_empty() {
1985            median_font_size * 0.5
1986        } else {
1987            char_widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1988            char_widths[char_widths.len() / 2]
1989        };
1990
1991        // Median line spacing — pairwise gaps between consecutive band
1992        // y-values.
1993        let band_tolerance = (median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
1994        let mut ys: Vec<f64> = spans.iter().map(|s| s.y).collect();
1995        ys.sort_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
1996        let mut band_ys: Vec<f64> = Vec::new();
1997        for y in ys {
1998            if band_ys
1999                .last()
2000                .map(|prev: &f64| (prev - y).abs() > band_tolerance)
2001                .unwrap_or(true)
2002            {
2003                band_ys.push(y);
2004            }
2005        }
2006        // ANN[r17/TEX4] "Line spacing" here means the TIGHT line-to-line
2007        // gap inside a text block — not the median of all gaps. Using
2008        // the median drags the estimate up when the page has
2009        // paragraph / zone breaks (which are the very gaps the
2010        // paragraph-break threshold is supposed to EXCEED). The 25th
2011        // percentile is the smallest gap that still shows up in more
2012        // than one place on the page; it captures body-text leading
2013        // robustly even when large zone gaps dominate.
2014        let median_line_spacing = if band_ys.len() < 2 {
2015            0.0
2016        } else {
2017            let mut spacings: Vec<f64> = band_ys
2018                .windows(2)
2019                .map(|pair| (pair[0] - pair[1]).abs())
2020                .collect();
2021            spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
2022            let q1_index = spacings.len() / 4;
2023            spacings[q1_index]
2024        };
2025
2026        Self {
2027            median_font_size,
2028            median_char_width,
2029            median_line_spacing,
2030        }
2031    }
2032}
2033
2034// ANN[r17/TEX2] Maximum recursion depth for XY-Cut. Any real page layout
2035// is decomposable in well under 10 alternating cuts; the cap guards
2036// against pathological inputs where the cut predicate keeps triggering
2037// due to floating-point drift.
2038const XY_CUT_MAX_DEPTH: usize = 12;
2039// THE GUTTER IS MEASURED AGAINST THE TYPE, NOT AGAINST THE PAGE. There was a
2040// third rail here -- a vertical gap had to reach 4% of the region's width --
2041// and on the page it was written for it is the only rail that ever binds. A
2042// two-column paper is set in 9pt type inside a 500pt text block, so 4% asks for
2043// 20pt of white where the typographer left 13pt: wider than the gutter of every
2044// journal that ever printed two columns, which is what "narrow-gutter academic
2045// layouts are still detected" in the comment below was meant to allow and did
2046// not. It refused the cut on tracemonkey's pages 2 and 13 and on the same pages
2047// of highlights and tracemonkey_with_editable_annotations -- six of the eleven
2048// pages that were left under 95% on the gate set (#389).
2049//
2050// What separates a gutter from a word space is the type, and that is what the
2051// two remaining rails say: the median font size (a word space is a quarter of
2052// an em, a gutter is more than a whole one) and a 10pt absolute floor. A page
2053// four times as wide does not get four times the word spaces, so scaling the
2054// question with the page was never measuring the thing it asked about.
2055/// Floor (in pt) for vertical gap regardless of region width. Matches
2056/// the previous `COLUMN_GAP_THRESHOLD_MIN` and keeps XY-Cut conservative
2057/// on narrow regions (sidebars, tall columns).
2058const XY_CUT_VERTICAL_GAP_FLOOR: f64 = 10.0;
2059/// Multiplier applied to median font size to produce the horizontal-gap
2060/// threshold. 1.8 × line-height matches typical paragraph spacing.
2061const XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER: f64 = 1.8;
2062/// Minimum number of spans a column must contain before it is eligible
2063/// for acceptance — one-span "columns" are almost always sidebar noise
2064/// or table-cell fragments.
2065const XY_CUT_MIN_SPANS_PER_COLUMN: usize = 2;
2066/// Average characters per band a column must have before it's accepted
2067/// as dense prose (vs. a short-cell table column).
2068const XY_CUT_MIN_CHARS_PER_BAND: f64 = 8.0;
2069
2070/// The page's spans as blocks, in reading order, given the horizontal rules the
2071/// page drew (#545): a region the document itself rules into rows is a table,
2072/// and a table is not read column by column.
2073///
2074/// ANN[r17/TEX2][r17/TEX4] Top-level grouping uses recursive XY-Cut
2075/// with a density guard. Per-page stats are computed once up front so
2076/// every decision downstream speaks the same typographic baseline.
2077fn group_spans_into_blocks(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<TextBlock> {
2078    if spans.is_empty() {
2079        return Vec::new();
2080    }
2081    // A page that is itself turned is read in the frame it was written in, and
2082    // an upright page is turned by nothing at all (#546).
2083    let turn = PageTurn::of(&spans);
2084    let spans = turn.into_frame(spans);
2085    let stats = PageStats::from_spans(&spans);
2086    let rules = turn.rules_in_frame(rules);
2087    let blocks = xy_cut_recursive(spans, 0, &stats, &rules);
2088    let blocks = reassemble_cross_direction_runs(blocks);
2089    turn.back(blocks)
2090}
2091
2092/// The quarter turn that puts a page which is itself turned back in the frame
2093/// it was written in (#546).
2094///
2095/// A page carries a `/Rotate`, of its own or inherited from the `Pages` tree,
2096/// and `Page::initial_transform` turns every glyph on it with the page: the
2097/// text device sees a page of lines running up, down or backwards. Bands are
2098/// horizontal and they read one direction, so on such a page they lie ACROSS
2099/// its lines: each glyph of a line lands in a band of its own and the page
2100/// reads as letter salad -- `ro t a ti o n  9 0` where pdfbox `PDFBOX-6049`
2101/// says `rotation 90`, and `081 noitator` on its half-turned page. This is the
2102/// same failure #435 fixed for a run drawn against the direction of its page,
2103/// and it stayed on the pages where the turned direction is the MAJORITY: what
2104/// #435 assembles there is the upright text on a turned page.
2105///
2106/// So the layout runs in the frame the page was written in. Every span is
2107/// turned into the direction most of the page's glyphs are drawn in before
2108/// `PageStats`, the cut and the bands, and the blocks are turned back before
2109/// they are returned, which leaves the spans the caller gets in page space to
2110/// the bit.
2111///
2112/// QUARTER TURNS ONLY, and the reason is exactness rather than taste: the sine
2113/// and cosine of a quarter turn are 0 and ±1, so the turn and its inverse move
2114/// no coordinate by a single bit, and a page this pass declines to turn comes
2115/// out of it byte for byte as it went in. A page `/Rotate` is a multiple of 90
2116/// by §7.7.3.3, which is the whole of what this is about; a page drawn entirely
2117/// at a free angle keeps its own bands, exactly as it did before (they read it:
2118/// `a_page_written_entirely_at_an_angle_keeps_its_own_bands`).
2119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2120enum PageTurn {
2121    /// The page reads left to right already, or in no direction this undoes.
2122    None,
2123    /// Its lines run up the page: `/Rotate 90`.
2124    Quarter,
2125    /// Its lines run right to left: `/Rotate 180`.
2126    Half,
2127    /// Its lines run down the page: `/Rotate 270`.
2128    ThreeQuarter,
2129}
2130
2131impl PageTurn {
2132    /// The turn that makes the direction the page is WRITTEN in upright.
2133    ///
2134    /// Written in, and not merely the heaviest of: this turns the layout of a
2135    /// whole page, so the direction has to carry more than half its glyphs.
2136    /// The plurality rule #435 picks its cross-direction runs by is the right
2137    /// one there -- whatever is not the page's direction is a run on it -- and
2138    /// the wrong one here, because a page with no majority direction has no
2139    /// frame to be read in and turning it by a 26% plurality moves every block
2140    /// on it. pdfbox `AcroFormsRotation` is that page: four blocks of form
2141    /// fields at the four rotations, a quarter of the glyphs each, and the
2142    /// plurality rule turned its UPRIGHT page and put its fields in an order
2143    /// neither mutool nor poppler reads them in (order agreement 100 -> 50 on
2144    /// the gate set, where nothing else on 500 documents moved at all).
2145    /// A REFLECTION IS NOT A TURN, and only the determinant says which it is
2146    /// (codex, review round 2). `heaviest_direction` reads the baseline vector,
2147    /// and a horizontal reflection such as `[-1, 0, 0, 1, e, f]` runs its
2148    /// baseline in `(-1, 0)` exactly as a half turn does. Classifying that page
2149    /// as `Half` makes [`Self::into_frame`] negate BOTH coordinates, so a
2150    /// mirrored block whose lines descend the page comes back bottom to top --
2151    /// a reading order this pass invented out of a page nobody turned.
2152    ///
2153    /// Reflected glyphs are therefore counted in the page's total and in no
2154    /// direction's share. A page mostly drawn mirrored cannot reach a majority
2155    /// and comes out of this pass byte for byte as it went in, which is what
2156    /// `Self::None` means here.
2157    fn of(spans: &[TextSpan]) -> Self {
2158        let page_total: usize = spans.iter().map(glyph_weight).sum();
2159        if page_total == 0 {
2160            return Self::None;
2161        }
2162        let turned: Vec<&TextSpan> = spans
2163            .iter()
2164            .filter(|span| is_a_proper_rotation(span))
2165            .collect();
2166        let turned_total: usize = turned.iter().copied().map(glyph_weight).sum();
2167        let Some((page, share_of_turned)) = heaviest_direction(turned.into_iter()) else {
2168            return Self::None;
2169        };
2170        // The share is of the WHOLE page, mirrored glyphs included: measured
2171        // against the rotations alone, one upright line on a mirrored page
2172        // would carry 100 % of the vote.
2173        let share = share_of_turned * turned_total as f64 / page_total as f64;
2174        if share <= 0.5 {
2175            return Self::None;
2176        }
2177        [Self::Quarter, Self::Half, Self::ThreeQuarter]
2178            .into_iter()
2179            .find(|turn| directions_agree(page, turn.direction()))
2180            .unwrap_or(Self::None)
2181    }
2182
2183    /// The direction a page this turn undoes is written in.
2184    fn direction(self) -> Direction {
2185        match self {
2186            Self::None => (1.0, 0.0),
2187            Self::Quarter => (0.0, 1.0),
2188            Self::Half => (-1.0, 0.0),
2189            Self::ThreeQuarter => (0.0, -1.0),
2190        }
2191    }
2192
2193    /// The `(cos, sin)` of the rotation into the frame: the one that takes
2194    /// [`Self::direction`] to `(1, 0)`.
2195    fn rotation(self) -> (f64, f64) {
2196        match self {
2197            Self::None => (1.0, 0.0),
2198            Self::Quarter => (0.0, -1.0),
2199            Self::Half => (-1.0, 0.0),
2200            Self::ThreeQuarter => (0.0, 1.0),
2201        }
2202    }
2203
2204    /// Every span in the frame the page was written in.
2205    fn into_frame(self, mut spans: Vec<TextSpan>) -> Vec<TextSpan> {
2206        if self == Self::None {
2207            return spans;
2208        }
2209        let rotation = self.rotation();
2210        for span in &mut spans {
2211            // In its own frame a run is as wide as it advances along its
2212            // baseline, and that is not what `draw_glyph` accumulated: `width`
2213            // is the furthest a glyph reached in PAGE x, which for a run
2214            // drawn up the page is one advance however long the run is. Every
2215            // reader of `width` above this point -- the column gutters, the
2216            // band gaps, the cell edges -- would take a line of a turned page
2217            // for a single word.
2218            let along = baseline_extent(span);
2219            turn_span(span, rotation);
2220            span.width = along;
2221        }
2222        spans
2223    }
2224
2225    /// The ruling lines of #545 in the frame the page is read in.
2226    ///
2227    /// The device records horizontal rules and no others, and only the half
2228    /// turn maps a horizontal line to a horizontal line -- mirrored, which is
2229    /// why the two ends swap. A quarter turn maps one to a vertical line:
2230    /// what the device caught on such a page are its column separators, and
2231    /// its row rules were never horizontal to begin with, so they were never
2232    /// recorded at all. Those pages are cut without rules, exactly as every
2233    /// page was before #545, rather than with rules that mean the other thing
2234    /// (#546, review round 2).
2235    fn rules_in_frame(self, rules: &[HorizontalRule]) -> Cow<'_, [HorizontalRule]> {
2236        let rotation = self.rotation();
2237        match self {
2238            Self::None => Cow::Borrowed(rules),
2239            Self::Half => Cow::Owned(
2240                rules
2241                    .iter()
2242                    .map(|rule| {
2243                        let (left, y) = turn_point(rule.left, rule.y, rotation);
2244                        let (right, _) = turn_point(rule.right, rule.y, rotation);
2245                        HorizontalRule {
2246                            left: left.min(right),
2247                            right: left.max(right),
2248                            y,
2249                        }
2250                    })
2251                    .collect(),
2252            ),
2253            Self::Quarter | Self::ThreeQuarter => Cow::Owned(Vec::new()),
2254        }
2255    }
2256
2257    /// The blocks back in page space, with every span's geometry the caller's
2258    /// own again.
2259    fn back(self, mut blocks: Vec<TextBlock>) -> Vec<TextBlock> {
2260        if self == Self::None {
2261            return blocks;
2262        }
2263        let (cos, sin) = self.rotation();
2264        for span in blocks.iter_mut().flat_map(|block| block.spans.iter_mut()) {
2265            turn_span(span, (cos, -sin));
2266            span.width = page_space_width(span);
2267        }
2268        blocks
2269    }
2270}
2271
2272/// How far a span reaches along its own baseline, from its origin.
2273fn baseline_extent(span: &TextSpan) -> f64 {
2274    let direction = span_direction(span);
2275    baseline_end(span, direction) - baseline_coordinates(span, direction).0
2276}
2277
2278/// The width `draw_glyph` measures: the furthest any of the span's glyphs
2279/// reaches in page x, from the span's origin.
2280///
2281/// This is what [`PageTurn::back`] restores, so that a caller of
2282/// `extract_text_blocks` on a turned page reads the same `width` off a span as
2283/// one on an upright page does. It is derived from the bounds rather than
2284/// remembered because the bounds are the same measurements `draw_glyph`
2285/// accumulated it from; a span with no bounds -- every hand-built one -- keeps
2286/// the width it came with.
2287fn page_space_width(span: &TextSpan) -> f64 {
2288    span.char_bounds
2289        .iter()
2290        .map(|bound| bound[2] - span.x)
2291        .reduce(f64::max)
2292        .unwrap_or(span.width)
2293}
2294
2295/// Turn one span's geometry by `(cos, sin)`.
2296///
2297/// What turns is what the layout reads as a position: the span's origin, the
2298/// baseline its transform names, and the origin of each glyph bound.
2299///
2300/// What does not turn is what is measured along that baseline and means the
2301/// same in either frame. A `char_bound` is one of those: `draw_glyph` writes
2302/// it as `[x, y, x + advance, y + font size]` around a PAGE-space origin, so
2303/// it is an advance and a font size hung off a point rather than the rectangle
2304/// a turned glyph covers -- which is what `baseline_end` reads back out of it.
2305/// Turning the two numbers as if the entry were a rectangle would turn a
2306/// measurement that was never in page space to begin with.
2307///
2308/// `tight_char_bounds` is deliberately left alone. That one IS a page-space
2309/// rectangle, and it is ink rather than layout: nothing between here and the
2310/// blocks reads it, so leaving it where it is keeps it exact for the caller who
2311/// does (`GeometryMode::RichGeometry`).
2312fn turn_span(span: &mut TextSpan, rotation: (f64, f64)) {
2313    let (x, y) = turn_point(span.x, span.y, rotation);
2314    span.x = x;
2315    span.y = y;
2316    if let Some(coeffs) = span.transform.as_mut() {
2317        let (a, b) = turn_point(coeffs[0], coeffs[1], rotation);
2318        let (c, d) = turn_point(coeffs[2], coeffs[3], rotation);
2319        let (e, f) = turn_point(coeffs[4], coeffs[5], rotation);
2320        *coeffs = [a, b, c, d, e, f];
2321    }
2322    for bound in &mut span.char_bounds {
2323        let (advance, size) = (bound[2] - bound[0], bound[3] - bound[1]);
2324        let (x0, y0) = turn_point(bound[0], bound[1], rotation);
2325        *bound = [x0, y0, x0 + advance, y0 + size];
2326    }
2327}
2328
2329/// A point turned by `(cos, sin)`.
2330fn turn_point(x: f64, y: f64, (cos, sin): (f64, f64)) -> (f64, f64) {
2331    (x * cos - y * sin, x * sin + y * cos)
2332}
2333
2334/// Take the runs drawn in another direction than the page's out of the blocks
2335/// the page's own layout put them in, and give each one back as the line it is
2336/// (#435).
2337///
2338/// Bands are horizontal, and they read one direction. The letters of a diagonal
2339/// watermark, of a rotated table header, of the marginalia down the side of a
2340/// page each land in a band of their own, interleaved with whatever upright text
2341/// shares that band, and the reader gets `rk Xa Orm test value aBte D df wN t pA
2342/// sS te` where pdfjs issue17069 says `test pdf watermark` and `SANDBOX`.
2343///
2344/// WHERE THE PAGE'S OWN DIRECTION COMES FROM. The direction most of the page's
2345/// glyphs are drawn in keeps the page's bands, and on a page that is ITSELF
2346/// turned -- a `/Rotate 90` page, whose every glyph reaches the device turned
2347/// with it -- those bands would lie across its lines and read it as badly as a
2348/// watermark read before this pass (pdfbox `PDFBOX-6049`, `AcroFormsRotation`).
2349/// Such a page is turned back into the frame it was written in before the cut
2350/// and the bands ever see it ([`PageTurn`], #546), so by the time this pass
2351/// runs the page's own direction is upright again and what is left for it is
2352/// what it is for: the run drawn against the direction of the page it is on.
2353///
2354/// This runs AFTER the page's cut and takes nothing away from it. Where the
2355/// page's columns and zones are is decided by every glyph on it, rotated ones
2356/// included -- a run of marginalia down the left edge is part of why a page
2357/// splits where it does, and a version of this that cut the page without them
2358/// moved the reading order of `issue6127` by sixteen points. So the layout is
2359/// the layout, and what changes here is only which line each rotated glyph is
2360/// read as part of.
2361fn reassemble_cross_direction_runs(blocks: Vec<TextBlock>) -> Vec<TextBlock> {
2362    let Some(page) = page_direction(&blocks) else {
2363        return blocks;
2364    };
2365
2366    let mut kept: Vec<TextBlock> = Vec::with_capacity(blocks.len());
2367    // Each cross-direction span, with the position in the page's order that the
2368    // block it was taken from held.
2369    let mut cross: Vec<PlacedSpan> = Vec::new();
2370    for block in blocks {
2371        let at = kept.len();
2372        let mut page_spans = Vec::with_capacity(block.spans.len());
2373        for span in block.spans {
2374            if directions_agree(page, span_direction(&span)) {
2375                page_spans.push(span);
2376            } else {
2377                cross.push((at, span));
2378            }
2379        }
2380        if !page_spans.is_empty() {
2381            kept.push(TextBlock { spans: page_spans });
2382        }
2383    }
2384
2385    if cross.is_empty() {
2386        return kept;
2387    }
2388
2389    // `lines_along_their_own_baselines` returns the runs in the order they are
2390    // to be read. They are inserted back to front -- so that inserting one
2391    // never moves the position another was measured at -- and two runs that
2392    // share a position are inserted in the reverse of their order, because each
2393    // insertion at the same index pushes the last one down (#435, review round
2394    // 2).
2395    let runs = lines_along_their_own_baselines(cross);
2396    for (at, run) in runs.into_iter().rev() {
2397        let at = at.min(kept.len());
2398        kept.insert(at, run);
2399    }
2400    kept
2401}
2402
2403/// The direction most of a page's glyphs are drawn in, or `None` for a page
2404/// with no text at all.
2405fn page_direction(blocks: &[TextBlock]) -> Option<Direction> {
2406    dominant_direction(blocks.iter().flat_map(|block| block.spans.iter()))
2407}
2408
2409/// The direction most of a set of spans' glyphs are drawn in, or `None` for no
2410/// spans at all.
2411///
2412/// Weighted by glyphs and not by spans: one word of upright text is not the
2413/// page's direction because a rotated run happened to arrive as three spans.
2414/// And by GLYPHS and not by characters, which are not the same count: a
2415/// ligature decodes to two or three of them, and the spaces the space consensus
2416/// puts between two glyphs are characters that were never drawn. Either would
2417/// let a minority direction outvote the page's own on a page where the two are
2418/// close. `char_bounds` holds one entry per glyph drawn; the character count
2419/// answers only for a span that carries no bounds, which is every hand-built
2420/// one (#435, review round 2).
2421fn dominant_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<Direction> {
2422    heaviest_direction(spans).map(|(direction, _)| direction)
2423}
2424
2425/// What one span weighs in a vote about direction: the glyphs it drew, or its
2426/// characters when it carries no bounds. One definition, because two of them
2427/// would let the same page have two majorities.
2428fn glyph_weight(span: &TextSpan) -> usize {
2429    if span.char_bounds.is_empty() {
2430        span.text.chars().count().max(1)
2431    } else {
2432        span.char_bounds.len()
2433    }
2434}
2435
2436/// Whether a span was drawn by a proper rotation rather than a reflection.
2437///
2438/// A baseline vector cannot tell the two apart. `[-1, 0, 0, 1, e, f]` mirrors
2439/// the page about its vertical axis and leaves the baseline running in `(-1,
2440/// 0)` -- the same direction a half turn produces -- while the page's vertical
2441/// axis stays upright and nothing has turned. The determinant is what separates
2442/// them: it is positive for a rotation and negative for a reflection.
2443///
2444/// A span with no transform is hand-built and reads upright, which is a proper
2445/// rotation by the identity.
2446fn is_a_proper_rotation(span: &TextSpan) -> bool {
2447    match span.transform {
2448        None => true,
2449        Some([a, b, c, d, _, _]) => a * d - b * c > 0.0,
2450    }
2451}
2452
2453/// The heaviest direction and the share of the glyphs drawn in it, weighed by
2454/// the rule [`dominant_direction`] documents.
2455fn heaviest_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<(Direction, f64)> {
2456    let mut directions: Vec<(Direction, usize)> = Vec::new();
2457    for span in spans {
2458        let direction = span_direction(span);
2459        let weight = glyph_weight(span);
2460        match directions
2461            .iter_mut()
2462            .find(|(known, _)| directions_agree(*known, direction))
2463        {
2464            Some(entry) => entry.1 += weight,
2465            None => directions.push((direction, weight)),
2466        }
2467    }
2468    let total: usize = directions.iter().map(|(_, weight)| *weight).sum();
2469    // The first of the heaviest, so that a page whose two directions carry the
2470    // same number of glyphs reads in the one it was drawn in first rather than
2471    // in whichever the iterator saw last.
2472    let mut winner: Option<(Direction, usize)> = None;
2473    for (direction, weight) in directions {
2474        if winner.map(|(_, best)| weight > best).unwrap_or(true) {
2475            winner = Some((direction, weight));
2476        }
2477    }
2478    winner.map(|(direction, weight)| (direction, weight as f64 / total as f64))
2479}
2480
2481/// Assemble cross-direction spans into lines along their own baselines.
2482///
2483/// The rule the page's bands follow, in each run's own frame: spans within the
2484/// band tolerance of each other across the baseline are one line, ordered along
2485/// it. A line is placed where the page's order held the first of its spans, so
2486/// a watermark drawn across the middle of a page is read there and not after
2487/// the footer.
2488fn lines_along_their_own_baselines(spans: Vec<PlacedSpan>) -> Vec<(usize, TextBlock)> {
2489    // One group per direction: two runs at two angles never join, however close
2490    // their glyphs land.
2491    let mut groups: Vec<(Direction, Vec<PlacedSpan>)> = Vec::new();
2492    for (at, span) in spans {
2493        let direction = span_direction(&span);
2494        match groups
2495            .iter_mut()
2496            .find(|(known, _)| directions_agree(*known, direction))
2497        {
2498            Some((_, group)) => group.push((at, span)),
2499            None => groups.push((direction, vec![(at, span)])),
2500        }
2501    }
2502
2503    let mut lines: Vec<(usize, TextBlock)> = Vec::new();
2504    for (direction, group) in groups {
2505        let mut rows: Vec<(f64, usize, Vec<TextSpan>)> = Vec::new();
2506        for (at, span) in group {
2507            let across = baseline_coordinates(&span, direction).1;
2508            let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
2509            match rows
2510                .iter_mut()
2511                .find(|(row_across, _, _)| (row_across - across).abs() <= tolerance)
2512            {
2513                Some((_, first, row)) => {
2514                    *first = (*first).min(at);
2515                    row.push(span);
2516                }
2517                None => rows.push((across, at, vec![span])),
2518            }
2519        }
2520
2521        // Read in the order the page's own order first held them, and where
2522        // two lines start at the same place -- which is what a block of
2523        // rotated text does -- from the top of the run's own frame down.
2524        rows.sort_by(|a, b| {
2525            a.1.cmp(&b.1)
2526                .then_with(|| b.0.partial_cmp(&a.0).unwrap_or(Ordering::Equal))
2527        });
2528        for (_, at, mut row) in rows {
2529            row.sort_by(|a, b| {
2530                baseline_coordinates(a, direction)
2531                    .0
2532                    .partial_cmp(&baseline_coordinates(b, direction).0)
2533                    .unwrap_or(Ordering::Equal)
2534            });
2535            lines.push((at, TextBlock { spans: row }));
2536        }
2537    }
2538    // Across directions too: the page's order decides, and a stable sort keeps
2539    // each direction's own line order inside it.
2540    lines.sort_by(|a, b| a.0.cmp(&b.0));
2541    lines
2542}
2543
2544fn xy_cut_recursive(
2545    spans: Vec<TextSpan>,
2546    depth: usize,
2547    stats: &PageStats,
2548    rules: &[HorizontalRule],
2549) -> Vec<TextBlock> {
2550    if spans.is_empty() {
2551        return Vec::new();
2552    }
2553    if depth >= XY_CUT_MAX_DEPTH {
2554        return band_based_blocks(spans, stats, rules);
2555    }
2556
2557    // ANN[r17/TEX2] Pick whichever direction has the largest qualifying
2558    // gap. Always cutting vertically first breaks layouts where a
2559    // footer sits in the mid-x range — it would attach to the left
2560    // column instead of being recognized as a page-level zone. The
2561    // "largest gap wins" rule is the standard XY-Cut tie-breaker used
2562    // by academic OCR literature and matches pdf_oxide.
2563    let vcut = try_vertical_cut(&spans, stats, rules);
2564    let hcut = try_horizontal_cut(&spans, stats);
2565
2566    let (chosen, _) = match (vcut, hcut) {
2567        (Some((v_groups, v_gap)), Some((h_groups, h_gap))) => {
2568            if v_gap >= h_gap {
2569                (Some(v_groups), v_gap)
2570            } else {
2571                (Some(h_groups), h_gap)
2572            }
2573        }
2574        (Some((v_groups, v_gap)), None) => (Some(v_groups), v_gap),
2575        (None, Some((h_groups, h_gap))) => (Some(h_groups), h_gap),
2576        (None, None) => (None, 0.0),
2577    };
2578
2579    if let Some(groups) = chosen {
2580        let mut out = Vec::new();
2581        for group in groups {
2582            out.extend(xy_cut_recursive(group, depth + 1, stats, rules));
2583        }
2584        return out;
2585    }
2586
2587    band_based_blocks(spans, stats, rules)
2588}
2589
2590/// Emit per-band row blocks without any column detection. Used as the
2591/// leaf of XY-Cut recursion — at this point the region either has no
2592/// further cuts or the density guard refused them.
2593fn band_based_blocks(
2594    spans: Vec<TextSpan>,
2595    stats: &PageStats,
2596    rules: &[HorizontalRule],
2597) -> Vec<TextBlock> {
2598    // XY-Cut can miss recurring gutters when a small number of bands span the
2599    // full page width (e.g. a running header above a 3-column body). In that
2600    // case, fall back to the older band/gutter detector inside the leaf region
2601    // instead of flattening everything row-major.
2602    group_spans_into_blocks_legacy_with_stats(spans, stats, rules)
2603}
2604
2605/// Median font-size helper. Currently unreferenced after `PageStats` took over
2606/// the typography baseline computation; kept available for future tuning paths.
2607#[allow(dead_code)]
2608fn median_font_size(spans: &[TextSpan]) -> f64 {
2609    if spans.is_empty() {
2610        return 12.0;
2611    }
2612    let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
2613    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
2614    sizes[sizes.len() / 2]
2615}
2616
2617/// Attempt a vertical (column) cut. Returns the span groups plus the
2618/// gap size (in pt) if a suitable gutter is found AND the density +
2619/// alignment guards accept.
2620///
2621/// ANN[r17/TEX2] Three guards together avoid false-positive columns:
2622///   1. `min_gap` is the MAX of (median_font, 10pt) — deliberately
2623///      lower than `median_font * 2` so narrow-gutter academic papers
2624///      (12pt gutters, common in print) are still detected. It does not
2625///      scale with the region: see the note on the constants above.
2626///   2. `columns_are_dense` rejects column splits where either side
2627///      has <2 spans or <8 chars/band — catches table cells.
2628///   3. `columns_are_band_aligned` rejects cuts where any band would
2629///      end up on only one side of the cut while being wider than
2630///      ~70% of that side's column width — catches full-width
2631///      paragraphs (Intro / Outro) that accidentally sit in the
2632///      left-column x-range.
2633fn try_vertical_cut(
2634    spans: &[TextSpan],
2635    stats: &PageStats,
2636    rules: &[HorizontalRule],
2637) -> Option<(Vec<Vec<TextSpan>>, f64)> {
2638    if spans.len() < 2 * XY_CUT_MIN_SPANS_PER_COLUMN {
2639        return None;
2640    }
2641
2642    let region_left = spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
2643    let region_right = spans
2644        .iter()
2645        .map(TextSpan::right)
2646        .fold(f64::NEG_INFINITY, f64::max);
2647    let region_width = region_right - region_left;
2648    if region_width <= 0.0 {
2649        return None;
2650    }
2651
2652    // ANN[r17/TEX2][r17/TEX4] Threshold uses the ADAPTIVE median-word-gap
2653    // from the bands rather than a flat font-size multiple. Narrow-gutter
2654    // academic layouts have 12pt gutters next to 4pt word spaces — the
2655    // adaptive threshold scales with the actual typography used on this
2656    // page. Clamped to `XY_CUT_VERTICAL_GAP_FLOOR` to avoid firing on
2657    // ordinary inter-word spaces when character advance data is noisy.
2658    // median_font and the width fraction act only as safety rails for
2659    // pathological inputs.
2660    let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
2661    // THE PAGE'S OWN RULING LINES COME FIRST. A region the document rules into
2662    // rows is a table, and a table read down its columns is the defect this
2663    // guard exists for (#545). Asked here as well as in the leaf below because
2664    // a table with a wide gutter is taken by this cut and never reaches the
2665    // leaf at all.
2666    let all_bands: Vec<usize> = (0..bands.len()).collect();
2667    if region_is_ruled(&bands, &all_bands, rules) {
2668        return None;
2669    }
2670    let adaptive = compute_adaptive_column_gap(&bands);
2671    let floor = stats.median_font_size.max(XY_CUT_VERTICAL_GAP_FLOOR);
2672    let min_gap = adaptive.min(floor).max(XY_CUT_VERTICAL_GAP_FLOOR);
2673
2674    // Intervals [x_left, x_right] of every span; we look for an x value
2675    // that is free of ALL intervals (full-height gap).
2676    // MEASURED EDGES ONLY. `right()` returns x + width.max(estimated_width()),
2677    // and the estimate is a character count times a nominal advance -- on
2678    // tracemonkey's abstract it overshoots the measured edge by 61pt, which is
2679    // wider than the gutter it then covers. A gap scan that asks "is any x free
2680    // of every span" cannot use an edge that is deliberately generous.
2681    // Unmeasured spans (width 0) keep the estimate, since a point-wide interval
2682    // would open a gap where there is text.
2683    let mut intervals: Vec<(f64, f64)> = spans
2684        .iter()
2685        .map(|s| {
2686            let r = if s.width > 0.0 {
2687                s.measured_right()
2688            } else {
2689                s.right()
2690            };
2691            (s.x, r.max(s.x + 0.001))
2692        })
2693        .collect();
2694    intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
2695
2696    let mut cursor = intervals[0].1;
2697    let mut best_gap: Option<(f64, f64)> = None; // (gap_size, cut_x)
2698    for (left, right) in intervals.iter().skip(1) {
2699        if *left > cursor {
2700            let gap = *left - cursor;
2701            if gap >= min_gap {
2702                match best_gap {
2703                    Some((best, _)) if best >= gap => {}
2704                    _ => {
2705                        let cut_x = (cursor + *left) * 0.5;
2706                        best_gap = Some((gap, cut_x));
2707                    }
2708                }
2709            }
2710        }
2711        cursor = cursor.max(*right);
2712    }
2713
2714    let (gap_size, cut_x) = best_gap?;
2715
2716    // Split spans around the cut. A span whose midpoint is < cut_x
2717    // belongs to the left group.
2718    let mut left_group = Vec::new();
2719    let mut right_group = Vec::new();
2720    for span in spans {
2721        let midpoint = span.x + (span.right() - span.x) * 0.5;
2722        if midpoint < cut_x {
2723            left_group.push(span.clone());
2724        } else {
2725            right_group.push(span.clone());
2726        }
2727    }
2728
2729    if !columns_are_dense(&left_group, &right_group, stats) {
2730        return None;
2731    }
2732    if !columns_are_band_aligned(spans, cut_x, region_left, region_right, stats) {
2733        return None;
2734    }
2735
2736    Some((vec![left_group, right_group], gap_size))
2737}
2738
2739/// ANN[r17/TEX2] Reject a vertical cut when any band sits on only one
2740/// side of the cut AND occupies more than ~70% of that side's column
2741/// width. Such bands are almost certainly full-width paragraphs that
2742/// happened to align with the left margin of one column, and forcing
2743/// them into that column re-orders them relative to text that follows.
2744fn columns_are_band_aligned(
2745    spans: &[TextSpan],
2746    cut_x: f64,
2747    region_left: f64,
2748    region_right: f64,
2749    stats: &PageStats,
2750) -> bool {
2751    let left_width = (cut_x - region_left).max(1.0);
2752    let right_width = (region_right - cut_x).max(1.0);
2753
2754    // Threshold chosen empirically: paragraph bodies in columnar
2755    // layouts usually fill ~60-70% of their column; anything wider
2756    // than 0.7× is a page-level element masquerading as column
2757    // content.
2758    const MAX_SINGLE_SIDE_FRACTION: f64 = 0.70;
2759
2760    let _ = (left_width, right_width, MAX_SINGLE_SIDE_FRACTION);
2761    // ONE SPAN'S INK REACHING ACROSS THE CUT is what "a full-width element
2762    // masquerading as column content" actually looks like. The guard used to
2763    // ask a different question -- is a band that sits on one side wider than
2764    // 0.70 of that side -- and on a real two-column page the answer is yes for
2765    // any ordinary line of body text, because a line fills about 0.9 of its
2766    // column. Every such page has single-sided bands (a heading, a column that
2767    // ends short, a figure), so the old test refused the canonical academic
2768    // page every time (#429).
2769    //
2770    // Width was a proxy for crossing, and it is a proxy that cannot tell a
2771    // banner from a column line. Its own fixture shows why: the banner runs
2772    // x 40..320 against a cut at 200, which is ink on both sides. It counted as
2773    // "one side" only because sides were decided on each span's midpoint.
2774    for span in spans {
2775        if span.x < cut_x && span.measured_right() > cut_x {
2776            return false;
2777        }
2778    }
2779
2780    // A LINE CANNOT BE HALF AGAIN AS WIDE AS THE TYPICAL LINE IN ITS OWN COLUMN
2781    // and still be a line of that column. That is the second way a full-width
2782    // element hides: `Intro paragraph` and `Outro paragraph` in
2783    // mixed_single_and_multi_column_regions_preserve_shared_bands are wider
2784    // than the column they sit above but still stop short of the gutter, so no
2785    // ink of theirs crosses the cut. Swallowed into the left column they would
2786    // read Intro, L1..L3, Outro, R1..R3 -- the outro before the right column it
2787    // precedes.
2788    //
2789    // Measured against the MEDIAN LINE OF THAT SIDE, not against the width of
2790    // the side. The old test used cut_x - region_left, which is a column plus
2791    // half a gutter, so an ordinary body line at ~0.9 of its column tripped it
2792    // and the canonical two-column page was refused every time (#429). The
2793    // median calibrates itself: on a real column most lines run full width, so
2794    // the median IS the column width and nothing legitimate exceeds it.
2795    const OUTLIER: f64 = 1.3;
2796    // EVERY band's per-side extent, not only the single-sided ones. Measuring
2797    // the typical line from the single-sided bands alone compares the outliers
2798    // with themselves: in the Intro/Outro fixture those two ARE the only
2799    // single-sided bands, so their median is their own width and nothing ever
2800    // stands out.
2801    let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
2802    let mut widths: (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
2803    let mut single: Vec<(bool, f64)> = Vec::new();
2804    for band in &bands {
2805        let mut left_lo = f64::INFINITY;
2806        let mut left_hi = f64::NEG_INFINITY;
2807        let mut right_lo = f64::INFINITY;
2808        let mut right_hi = f64::NEG_INFINITY;
2809        for span in &band.spans {
2810            let r = span.measured_right();
2811            if span.x + (r - span.x) * 0.5 < cut_x {
2812                left_lo = left_lo.min(span.x);
2813                left_hi = left_hi.max(r);
2814            } else {
2815                right_lo = right_lo.min(span.x);
2816                right_hi = right_hi.max(r);
2817            }
2818        }
2819        let has_left = left_hi > left_lo || left_lo.is_finite();
2820        let has_right = right_hi > right_lo || right_lo.is_finite();
2821        if has_left {
2822            widths.0.push((left_hi - left_lo).max(0.0));
2823        }
2824        if has_right {
2825            widths.1.push((right_hi - right_lo).max(0.0));
2826        }
2827        match (has_left, has_right) {
2828            (true, false) => single.push((true, (left_hi - left_lo).max(0.0))),
2829            (false, true) => single.push((false, (right_hi - right_lo).max(0.0))),
2830            _ => {}
2831        }
2832    }
2833    let median = |v: &mut Vec<f64>| -> Option<f64> {
2834        if v.is_empty() {
2835            return None;
2836        }
2837        v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
2838        Some(v[v.len() / 2])
2839    };
2840    let left_median = median(&mut widths.0);
2841    let right_median = median(&mut widths.1);
2842    for (is_left, w) in single {
2843        let m = if is_left { left_median } else { right_median };
2844        if let Some(m) = m {
2845            if m > 0.0 && w > m * OUTLIER {
2846                return false;
2847            }
2848        }
2849    }
2850    true
2851}
2852
2853/// Density guard — reject column splits that look like tables (few,
2854/// short spans per column). A column is "dense" when it has at least
2855/// MIN_SPANS_PER_COLUMN spans and the average character count per band
2856/// exceeds MIN_CHARS_PER_BAND.
2857fn columns_are_dense(left: &[TextSpan], right: &[TextSpan], stats: &PageStats) -> bool {
2858    for col in [left, right] {
2859        if col.len() < XY_CUT_MIN_SPANS_PER_COLUMN {
2860            return false;
2861        }
2862        let bands = group_spans_into_bands_with_stats(col.to_vec(), stats);
2863        if bands.is_empty() {
2864            return false;
2865        }
2866        let total_chars: usize = col.iter().map(|s| s.text.chars().count()).sum();
2867        let chars_per_band = total_chars as f64 / bands.len() as f64;
2868        if chars_per_band < XY_CUT_MIN_CHARS_PER_BAND {
2869            return false;
2870        }
2871    }
2872    true
2873}
2874
2875/// Attempt a horizontal (zone / paragraph) cut. Unlike vertical cuts
2876/// this does NOT need a density guard — splitting top-from-bottom
2877/// cannot re-order content.
2878fn try_horizontal_cut(spans: &[TextSpan], stats: &PageStats) -> Option<(Vec<Vec<TextSpan>>, f64)> {
2879    if spans.len() < 2 {
2880        return None;
2881    }
2882    // Sort by descending y (PDF y grows upward).
2883    let mut sorted = spans.to_vec();
2884    sorted.sort_by(|a, b| {
2885        b.y.partial_cmp(&a.y)
2886            .unwrap_or(Ordering::Equal)
2887            .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
2888    });
2889
2890    // ANN[r17/TEX4] Paragraph / zone cuts scale with MEDIAN LINE
2891    // SPACING when available — this is the typographically correct
2892    // baseline (paragraph break ≈ 1.8 × line-spacing). When the page
2893    // has only one band, or stats haven't observed spacing yet, fall
2894    // back to the font-size multiple the legacy path used.
2895    let min_gap = if stats.median_line_spacing > 0.0 {
2896        stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
2897    } else {
2898        stats.median_font_size * XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER
2899    };
2900
2901    // Look for the largest gap between consecutive span y-values.
2902    let mut best: Option<(f64, f64)> = None; // (gap_size, cut_y)
2903    let tolerance = stats.median_font_size * BAND_Y_FRACTION;
2904    let mut band_bottom = sorted[0].y;
2905
2906    for span in sorted.iter().skip(1) {
2907        if (band_bottom - span.y).abs() <= tolerance {
2908            band_bottom = band_bottom.min(span.y);
2909            continue;
2910        }
2911        let gap = band_bottom - span.y;
2912        if gap >= min_gap {
2913            let cut_y = (band_bottom + span.y) * 0.5;
2914            match best {
2915                Some((best_gap, _)) if best_gap >= gap => {}
2916                _ => best = Some((gap, cut_y)),
2917            }
2918        }
2919        band_bottom = span.y;
2920    }
2921
2922    let (gap_size, cut_y) = best?;
2923
2924    let mut top_group = Vec::new();
2925    let mut bottom_group = Vec::new();
2926    for span in spans {
2927        if span.y > cut_y {
2928            top_group.push(span.clone());
2929        } else {
2930            bottom_group.push(span.clone());
2931        }
2932    }
2933    if top_group.is_empty() || bottom_group.is_empty() {
2934        return None;
2935    }
2936    Some((vec![top_group, bottom_group], gap_size))
2937}
2938
2939/// Legacy band+column-detection path, kept for reference and as the
2940/// fallback inside `band_based_blocks` test coverage. Not currently
2941/// used — XY-Cut supersedes it.
2942#[allow(dead_code)]
2943fn group_spans_into_blocks_legacy(spans: Vec<TextSpan>) -> Vec<TextBlock> {
2944    let bands = group_spans_into_bands(spans);
2945    group_spans_into_blocks_legacy_from_bands(bands, &[])
2946}
2947
2948fn group_spans_into_blocks_legacy_with_stats(
2949    spans: Vec<TextSpan>,
2950    stats: &PageStats,
2951    rules: &[HorizontalRule],
2952) -> Vec<TextBlock> {
2953    let bands = group_spans_into_bands_with_stats(spans, stats);
2954    group_spans_into_blocks_legacy_from_bands(bands, rules)
2955}
2956
2957fn group_spans_into_blocks_legacy_from_bands(
2958    bands: Vec<TextBand>,
2959    rules: &[HorizontalRule],
2960) -> Vec<TextBlock> {
2961    if bands.is_empty() {
2962        return Vec::new();
2963    }
2964
2965    let column_gap_threshold = compute_adaptive_column_gap(&bands);
2966
2967    let mut blocks = Vec::new();
2968    let mut idx = 0;
2969
2970    while idx < bands.len() {
2971        let gap_midpoints = bands[idx].gap_midpoints(column_gap_threshold);
2972        if gap_midpoints.is_empty() {
2973            idx = push_row(&bands, idx, &mut blocks);
2974            continue;
2975        }
2976
2977        let mut boundaries = gap_midpoints.clone();
2978        let mut band_indices = vec![idx];
2979        let mut gapped_band_count = 1usize;
2980        let mut region_left = bands[idx].left();
2981        let mut region_right = bands[idx].right();
2982        let mut next_idx = idx + 1;
2983
2984        while next_idx < bands.len() {
2985            let next_band = &bands[next_idx];
2986            let next_gap_midpoints = next_band.gap_midpoints(column_gap_threshold);
2987            if next_gap_midpoints.is_empty() {
2988                if next_band
2989                    .fits_single_column(&boundaries, region_left, region_right)
2990                    .is_some()
2991                {
2992                    band_indices.push(next_idx);
2993                    next_idx += 1;
2994                    continue;
2995                }
2996                break;
2997            }
2998
2999            if !boundaries_match(&boundaries, &next_gap_midpoints, column_gap_threshold) {
3000                break;
3001            }
3002
3003            update_boundaries(&mut boundaries, &next_gap_midpoints, gapped_band_count);
3004            gapped_band_count += 1;
3005            band_indices.push(next_idx);
3006            region_left = region_left.min(next_band.left());
3007            region_right = region_right.max(next_band.right());
3008            next_idx += 1;
3009        }
3010
3011        if region_is_columnar(&bands, &band_indices, &boundaries, gapped_band_count)
3012            && !region_is_ruled(&bands, &band_indices, rules)
3013        {
3014            append_column_region_blocks(&bands, &band_indices, &boundaries, &mut blocks);
3015            idx = next_idx;
3016        } else {
3017            idx = push_row(&bands, idx, &mut blocks);
3018        }
3019    }
3020
3021    blocks
3022}
3023
3024/// Emit the band at `idx` and return the index of the first band after what was
3025/// emitted.
3026///
3027/// A band is normally one line of the page and becomes one block. The exception
3028/// is the row of a table one of whose cells wraps: the wrapped cell's second
3029/// line is a band of its own, and emitting the bands in turn puts it after the
3030/// row's remaining cells instead of after the text it continues (#434).
3031fn push_row(bands: &[TextBand], idx: usize, blocks: &mut Vec<TextBlock>) -> usize {
3032    if let Some(next) = append_wrapped_table_row(bands, idx, blocks) {
3033        return next;
3034    }
3035    blocks.push(bands[idx].row_block());
3036    idx + 1
3037}
3038
3039/// The left edges of the cells the band at `idx` draws, as the row itself
3040/// states them: one edge per span start, and two starts within
3041/// `CELL_EDGE_TOLERANCE` are one edge.
3042///
3043/// Cell edges are taken from starts rather than from the gaps between spans
3044/// because a cell's measured extent is not a column boundary: `8.2.5.26-t05`
3045/// draws four of its five columns less than 16 pt apart, which no gutter
3046/// threshold that leaves ordinary word spacing alone would ever see.
3047fn cell_edges(band: &TextBand) -> Vec<f64> {
3048    let mut edges: Vec<f64> = band.spans.iter().map(|span| span.x).collect();
3049    edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3050    edges.dedup_by(|a, b| (*a - *b).abs() <= CELL_EDGE_TOLERANCE);
3051    edges
3052}
3053
3054/// The cell a span belongs to: the edge it starts on, when it also stops before
3055/// the next edge. A span that starts between two edges, or that reaches into the
3056/// next cell, belongs to no cell of this grid and refuses the whole row.
3057///
3058/// The extent asked for is the measured one. A span's nominal width is the sum
3059/// of the advances the font asks for, which regularly overshoots the ink and so
3060/// the cell: `t05`'s wrapped cell advances 78 pt past its own text and into the
3061/// column after it.
3062fn cell_of(span: &TextSpan, edges: &[f64]) -> Option<usize> {
3063    let idx = edges
3064        .iter()
3065        .rposition(|edge| span.x >= edge - CELL_EDGE_TOLERANCE)?;
3066    if (span.x - edges[idx]).abs() > CELL_EDGE_TOLERANCE {
3067        return None;
3068    }
3069    if let Some(next_edge) = edges.get(idx + 1) {
3070        if span.measured_right() > next_edge - CELL_EDGE_TOLERANCE {
3071            return None;
3072        }
3073    }
3074    Some(idx)
3075}
3076
3077/// The tallest glyph of the band, which is the unit both vertical distances
3078/// below are measured in.
3079fn band_line_height(band: &TextBand) -> f64 {
3080    band.spans
3081        .iter()
3082        .map(|span| span.height.max(span.font_size))
3083        .fold(0.0_f64, f64::max)
3084}
3085
3086/// Whether `band` is another row of the same grid: every one of its spans sits
3087/// in a cell of `edges`, and it carries at least `TABLE_GRID_MIN_COLUMNS` of
3088/// them.
3089///
3090/// This is what separates a table row from two lines of prose that happen to
3091/// break in the right place. One line's spans always align with themselves; a
3092/// grid is only a grid when a second row draws the same edges.
3093/// The single cell of the grid a band fills, when it fills exactly one.
3094///
3095/// This is what a wrapped line looks like from above, and what the next row of
3096/// a table does not look like: a row with an empty cell still fills two of
3097/// them, so it is not mistaken for the line of a cell that ran on.
3098fn band_fills_one_cell(band: &TextBand, edges: &[f64]) -> Option<usize> {
3099    let mut filled: Option<usize> = None;
3100    for span in &band.spans {
3101        let cell = cell_of(span, edges)?;
3102        match filled {
3103            Some(seen) if seen != cell => return None,
3104            _ => filled = Some(cell),
3105        }
3106    }
3107    filled
3108}
3109
3110fn band_matches_grid(band: &TextBand, edges: &[f64]) -> bool {
3111    let mut occupied = vec![false; edges.len()];
3112    for span in &band.spans {
3113        match cell_of(span, edges) {
3114            Some(cell) => occupied[cell] = true,
3115            None => return false,
3116        }
3117    }
3118    occupied.iter().filter(|seen| **seen).count() >= TABLE_GRID_MIN_COLUMNS
3119}
3120
3121/// Emit the row at `idx` cell by cell when one of its cells wraps, and return
3122/// the index of the first band after the row. `None` when the band is not such
3123/// a row, which is the ordinary case.
3124///
3125/// The row is emitted slice-major: every cell in turn, and within a cell its
3126/// own lines in the order they are drawn. That is what both readers do, and it
3127/// is the only order in which a wrapped cell reads as one piece of text.
3128///
3129/// A wrapped line and the next row of the table look alike from above -- both
3130/// are a band under the row with some of its cells filled -- and telling them
3131/// apart is the whole of the work here. Two things say "line" rather than
3132/// "row": it fills a single cell of the grid, and it sits closer to the row
3133/// than the rows of the grid sit to each other.
3134///
3135/// What it does not do, deliberately:
3136///   * a row two of whose cells wrap onto the same line is a line that fills
3137///     two cells, which is what the next row of the table looks like, so it
3138///     stays row-major;
3139///   * so does a row every one of whose cells has a line under it: nothing
3140///     there says which line continues the row and which one begins the next,
3141///     and it is also the shape a two-column region takes when one column runs
3142///     on alone -- reordering that would be the column cut of #389 class O-B
3143///     done badly;
3144///   * a row of two cells is left alone, because that is also what a page set
3145///     in columns looks like -- see `TABLE_ROW_MIN_CELLS`;
3146///   * the first row of a table is left alone, because a single line's cell
3147///     edges are also the word boundaries of a sentence and no row above it
3148///     corroborates them;
3149///   * a continuation that is indented, centred or right-aligned inside its
3150///     cell does not start on the cell's edge and is not recognised.
3151fn append_wrapped_table_row(
3152    bands: &[TextBand],
3153    idx: usize,
3154    blocks: &mut Vec<TextBlock>,
3155) -> Option<usize> {
3156    let head = &bands[idx];
3157    if head.spans.len() < TABLE_ROW_MIN_CELLS {
3158        return None;
3159    }
3160    let edges = cell_edges(head);
3161    if edges.len() < TABLE_ROW_MIN_CELLS {
3162        return None;
3163    }
3164    let line_height = band_line_height(head);
3165    if line_height <= 0.0 {
3166        return None;
3167    }
3168
3169    let head_cells: Vec<usize> = head
3170        .spans
3171        .iter()
3172        .map(|span| cell_of(span, &edges))
3173        .collect::<Option<Vec<usize>>>()?;
3174
3175    // The pitch of the grid, from the band directly above: one line plus
3176    // whatever the cells are padded by. Nothing below may sit that far from
3177    // what it continues, or it is the next row rather than a wrapped line.
3178    let above = idx.checked_sub(1)?;
3179    let row_pitch = bands[above].y - head.y;
3180    if row_pitch <= 0.0 {
3181        return None;
3182    }
3183    let max_line_gap = (line_height * WRAPPED_CELL_LINE_GAP_FACTOR)
3184        .min(row_pitch * WRAPPED_CELL_MAX_PITCH_FRACTION);
3185
3186    // The nearest row above that draws the same cell edges. It is what says
3187    // this is a grid at all: one line's spans always align with themselves, so
3188    // only a second row makes the edges evidence of anything. The bands stepped
3189    // over on the way are the wrapped lines of that row.
3190    let mut corroborator = above;
3191    loop {
3192        if band_matches_grid(&bands[corroborator], &edges) {
3193            break;
3194        }
3195        if band_fills_one_cell(&bands[corroborator], &edges).is_none()
3196            || idx - corroborator >= TABLE_GRID_MAX_LOOKBACK
3197            || corroborator == 0
3198        {
3199            return None;
3200        }
3201        corroborator -= 1;
3202    }
3203
3204    // The continuation lines: each fills one cell of the grid that the row
3205    // itself fills, and sits less than the grid's pitch below the last line of
3206    // *that cell*.
3207    //
3208    // Per cell, and not below the line before it whichever cell that was: a
3209    // cell's lines are consecutive, so its third line is two gaps below the row
3210    // while another cell's second line is one. Measuring against the last line
3211    // accepted would let the row after a wrapped one -- if it happens to fill a
3212    // single cell -- sit one gap below that wrapped line and be read as a third
3213    // line of the row it follows.
3214    let mut continuations: Vec<(&TextBand, usize)> = Vec::new();
3215    let mut last_line_in_cell = vec![head.y; edges.len()];
3216    let mut next_idx = idx + 1;
3217    while next_idx < bands.len() {
3218        let band = &bands[next_idx];
3219        // One cell's line cannot carry more runs than the whole row it
3220        // continues; a band that does is a row of its own.
3221        if band.spans.len() >= head.spans.len() {
3222            break;
3223        }
3224        let Some(cell) = band_fills_one_cell(band, &edges) else {
3225            break;
3226        };
3227        if !head_cells.contains(&cell) {
3228            break;
3229        }
3230        let line_gap = last_line_in_cell[cell] - band.y;
3231        if line_gap <= 0.0 || line_gap > max_line_gap {
3232            break;
3233        }
3234        last_line_in_cell[cell] = band.y;
3235        continuations.push((band, cell));
3236        next_idx += 1;
3237    }
3238    if continuations.is_empty() {
3239        return None;
3240    }
3241
3242    let wrapped_cells: Vec<usize> = continuations.iter().map(|(_, cell)| *cell).collect();
3243    if head_cells.iter().all(|cell| wrapped_cells.contains(cell)) {
3244        return None;
3245    }
3246
3247    for cell in 0..edges.len() {
3248        let row_spans: Vec<TextSpan> = head
3249            .spans
3250            .iter()
3251            .zip(&head_cells)
3252            .filter(|(_, span_cell)| **span_cell == cell)
3253            .map(|(span, _)| span.clone())
3254            .collect();
3255        if !row_spans.is_empty() {
3256            blocks.push(TextBlock { spans: row_spans });
3257        }
3258        for (band, wrapped_cell) in &continuations {
3259            if *wrapped_cell == cell {
3260                blocks.push(band.row_block());
3261            }
3262        }
3263    }
3264
3265    Some(next_idx)
3266}
3267
3268/// Legacy wrapper used by call sites that haven't been handed PageStats.
3269/// It derives stats locally. Prefer `group_spans_into_bands_with_stats`
3270/// inside the XY-Cut pipeline to avoid recomputing the stats per call.
3271fn group_spans_into_bands(spans: Vec<TextSpan>) -> Vec<TextBand> {
3272    let stats = PageStats::from_spans(&spans);
3273    group_spans_into_bands_with_stats(spans, &stats)
3274}
3275
3276fn group_spans_into_bands_with_stats(mut spans: Vec<TextSpan>, stats: &PageStats) -> Vec<TextBand> {
3277    if spans.is_empty() {
3278        return Vec::new();
3279    }
3280
3281    spans.sort_by(|a, b| {
3282        b.y.partial_cmp(&a.y)
3283            .unwrap_or(Ordering::Equal)
3284            .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
3285    });
3286
3287    // ANN[r17/TEX4] Band tolerance scales with this page's median font
3288    // size rather than a fixed 5pt floor. Single-page spreads with
3289    // huge display fonts (24pt+) previously merged unrelated lines; a
3290    // fractional threshold keeps that from happening without hurting
3291    // body-text pages.
3292    let page_tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
3293
3294    let mut bands: Vec<TextBand> = Vec::new();
3295
3296    for span in spans {
3297        let tolerance = (span.height * BAND_Y_FRACTION)
3298            .max(page_tolerance)
3299            .max(BAND_Y_TOLERANCE);
3300        if let Some(band) = bands
3301            .iter_mut()
3302            .find(|band| (band.y - span.y).abs() <= tolerance)
3303        {
3304            let span_count = band.spans.len() as f64;
3305            band.y = (band.y * span_count + span.y) / (span_count + 1.0);
3306            band.spans.push(span);
3307        } else {
3308            bands.push(TextBand::new(span));
3309        }
3310    }
3311
3312    for band in &mut bands {
3313        band.sort_spans();
3314    }
3315
3316    bands.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
3317    bands
3318}
3319
3320fn boundaries_match(boundaries: &[f64], gap_midpoints: &[f64], column_gap_threshold: f64) -> bool {
3321    let tolerance = (column_gap_threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
3322    boundaries.len() == gap_midpoints.len()
3323        && boundaries
3324            .iter()
3325            .zip(gap_midpoints)
3326            .all(|(lhs, rhs)| (lhs - rhs).abs() <= tolerance)
3327}
3328
3329fn update_boundaries(boundaries: &mut [f64], gap_midpoints: &[f64], seen_gapped_bands: usize) {
3330    for (boundary, midpoint) in boundaries.iter_mut().zip(gap_midpoints) {
3331        *boundary =
3332            (*boundary * seen_gapped_bands as f64 + midpoint) / (seen_gapped_bands as f64 + 1.0);
3333    }
3334}
3335
3336fn region_is_columnar(
3337    bands: &[TextBand],
3338    band_indices: &[usize],
3339    boundaries: &[f64],
3340    gapped_band_count: usize,
3341) -> bool {
3342    if boundaries.is_empty()
3343        || gapped_band_count < MIN_COLUMN_GAPPED_BANDS
3344        || band_indices.is_empty()
3345        || (gapped_band_count as f64 / band_indices.len() as f64) < MIN_COLUMN_GAP_SUPPORT
3346    {
3347        return false;
3348    }
3349
3350    let mut non_empty_slices = 0usize;
3351    let mut dense_slices = 0usize;
3352    let mut slices_per_column = vec![0usize; boundaries.len() + 1];
3353
3354    for &band_idx in band_indices {
3355        let slices = bands[band_idx].split_by_boundaries(boundaries);
3356        for (column_idx, slice) in slices.iter().enumerate() {
3357            if slice.is_empty() {
3358                continue;
3359            }
3360
3361            non_empty_slices += 1;
3362            slices_per_column[column_idx] += 1;
3363
3364            let char_count = slice
3365                .iter()
3366                .map(|span| span.text.chars().count())
3367                .sum::<usize>();
3368            if slice.len() >= 2 || char_count >= 8 {
3369                dense_slices += 1;
3370            }
3371        }
3372    }
3373
3374    if non_empty_slices < boundaries.len() + 2 {
3375        return false;
3376    }
3377
3378    if slices_per_column.contains(&0) {
3379        return false;
3380    }
3381
3382    (dense_slices as f64 / non_empty_slices as f64) >= MIN_DENSE_SLICE_RATIO
3383}
3384
3385/// Whether the page rules this region into rows (#545).
3386///
3387/// A rule counts when it crosses the region -- `RULE_SPAN_FRACTION` of the
3388/// region's own width, so a line under a single cell does not speak for the
3389/// table -- and lies between the baselines of two bands that follow each other
3390/// in the region. Enough of the region's band gaps carrying such a rule is the
3391/// document saying, in its own graphics, that these bands are rows; and rows
3392/// are read across, never down.
3393///
3394/// What it does not claim: a table drawn without ruling lines is invisible
3395/// here, and this is deliberately the whole of the claim. The alternative
3396/// offered for that case was a width heuristic, and a width heuristic cannot
3397/// tell a table from a paper set in two columns -- it fixed the Isartor
3398/// appendix and cost `tracemonkey` p2 and p13, which is the class O-B danger
3399/// the #389 report names for exactly this defect.
3400fn region_is_ruled(bands: &[TextBand], band_indices: &[usize], rules: &[HorizontalRule]) -> bool {
3401    if rules.is_empty() || band_indices.len() < MIN_RULED_ROW_SEPARATORS + 1 {
3402        return false;
3403    }
3404
3405    let mut left = f64::INFINITY;
3406    let mut right = f64::NEG_INFINITY;
3407    for &band_idx in band_indices {
3408        for span in &bands[band_idx].spans {
3409            left = left.min(span.x);
3410            right = right.max(span.measured_right());
3411        }
3412    }
3413    let width = right - left;
3414    if width <= 0.0 {
3415        return false;
3416    }
3417
3418    let mut gaps = 0usize;
3419    let mut ruled = 0usize;
3420    for pair in band_indices.windows(2) {
3421        let upper = bands[pair[0]].y;
3422        let lower = bands[pair[1]].y;
3423        if upper <= lower {
3424            continue;
3425        }
3426        gaps += 1;
3427        let crossed = rules.iter().any(|rule| {
3428            rule.y > lower
3429                && rule.y < upper
3430                && (rule.right.min(right) - rule.left.max(left)) >= width * RULE_SPAN_FRACTION
3431        });
3432        if crossed {
3433            ruled += 1;
3434        }
3435    }
3436
3437    gaps > 0
3438        && ruled >= MIN_RULED_ROW_SEPARATORS
3439        && (ruled as f64 / gaps as f64) >= MIN_RULED_GAP_SUPPORT
3440}
3441
3442fn append_column_region_blocks(
3443    bands: &[TextBand],
3444    band_indices: &[usize],
3445    boundaries: &[f64],
3446    blocks: &mut Vec<TextBlock>,
3447) {
3448    let column_count = boundaries.len() + 1;
3449    let mut column_bands = vec![Vec::<TextSpan>::new(); column_count];
3450
3451    for &band_idx in band_indices {
3452        let slices = bands[band_idx].split_by_boundaries(boundaries);
3453        for (column_idx, slice) in slices.into_iter().enumerate() {
3454            if slice.is_empty() {
3455                continue;
3456            }
3457            column_bands[column_idx].push(TextSpan::default());
3458            let marker_idx = column_bands[column_idx].len() - 1;
3459            column_bands[column_idx][marker_idx] = TextSpan {
3460                x: f64::NEG_INFINITY,
3461                y: bands[band_idx].y,
3462                ..TextSpan::default()
3463            };
3464            column_bands[column_idx].extend(slice);
3465        }
3466    }
3467
3468    for spans in column_bands {
3469        let mut current: Vec<TextSpan> = Vec::new();
3470        for span in spans {
3471            if span.x == f64::NEG_INFINITY {
3472                if !current.is_empty() {
3473                    current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
3474                    blocks.push(TextBlock {
3475                        spans: std::mem::take(&mut current),
3476                    });
3477                }
3478                continue;
3479            }
3480            current.push(span);
3481        }
3482        if !current.is_empty() {
3483            current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
3484            blocks.push(TextBlock { spans: current });
3485        }
3486    }
3487}
3488
3489/// Join per-block lines, stitching end-of-line hyphenated word-wraps the
3490/// way pdftotext / MuPDF / PDFBox do.
3491///
3492/// Trigger conditions (all must hold):
3493/// 1. Previous line ends with `-` preceded by an alphabetic character.
3494/// 2. The alphabetic suffix before the `-` has >= 3 characters.
3495/// 3. The next line (trimmed) starts with an ASCII lowercase letter.
3496/// 4. The lowercase prefix of the next line has >= 3 characters.
3497///
3498/// When triggered, the trailing `-` is removed and the two halves are
3499/// concatenated without a space or newline.
3500///
3501/// This avoids false positives on compound words ("real-time"), bullet
3502/// lists, numeric ranges ("42-"), and short fragments.
3503fn stitch_hyphenated_lines(lines: &[String]) -> String {
3504    let mut out = String::new();
3505    for (idx, line) in lines.iter().enumerate() {
3506        if idx == 0 {
3507            out.push_str(line);
3508            continue;
3509        }
3510
3511        let next_trimmed = line.trim_start();
3512
3513        // Check the accumulated output for end-of-line hyphen pattern
3514        let should_merge = is_hyphen_wrap_candidate(&out, next_trimmed);
3515
3516        if should_merge {
3517            out.pop(); // drop the trailing '-'
3518            out.push_str(next_trimmed);
3519        } else {
3520            out.push('\n');
3521            out.push_str(line);
3522        }
3523    }
3524    out
3525}
3526
3527/// Check if the accumulated text ends with a hyphen-wrap pattern and the
3528/// continuation is a valid merge target.
3529fn is_hyphen_wrap_candidate(accumulated: &str, next_trimmed: &str) -> bool {
3530    // Must end with '-'
3531    if !accumulated.ends_with('-') {
3532        return false;
3533    }
3534
3535    // Character before '-' must be alphabetic
3536    let before_hyphen = accumulated.chars().rev().nth(1);
3537    if !before_hyphen.is_some_and(|c| c.is_alphabetic()) {
3538        return false;
3539    }
3540
3541    // Count consecutive alphabetic chars before the '-' (the word fragment)
3542    let alpha_prefix_len = accumulated
3543        .chars()
3544        .rev()
3545        .skip(1) // skip the '-'
3546        .take_while(|c| c.is_alphabetic())
3547        .count();
3548    if alpha_prefix_len < 3 {
3549        return false;
3550    }
3551
3552    // Next line must start with lowercase ASCII
3553    let first_next = next_trimmed.chars().next();
3554    if !first_next.is_some_and(|c| c.is_ascii_lowercase()) {
3555        return false;
3556    }
3557
3558    // Count consecutive lowercase chars at start of next line
3559    let next_alpha_len = next_trimmed
3560        .chars()
3561        .take_while(|c| c.is_ascii_lowercase())
3562        .count();
3563    if next_alpha_len < 3 {
3564        return false;
3565    }
3566
3567    true
3568}
3569
3570/// Normalize extracted text to match pdftotext conventions.
3571///
3572/// 1. Trim trailing whitespace from each line.
3573/// 2. Collapse runs of more than two consecutive newlines into exactly two.
3574/// 3. Preserve form-feed characters (`\x0C`) as page separators.
3575/// 4. End with a single trailing newline (or empty for empty input).
3576pub(crate) fn normalize_text_output(text: &str) -> String {
3577    if text.is_empty() {
3578        return String::new();
3579    }
3580
3581    let mut lines: Vec<&str> = Vec::new();
3582    for line in text.split('\n') {
3583        lines.push(line.trim_end());
3584    }
3585
3586    // Remove trailing empty lines (we'll add exactly one \n at the end)
3587    while lines.last() == Some(&"") {
3588        lines.pop();
3589    }
3590
3591    if lines.is_empty() {
3592        return String::new();
3593    }
3594
3595    let mut result = String::with_capacity(text.len());
3596    let mut consecutive_empty = 0u32;
3597
3598    for (i, line) in lines.iter().enumerate() {
3599        if line.is_empty() || *line == "\x0C" {
3600            if line.is_empty() {
3601                consecutive_empty += 1;
3602                // Collapse >2 consecutive blank lines to 2
3603                if consecutive_empty <= 2 {
3604                    result.push('\n');
3605                }
3606            } else {
3607                // Bare form-feed line
3608                consecutive_empty = 0;
3609                result.push_str(line);
3610                if i + 1 < lines.len() {
3611                    result.push('\n');
3612                }
3613            }
3614        } else {
3615            // Both form-feed-prefixed and regular lines are emitted as-is.
3616            consecutive_empty = 0;
3617            result.push_str(line);
3618            if i + 1 < lines.len() {
3619                result.push('\n');
3620            }
3621        }
3622    }
3623
3624    // Ensure single trailing newline
3625    if !result.is_empty() && !result.ends_with('\n') {
3626        result.push('\n');
3627    }
3628
3629    result
3630}
3631
3632#[cfg(test)]
3633mod tests {
3634    use super::*;
3635
3636    fn span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
3637        TextSpan {
3638            text: text.into(),
3639            x,
3640            y,
3641            width,
3642            height: 12.0,
3643            font_size: 12.0,
3644            ..TextSpan::default()
3645        }
3646    }
3647
3648    /// A two-column academic page, the shape #429 is about: a full-width title,
3649    /// then two columns of body text whose right column ends one line short of
3650    /// the left. Every line is a realistic fraction of its column width.
3651    ///
3652    /// Returned in reading order — title, all of column one, then all of column
3653    /// two — so the test can say what the right answer is instead of guessing.
3654    ///
3655    /// The lines are prose-length on purpose. `estimated_width()` is
3656    /// `font_size * 0.5 * chars`, so a realistic line of about fifty characters
3657    /// estimates wider than it measures, and `right()` — which takes the larger
3658    /// of the two — then reaches across the gutter. Short labels would make a
3659    /// tidier fixture that never reproduces the defect.
3660    fn two_column_page() -> Vec<TextSpan> {
3661        const LEFT_X: f64 = 40.0;
3662        const RIGHT_X: f64 = 320.0;
3663        const COLUMN_WIDTH: f64 = 250.0;
3664        const TOP: f64 = 700.0;
3665        const LEADING: f64 = 14.0;
3666        const LINES: usize = 20;
3667
3668        let mut out = vec![span(
3669            "A Trace-based Just-in-Time Compiler",
3670            40.0,
3671            740.0,
3672            530.0,
3673        )];
3674        for i in 0..LINES {
3675            out.push(span(
3676                &format!("left line {i:02} of the two-column academic body text"),
3677                LEFT_X,
3678                TOP - (i as f64) * LEADING,
3679                COLUMN_WIDTH,
3680            ));
3681        }
3682        // One line short: the right column ends above the left, which leaves the
3683        // left column's last band alone on its side of the gutter. Every real
3684        // two-column page has such bands -- a heading, a column that ends early,
3685        // a figure -- and the guard this test is about used to refuse the cut
3686        // for exactly that reason.
3687        for i in 0..(LINES - 1) {
3688            out.push(span(
3689                &format!("right line {i:02} of the two-column academic body text"),
3690                RIGHT_X,
3691                TOP - (i as f64) * LEADING,
3692                COLUMN_WIDTH,
3693            ));
3694        }
3695        out
3696    }
3697
3698    /// #429: a two-column body is read column by column.
3699    ///
3700    /// The whole page in one assertion, because reading order is a property of
3701    /// the whole page and a per-block check would pass while the columns were
3702    /// interleaved. Revert either half of the fix and this goes red: with the
3703    /// gap scan on `right()` no gutter is found at all, and with the old
3704    /// band-aligned guard the cut is refused once it is.
3705    #[test]
3706    fn a_two_column_body_is_read_column_by_column() {
3707        let spans = two_column_page();
3708        let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
3709
3710        let produced: Vec<String> = group_spans_into_blocks(spans, &[])
3711            .into_iter()
3712            .flat_map(|b| b.spans.into_iter().map(|s| s.text))
3713            .collect();
3714
3715        assert_eq!(produced, expected);
3716    }
3717
3718    /// A JOURNAL GUTTER IS NARROWER THAN FOUR PERCENT OF THE PAGE (#389).
3719    ///
3720    /// The shape that the region-width rail refused: 9 pt type, a 493 pt text
3721    /// block, and the 13.2 pt gutter tracemonkey is actually set with. Four
3722    /// percent of that block is 19.7 pt, which is wider than the gutter of any
3723    /// journal, and the cut was refused on pages 2 and 13 of tracemonkey,
3724    /// highlights and tracemonkey_with_editable_annotations -- six of the eleven
3725    /// pages left under 95 % order agreement on the gate set.
3726    ///
3727    /// Returned in reading order, so the test says what the right answer is.
3728    fn narrow_gutter_page() -> Vec<TextSpan> {
3729        narrow_gutter_page_with_gutter(13.2)
3730    }
3731
3732    /// The same page with the gutter set by the caller, so a test can ask for a
3733    /// gap that is not a gutter.
3734    fn narrow_gutter_page_with_gutter(gutter: f64) -> Vec<TextSpan> {
3735        const LEFT_X: f64 = 54.0;
3736        const COLUMN_WIDTH: f64 = 240.0;
3737        const FONT_SIZE: f64 = 9.0;
3738        const TOP: f64 = 700.0;
3739        const LEADING: f64 = 11.0;
3740        const LINES: usize = 20;
3741
3742        let right_x = LEFT_X + COLUMN_WIDTH + gutter;
3743        let line = |text: &str, x: f64, y: f64| TextSpan {
3744            text: text.into(),
3745            x,
3746            y,
3747            width: COLUMN_WIDTH,
3748            height: FONT_SIZE,
3749            font_size: FONT_SIZE,
3750            ..TextSpan::default()
3751        };
3752
3753        let mut out = vec![TextSpan {
3754            text: "Trace-based Just-in-Time Type Specialization".into(),
3755            x: LEFT_X,
3756            y: 740.0,
3757            width: 490.0,
3758            height: 14.0,
3759            font_size: 14.0,
3760            ..TextSpan::default()
3761        }];
3762        // PROSE-LENGTH LINES, for the same reason `two_column_page` gives them:
3763        // `estimated_width()` is font_size * 0.5 * chars, so a real line of
3764        // sixty characters overshoots its measured width and the overshoot
3765        // swallows the gutter -- which is how a page ends up with no measurable
3766        // word gaps at all and the adaptive threshold falls back to 20 pt.
3767        for i in 0..LINES {
3768            out.push(line(
3769                &format!("left line {i:02} of the two-column academic body of this paper"),
3770                LEFT_X,
3771                TOP - (i as f64) * LEADING,
3772            ));
3773        }
3774        for i in 0..(LINES - 1) {
3775            out.push(line(
3776                &format!("right line {i:02} of the two-column academic body of this paper"),
3777                right_x,
3778                TOP - (i as f64) * LEADING,
3779            ));
3780        }
3781        out
3782    }
3783
3784    /// Red the moment a gutter has to be a fraction of the region again.
3785    ///
3786    /// Asked of `try_vertical_cut` itself, so that it names the cut and not the
3787    /// page: the reading order below is the consequence and this is the cause.
3788    #[test]
3789    fn a_thirteen_point_gutter_in_nine_point_type_is_a_gutter() {
3790        let spans = narrow_gutter_page();
3791        let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
3792        let stats = PageStats::from_spans(&body);
3793
3794        let (groups, gap) = try_vertical_cut(&body, &stats, &[])
3795            .expect("no column cut found on a 13.2 pt gutter in 9 pt type");
3796
3797        assert_eq!(groups.len(), 2);
3798        assert!(
3799            (gap - 13.2).abs() < 0.5,
3800            "the cut was taken on a gap of {gap}, not on the gutter"
3801        );
3802        assert!(
3803            groups[0].iter().all(|s| s.text.starts_with("left")),
3804            "the left group holds text from the right column"
3805        );
3806        assert!(
3807            groups[1].iter().all(|s| s.text.starts_with("right")),
3808            "the right group holds text from the left column"
3809        );
3810    }
3811
3812    /// And the whole page, in reading order, through the path a document takes.
3813    /// Red with the rail back as well: the leaf's own band-by-band gutter
3814    /// detector does not rescue a page whose lines overshoot their measured
3815    /// width, which is every page set in a proportional face.
3816    #[test]
3817    fn the_narrow_gutter_page_reads_column_by_column() {
3818        let spans = narrow_gutter_page();
3819        let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
3820
3821        let produced: Vec<String> = group_spans_into_blocks(spans, &[])
3822            .into_iter()
3823            .flat_map(|b| b.spans.into_iter().map(|s| s.text))
3824            .collect();
3825
3826        assert_eq!(produced, expected);
3827    }
3828
3829    /// And the floor still does its work: the same page whose columns are six
3830    /// points apart is not two columns, because six points is a word space in
3831    /// nine point type. Red if the floor is dropped along with the rail.
3832    #[test]
3833    fn a_gap_narrower_than_the_type_is_not_a_gutter() {
3834        let spans = narrow_gutter_page_with_gutter(6.0);
3835        let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
3836        let stats = PageStats::from_spans(&body);
3837
3838        assert!(
3839            try_vertical_cut(&body, &stats, &[]).is_none(),
3840            "a six point gap in nine point type was taken for a column gutter"
3841        );
3842    }
3843
3844    /// The gutter is only there in the MEASURED edges. `right()` returns
3845    /// x + width.max(estimated_width()), and on tracemonkey's abstract that
3846    /// estimate overshoots the measured edge by 61pt -- wider than the gutter
3847    /// it then covers, so the scan finds no corridor and the guards never run.
3848    #[test]
3849    fn a_column_gap_is_found_on_measured_edges_not_estimated_ones() {
3850        let spans = two_column_page();
3851        let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
3852        let stats = PageStats::from_spans(&body);
3853
3854        let overshoots = body.iter().any(|s| s.right() > s.measured_right() + 1.0);
3855        assert!(
3856            overshoots,
3857            "fixture no longer exercises the overshoot; right() must exceed measured_right()              for this test to mean anything"
3858        );
3859
3860        let cut = try_vertical_cut(&body, &stats, &[]);
3861        assert!(
3862            cut.is_some(),
3863            "no column cut found on a clean two-column body"
3864        );
3865    }
3866
3867    fn block_texts(spans: Vec<TextSpan>) -> Vec<String> {
3868        block_texts_with_rules(spans, &[])
3869    }
3870
3871    /// The same, with the horizontal rules the page drew (#545).
3872    fn block_texts_with_rules(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<String> {
3873        group_spans_into_blocks(spans, rules)
3874            .into_iter()
3875            .map(|block| block.text())
3876            .collect()
3877    }
3878
3879    /// The same, but only the leaf of the XY-cut: the bands as
3880    /// `group_spans_into_blocks_legacy_from_bands` sees them.
3881    ///
3882    /// Some of the shapes below are refused by the leaf and taken by the column
3883    /// cut above it instead, which proves nothing about the leaf. This says what
3884    /// the leaf does with them.
3885    fn leaf_texts(spans: Vec<TextSpan>) -> Vec<String> {
3886        let bands = group_spans_into_bands(spans);
3887        group_spans_into_blocks_legacy_from_bands(bands, &[])
3888            .iter()
3889            .map(TextBlock::text)
3890            .collect()
3891    }
3892
3893    #[test]
3894    fn empty_device_produces_empty_text() {
3895        let dev = TextExtractionDevice::new();
3896        assert!(dev.into_text().is_empty());
3897    }
3898
3899    #[test]
3900    fn single_column_stays_row_major() {
3901        let texts = block_texts(vec![
3902            span("Single Column Line 1", 40.0, 700.0, 140.0),
3903            span("Single Column Line 2", 40.0, 684.0, 140.0),
3904            span("Single Column Line 3", 40.0, 668.0, 140.0),
3905        ]);
3906
3907        assert_eq!(
3908            texts,
3909            vec![
3910                "Single Column Line 1",
3911                "Single Column Line 2",
3912                "Single Column Line 3",
3913            ]
3914        );
3915    }
3916
3917    /// A header, three bands each of which draws two slices with a gutter
3918    /// between them, and a footer. Read as columns unless the page says
3919    /// otherwise, which is what the three tests after this one are about.
3920    fn two_column_region_spans() -> Vec<TextSpan> {
3921        vec![
3922            span("Header", 200.0, 740.0, 80.0),
3923            span("Left column line one", 40.0, 700.0, 115.0),
3924            span("Right column line one", 320.0, 700.0, 120.0),
3925            span("Left column line two", 40.0, 684.0, 115.0),
3926            span("Right column line two", 320.0, 684.0, 120.0),
3927            span("Left column line three", 40.0, 668.0, 125.0),
3928            span("Right column line three", 320.0, 668.0, 130.0),
3929            span("Footer", 200.0, 620.0, 80.0),
3930        ]
3931    }
3932
3933    #[test]
3934    fn two_column_region_reads_column_major() {
3935        let texts = block_texts(two_column_region_spans());
3936
3937        assert_eq!(
3938            texts,
3939            vec![
3940                "Header",
3941                "Left column line one",
3942                "Left column line two",
3943                "Left column line three",
3944                "Right column line one",
3945                "Right column line two",
3946                "Right column line three",
3947                "Footer",
3948            ]
3949        );
3950    }
3951
3952    /// The rules the page draws between its rows, for the fixture above: a
3953    /// horizontal line across the whole table, in every gap between two rows.
3954    fn rules_between_the_rows() -> Vec<HorizontalRule> {
3955        [692.0, 676.0]
3956            .into_iter()
3957            .map(|y| HorizontalRule {
3958                left: 35.0,
3959                right: 445.0,
3960                y,
3961            })
3962            .collect()
3963    }
3964
3965    /// The same three bands that read column by column above, with a ruling
3966    /// line between each pair of them, read across instead (#545). This is the
3967    /// Isartor appendix: two columns of text that are the two cells of a row,
3968    /// and nothing but the page's own graphics says so.
3969    #[test]
3970    fn a_ruled_two_column_region_is_read_row_by_row() {
3971        let texts = block_texts_with_rules(two_column_region_spans(), &rules_between_the_rows());
3972
3973        assert_eq!(
3974            texts,
3975            vec![
3976                "Header",
3977                "Left column line one Right column line one",
3978                "Left column line two Right column line two",
3979                "Left column line three Right column line three",
3980                "Footer",
3981            ]
3982        );
3983    }
3984
3985    /// A rule under one cell is not a rule between two rows. Widening the test
3986    /// to any line that happens to sit in the gap turns every underlined cell
3987    /// into a table, so the rule has to cross the region it claims to divide.
3988    #[test]
3989    fn a_rule_that_crosses_only_one_column_leaves_the_columns_alone() {
3990        let short: Vec<HorizontalRule> = rules_between_the_rows()
3991            .into_iter()
3992            .map(|rule| HorizontalRule {
3993                right: 160.0,
3994                ..rule
3995            })
3996            .collect();
3997
3998        let texts = block_texts_with_rules(two_column_region_spans(), &short);
3999
4000        assert_eq!(
4001            texts,
4002            vec![
4003                "Header",
4004                "Left column line one",
4005                "Left column line two",
4006                "Left column line three",
4007                "Right column line one",
4008                "Right column line two",
4009                "Right column line three",
4010                "Footer",
4011            ]
4012        );
4013    }
4014
4015    /// One rule is a headline's underline, a footnote's separator, the line
4016    /// above a page number -- every document has them, and none of them makes
4017    /// the text below a table.
4018    ///
4019    /// Asked of the predicate rather than of the page, because a region of
4020    /// three bands with one rule in it is refused by the column cut above the
4021    /// leaf for its own reasons, and a page-level assertion would be green
4022    /// whatever this rule said.
4023    #[test]
4024    fn a_single_rule_does_not_make_a_region_a_table() {
4025        let bands = group_spans_into_bands(two_column_region_spans());
4026        let rows: Vec<usize> = bands
4027            .iter()
4028            .enumerate()
4029            .filter(|(_, band)| band.y < 720.0 && band.y > 640.0)
4030            .map(|(idx, _)| idx)
4031            .collect();
4032        assert_eq!(
4033            rows.len(),
4034            3,
4035            "the fixture's three rows are the region here"
4036        );
4037
4038        let both = rules_between_the_rows();
4039        assert!(
4040            region_is_ruled(&bands, &rows, &both),
4041            "a rule in every gap of the region is a ruled table"
4042        );
4043        assert!(
4044            !region_is_ruled(&bands, &rows, &both[..1]),
4045            "one rule is not a table"
4046        );
4047    }
4048
4049    /// One path may hold every rule a table draws, so a cap asked once per
4050    /// `draw_path` bounds nothing. The vector it would grow is walked per band
4051    /// by `region_is_ruled`, which is why the bound is on the vector and not on
4052    /// the call.
4053    #[test]
4054    fn one_path_of_many_rules_still_stops_at_the_cap() {
4055        let mut path = BezPath::new();
4056        for row in 0..(MAX_RECORDED_RULES * 2) {
4057            let y = 700.0 - row as f64;
4058            path.move_to((40.0, y));
4059            path.line_to((440.0, y));
4060            path.close_path();
4061        }
4062
4063        let mut device = TextExtractionDevice::new();
4064        device.record_rule(&path, Affine::IDENTITY);
4065
4066        assert_eq!(device.rules.len(), MAX_RECORDED_RULES);
4067    }
4068
4069    /// A Form XObject's `/BBox` reaches this device as a clip, with the
4070    /// geometry inside it unclipped. A line ruled across one cell of such a
4071    /// form therefore arrives as a line across the page, and two of those are
4072    /// enough to refuse a column cut that should have been taken.
4073    #[test]
4074    fn a_rule_is_recorded_at_the_width_the_clip_leaves_it() {
4075        let mut wide = BezPath::new();
4076        wide.move_to((40.0, 600.0));
4077        wide.line_to((440.0, 600.0));
4078
4079        let mut device = TextExtractionDevice::new();
4080        device.push_clip_path(&ClipPath {
4081            path: kurbo::Rect::new(40.0, 500.0, 160.0, 700.0).to_path(0.1),
4082            fill: pdf_render::pdf_interpret::FillRule::NonZero,
4083        });
4084        device.record_rule(&wide, Affine::IDENTITY);
4085
4086        assert_eq!(device.rules.len(), 1, "the visible part is still a rule");
4087        assert!(
4088            device.rules[0].right < 161.0,
4089            "the rule was recorded past its clip: {:?}",
4090            device.rules[0]
4091        );
4092
4093        let mut above = BezPath::new();
4094        above.move_to((40.0, 800.0));
4095        above.line_to((440.0, 800.0));
4096        device.record_rule(&above, Affine::IDENTITY);
4097        assert_eq!(
4098            device.rules.len(),
4099            1,
4100            "a rule the clip cuts away entirely was recorded: {:?}",
4101            device.rules
4102        );
4103
4104        device.pop_clip_path();
4105        device.record_rule(&wide, Affine::IDENTITY);
4106        assert_eq!(
4107            device.rules[1].right, 440.0,
4108            "the clip outlived the pop: {:?}",
4109            device.rules[1]
4110        );
4111    }
4112
4113    /// A table rules most of its gaps and a page of prose rules a couple: the
4114    /// share is what separates them, and it is measured rather than assumed.
4115    /// The Isartor appendix rules 26 of its 28 gaps; the two it leaves are the
4116    /// rows whose cell wraps, which is why the share is a half and not all.
4117    #[test]
4118    fn a_region_with_a_rule_in_a_minority_of_its_gaps_is_not_a_table() {
4119        let mut spans = Vec::new();
4120        for row in 0..6 {
4121            let y = 700.0 - row as f64 * 16.0;
4122            spans.push(span("Left column line", 40.0, y, 115.0));
4123            spans.push(span("Right column line", 320.0, y, 120.0));
4124        }
4125        let bands = group_spans_into_bands(spans);
4126        let rows: Vec<usize> = (0..bands.len()).collect();
4127        assert_eq!(bands.len(), 6, "six rows, so five gaps between them");
4128
4129        let rule_at = |y: f64| HorizontalRule {
4130            left: 35.0,
4131            right: 445.0,
4132            y,
4133        };
4134        let two = vec![rule_at(692.0), rule_at(676.0)];
4135        let three = vec![rule_at(692.0), rule_at(676.0), rule_at(660.0)];
4136
4137        assert!(
4138            !region_is_ruled(&bands, &rows, &two),
4139            "two of five is prose"
4140        );
4141        assert!(
4142            region_is_ruled(&bands, &rows, &three),
4143            "three of five is a table"
4144        );
4145    }
4146
4147    #[test]
4148    fn mixed_single_and_multi_column_regions_preserve_shared_bands() {
4149        let texts = block_texts(vec![
4150            span("Intro paragraph", 40.0, 740.0, 180.0),
4151            span("L1 words here", 40.0, 700.0, 110.0),
4152            span("R1 words here", 320.0, 700.0, 110.0),
4153            span("L2 words here", 40.0, 684.0, 110.0),
4154            span("R2 words here", 320.0, 684.0, 110.0),
4155            span("L3 words here", 40.0, 668.0, 110.0),
4156            span("R3 words here", 320.0, 668.0, 110.0),
4157            span("Outro paragraph", 40.0, 620.0, 180.0),
4158        ]);
4159
4160        assert_eq!(
4161            texts,
4162            vec![
4163                "Intro paragraph",
4164                "L1 words here",
4165                "L2 words here",
4166                "L3 words here",
4167                "R1 words here",
4168                "R2 words here",
4169                "R3 words here",
4170                "Outro paragraph",
4171            ]
4172        );
4173    }
4174
4175    #[test]
4176    fn short_table_like_rows_fall_back_to_row_major() {
4177        let texts = block_texts(vec![
4178            span("Name", 40.0, 700.0, 30.0),
4179            span("Age", 320.0, 700.0, 20.0),
4180            span("Alice", 40.0, 684.0, 35.0),
4181            span("30", 320.0, 684.0, 15.0),
4182            span("Bob", 40.0, 668.0, 24.0),
4183            span("25", 320.0, 668.0, 15.0),
4184        ]);
4185
4186        assert_eq!(texts, vec!["Name Age", "Alice 30", "Bob 25"]);
4187    }
4188
4189    /// A three-column table row whose middle cell wraps, at the block level:
4190    /// the reordering happens in the leaf of the XY-cut, and this is the leaf's
4191    /// own view of `8.2.5.26-t05-pass-d.pdf`'s second row (#434).
4192    #[test]
4193    fn a_wrapped_cell_is_emitted_with_its_own_cell() {
4194        let texts = block_texts(vec![
4195            span("Index", 60.0, 700.0, 30.0),
4196            span("Failure condition", 200.0, 700.0, 100.0),
4197            span("Section", 400.0, 700.0, 42.0),
4198            span("15-003", 60.0, 680.0, 36.0),
4199            span("In a table not organized", 200.0, 680.0, 144.0),
4200            span("UA1:7.5-2", 400.0, 680.0, 54.0),
4201            span("a cell without a Scope", 200.0, 668.0, 132.0),
4202        ]);
4203
4204        assert_eq!(
4205            texts,
4206            vec![
4207                "Index Failure condition Section",
4208                "15-003",
4209                "In a table not organized",
4210                "a cell without a Scope",
4211                "UA1:7.5-2",
4212            ]
4213        );
4214    }
4215
4216    /// The failure case the fix names: when every cell of the row has a line
4217    /// under it, nothing says which of those lines continues the row and which
4218    /// one begins the next -- the same shape a two-column region takes when one
4219    /// column runs on alone. Such a row keeps today's row-major order rather
4220    /// than being guessed at.
4221    /// The leading is deliberately tight, so that all three lines pass the
4222    /// distance test in their own cell and it is the last guard -- every cell
4223    /// of the row having a line under it -- that has to refuse the row. At that
4224    /// leading the column cut above the leaf takes the page, so this one asks
4225    /// the leaf directly.
4226    #[test]
4227    fn a_row_whose_every_cell_wraps_stays_row_major() {
4228        let texts = leaf_texts(vec![
4229            span("Index", 60.0, 700.0, 30.0),
4230            span("Failure condition", 200.0, 700.0, 100.0),
4231            span("Section", 400.0, 700.0, 42.0),
4232            span("15-003", 60.0, 680.0, 36.0),
4233            span("In a table not organized", 200.0, 680.0, 144.0),
4234            span("UA1:7.5-2", 400.0, 680.0, 54.0),
4235            span("is the index", 60.0, 674.0, 72.0),
4236            span("a cell without a Scope", 200.0, 668.0, 132.0),
4237            span("of the suite", 400.0, 662.0, 72.0),
4238        ]);
4239
4240        assert_eq!(
4241            texts,
4242            vec![
4243                "Index Failure condition Section",
4244                "15-003 In a table not organized UA1:7.5-2",
4245                "is the index",
4246                "a cell without a Scope",
4247                "of the suite",
4248            ]
4249        );
4250    }
4251
4252    /// The next row of a table with an empty cell also has fewer spans than the
4253    /// row above it and also starts on the same edges. It is the next row all
4254    /// the same, and what says so is that it fills two cells where a wrapped
4255    /// line fills one. The header of this table is padded, so the body's rows
4256    /// sit closer together than the pitch under the header and the distance
4257    /// alone would let this one pass.
4258    #[test]
4259    fn a_row_with_an_empty_cell_is_a_row_and_not_a_wrap() {
4260        let texts = block_texts(vec![
4261            span("Index", 60.0, 700.0, 30.0),
4262            span("Type", 200.0, 700.0, 24.0),
4263            span("How", 400.0, 700.0, 18.0),
4264            span("15-003", 60.0, 680.0, 36.0),
4265            span("Object", 200.0, 680.0, 36.0),
4266            span("Machine", 400.0, 680.0, 42.0),
4267            span("15-004", 60.0, 666.0, 36.0),
4268            span("Rope", 400.0, 666.0, 24.0),
4269        ]);
4270
4271        assert_eq!(
4272            texts,
4273            vec!["Index Type How", "15-003 Object Machine", "15-004 Rope"]
4274        );
4275    }
4276
4277    /// A row that fills a single cell, one line under a wrapped one. Measured
4278    /// against the wrapped line it is a line's distance away; measured against
4279    /// the cell it would join -- which has no line under it yet, so the row
4280    /// itself -- it is two, and it is a row.
4281    #[test]
4282    fn a_single_cell_row_under_a_wrapped_line_is_still_a_row() {
4283        let texts = block_texts(vec![
4284            span("Index", 60.0, 700.0, 30.0),
4285            span("Type", 200.0, 700.0, 24.0),
4286            span("How", 400.0, 700.0, 18.0),
4287            span("15-003", 60.0, 680.0, 36.0),
4288            span("In a table not organized", 200.0, 680.0, 144.0),
4289            span("UA1:7.5-2", 400.0, 680.0, 54.0),
4290            span("a cell without a Scope", 200.0, 668.0, 132.0),
4291            span("15-004", 60.0, 656.0, 36.0),
4292        ]);
4293
4294        assert_eq!(
4295            texts,
4296            vec![
4297                "Index Type How",
4298                "15-003",
4299                "In a table not organized",
4300                "a cell without a Scope",
4301                "UA1:7.5-2",
4302                "15-004",
4303            ]
4304        );
4305    }
4306
4307    /// The same, with a row that fills a single cell: nothing but the pitch
4308    /// separates it from a wrapped line, and the pitch is what says it.
4309    #[test]
4310    fn a_line_a_whole_row_pitch_down_is_a_row_and_not_a_wrap() {
4311        let texts = block_texts(vec![
4312            span("Index", 60.0, 700.0, 30.0),
4313            span("Type", 200.0, 700.0, 24.0),
4314            span("How", 400.0, 700.0, 18.0),
4315            span("15-003", 60.0, 680.0, 36.0),
4316            span("Object", 200.0, 680.0, 36.0),
4317            span("Machine", 400.0, 680.0, 42.0),
4318            span("Rope", 200.0, 660.0, 24.0),
4319        ]);
4320
4321        assert_eq!(
4322            texts,
4323            vec!["Index Type How", "15-003 Object Machine", "Rope"]
4324        );
4325    }
4326
4327    #[test]
4328    fn three_column_regions_are_supported() {
4329        let texts = block_texts(vec![
4330            span("Column one line one", 40.0, 700.0, 105.0),
4331            span("Column two line one", 220.0, 700.0, 105.0),
4332            span("Column three line one", 400.0, 700.0, 120.0),
4333            span("Column one line two", 40.0, 684.0, 105.0),
4334            span("Column two line two", 220.0, 684.0, 105.0),
4335            span("Column three line two", 400.0, 684.0, 120.0),
4336            span("Column one line three", 40.0, 668.0, 120.0),
4337            span("Column two line three", 220.0, 668.0, 120.0),
4338            span("Column three line three", 400.0, 668.0, 135.0),
4339        ]);
4340
4341        assert_eq!(
4342            texts,
4343            vec![
4344                "Column one line one",
4345                "Column one line two",
4346                "Column one line three",
4347                "Column two line one",
4348                "Column two line two",
4349                "Column two line three",
4350                "Column three line one",
4351                "Column three line two",
4352                "Column three line three",
4353            ]
4354        );
4355    }
4356
4357    #[test]
4358    fn text_block_concatenation_spaced() {
4359        let block = TextBlock {
4360            spans: vec![span("A", 0.0, 0.0, 6.0), span("B", 20.0, 0.0, 6.0)],
4361        };
4362        assert_eq!(block.text(), "A B");
4363    }
4364
4365    #[test]
4366    fn adaptive_column_gap_fallback_for_no_gaps() {
4367        // Single-span bands produce no measurable gaps → fallback
4368        let bands = vec![
4369            TextBand::new(span("Hello", 40.0, 700.0, 80.0)),
4370            TextBand::new(span("World", 40.0, 684.0, 80.0)),
4371        ];
4372        let threshold = compute_adaptive_column_gap(&bands);
4373        assert!((threshold - COLUMN_GAP_THRESHOLD_FALLBACK).abs() < 0.01);
4374    }
4375
4376    #[test]
4377    fn adaptive_column_gap_uses_median() {
4378        // Three bands with word gaps of ~4pt each → median ≈ 4, threshold = 12
4379        let mut bands = Vec::new();
4380        for y in [700.0, 684.0, 668.0] {
4381            let mut band = TextBand::new(span("word1", 40.0, y, 30.0));
4382            band.spans.push(span("word2", 74.0, y, 30.0)); // gap = 4
4383            band.spans.push(span("word3", 108.0, y, 30.0)); // gap = 4
4384            bands.push(band);
4385        }
4386        let threshold = compute_adaptive_column_gap(&bands);
4387        // median gap = 4, × 3 = 12, clamped to [10, 40] → 12
4388        assert!(
4389            (10.0..=14.0).contains(&threshold),
4390            "expected ~12, got {threshold}"
4391        );
4392    }
4393
4394    #[test]
4395    fn adaptive_column_gap_clamps_to_min() {
4396        // Tight gaps (2pt) across many bands → median = 2, 3×2 = 6 → clamped to 10
4397        let mut bands = Vec::new();
4398        for y in [700.0, 684.0, 668.0, 652.0] {
4399            let mut band = TextBand::new(span("abc", 0.0, y, 18.0));
4400            // right of "abc" = max(18, 12*0.5*3=18) = 18; gap = 20-18 = 2
4401            band.spans.push(span("def", 20.0, y, 18.0));
4402            bands.push(band);
4403        }
4404        let threshold = compute_adaptive_column_gap(&bands);
4405        assert!(
4406            (threshold - COLUMN_GAP_THRESHOLD_MIN).abs() < 0.01,
4407            "expected {COLUMN_GAP_THRESHOLD_MIN}, got {threshold}"
4408        );
4409    }
4410
4411    #[test]
4412    fn adaptive_column_gap_all_large_gaps_uses_fraction_of_min() {
4413        // When all gaps are large (> MIN), threshold = 0.75 × min_gap.
4414        let mut band = TextBand::new(span("Left", 0.0, 700.0, 30.0));
4415        band.spans.push(span("Right", 80.0, 700.0, 30.0)); // gap = 50
4416        let bands = vec![band];
4417        let threshold = compute_adaptive_column_gap(&bands);
4418        assert!(
4419            (threshold - 37.5).abs() < 0.01,
4420            "expected 37.5 (0.75×50), got {threshold}"
4421        );
4422    }
4423
4424    #[test]
4425    fn normalize_trims_trailing_whitespace_per_line() {
4426        assert_eq!(
4427            normalize_text_output("hello   \nworld  \n"),
4428            "hello\nworld\n"
4429        );
4430    }
4431
4432    #[test]
4433    fn normalize_collapses_excess_newlines() {
4434        // >2 blank lines collapse to 2 (meaning 3 \n in a row: line, blank, blank)
4435        assert_eq!(
4436            normalize_text_output("hello\n\n\n\n\nworld\n"),
4437            "hello\n\n\nworld\n"
4438        );
4439    }
4440
4441    #[test]
4442    fn normalize_preserves_double_newline() {
4443        assert_eq!(
4444            normalize_text_output("paragraph one\n\nparagraph two\n"),
4445            "paragraph one\n\nparagraph two\n"
4446        );
4447    }
4448
4449    #[test]
4450    fn normalize_preserves_form_feed() {
4451        assert_eq!(
4452            normalize_text_output("page1\n\n\x0Cpage2\n"),
4453            "page1\n\n\x0Cpage2\n"
4454        );
4455    }
4456
4457    #[test]
4458    fn normalize_adds_trailing_newline() {
4459        assert_eq!(normalize_text_output("hello"), "hello\n");
4460    }
4461
4462    #[test]
4463    fn normalize_empty_input() {
4464        assert_eq!(normalize_text_output(""), "");
4465    }
4466
4467    #[test]
4468    fn normalize_only_whitespace() {
4469        assert_eq!(normalize_text_output("   \n  \n"), "");
4470    }
4471
4472    // --- Hyphen stitching tests ---
4473
4474    #[test]
4475    fn hyphen_stitch_joins_wrapped_word() {
4476        let lines = vec!["the aver-".into(), "age rainfall".into()];
4477        assert_eq!(stitch_hyphenated_lines(&lines), "the average rainfall");
4478    }
4479
4480    #[test]
4481    fn hyphen_stitch_handles_leading_whitespace() {
4482        let lines = vec!["pre-".into(), "   dict the outcome".into()];
4483        // "pre" is only 3 chars → meets >= 3 guard
4484        assert_eq!(stitch_hyphenated_lines(&lines), "predict the outcome");
4485    }
4486
4487    #[test]
4488    fn hyphen_stitch_capital_continuation_not_stitched() {
4489        let lines = vec!["Section three-".into(), "Summary here".into()];
4490        assert_eq!(
4491            stitch_hyphenated_lines(&lines),
4492            "Section three-\nSummary here"
4493        );
4494    }
4495
4496    #[test]
4497    fn hyphen_stitch_bullet_dash_not_stitched() {
4498        // "-" alone: char before hyphen is not alphabetic
4499        let lines = vec!["Items:".into(), "-".into(), "milk".into()];
4500        assert_eq!(stitch_hyphenated_lines(&lines), "Items:\n-\nmilk");
4501    }
4502
4503    #[test]
4504    fn hyphen_stitch_numeric_range_not_stitched() {
4505        // "42-" — char before hyphen is digit, not alphabetic
4506        let lines = vec!["page 42-".into(), "seventy".into()];
4507        assert_eq!(stitch_hyphenated_lines(&lines), "page 42-\nseventy");
4508    }
4509
4510    #[test]
4511    fn hyphen_stitch_short_prefix_not_stitched() {
4512        // "re-" only 2 alpha chars before hyphen → below 3-char guard
4513        let lines = vec!["re-".into(), "organize".into()];
4514        assert_eq!(stitch_hyphenated_lines(&lines), "re-\norganize");
4515    }
4516
4517    #[test]
4518    fn hyphen_stitch_short_continuation_not_stitched() {
4519        // Next line starts with "an" (2 chars) → below 3-char guard
4520        let lines = vec!["counter-".into(), "an example".into()];
4521        assert_eq!(stitch_hyphenated_lines(&lines), "counter-\nan example");
4522    }
4523
4524    #[test]
4525    fn hyphen_stitch_compound_word_midline_preserved() {
4526        // "real-time" is mid-line, not end-of-line — no stitching applies
4527        // because stitch only operates on line boundaries
4528        let lines = vec!["real-time system".into()];
4529        assert_eq!(stitch_hyphenated_lines(&lines), "real-time system");
4530    }
4531
4532    #[test]
4533    fn hyphen_stitch_single_line_unchanged() {
4534        let lines = vec!["only line".into()];
4535        assert_eq!(stitch_hyphenated_lines(&lines), "only line");
4536    }
4537
4538    #[test]
4539    fn hyphen_stitch_empty_input() {
4540        let lines: Vec<String> = vec![];
4541        assert_eq!(stitch_hyphenated_lines(&lines), "");
4542    }
4543
4544    // --- TEX1 multi-signal space consensus tests ---
4545
4546    fn make_device_with_median(median: f64) -> TextExtractionDevice {
4547        let mut dev = TextExtractionDevice::new();
4548        // Seed enough samples for the median to resolve to `median`.
4549        for _ in 0..MEDIAN_REFRESH {
4550            dev.glyph_widths.push(median);
4551        }
4552        dev.refresh_median_char_width();
4553        assert!((dev.cached_median_char_width - median).abs() < 1e-9);
4554        dev
4555    }
4556
4557    #[test]
4558    fn consensus_inserts_space_on_strong_tj_offset_alone() {
4559        // Gap is below the geometric threshold, but the TJ offset is large
4560        // enough that the consensus must still fire.
4561        let mut dev = make_device_with_median(6.0);
4562        dev.pending_tj_offset = 250.0; // full em-space
4563        assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
4564    }
4565
4566    #[test]
4567    fn consensus_inserts_space_on_geometric_gap_alone() {
4568        // No TJ, no character transition, but a clearly wide geometric gap.
4569        let dev = make_device_with_median(6.0);
4570        // gap > 0.3 * 6.0 = 1.8 → fires gap signal (0.80), below threshold
4571        // on its own? 0.80 < 0.75 threshold? No, 0.80 > 0.75, so it fires.
4572        assert!(dev.evaluate_space_consensus(2.5, 12.0, "hello", "world"));
4573    }
4574
4575    #[test]
4576    fn consensus_no_space_on_kerning_gap() {
4577        // Small kerning-size gap with no other signals must not inject a
4578        // space (regression guard against false-positive spaces inside
4579        // tightly kerned words).
4580        let dev = make_device_with_median(6.0);
4581        assert!(!dev.evaluate_space_consensus(0.5, 12.0, "fi", "lm"));
4582    }
4583
4584    #[test]
4585    fn consensus_inserts_space_on_camel_case_plus_gap() {
4586        // CamelCase heuristic (0.60) alone doesn't reach threshold, but a
4587        // moderate gap (0.60 gap + 0.60 heuristic if gap fires) should.
4588        // Here gap = 2.5 > 1.8 → gap fires → total 0.80 + 0.60 = 1.40.
4589        let dev = make_device_with_median(6.0);
4590        assert!(dev.evaluate_space_consensus(2.5, 12.0, "helloWorld", "Inc"));
4591    }
4592
4593    #[test]
4594    fn consensus_inserts_space_on_digit_letter_transition_with_gap() {
4595        let dev = make_device_with_median(6.0);
4596        assert!(dev.evaluate_space_consensus(2.5, 12.0, "123", "abc"));
4597    }
4598
4599    #[test]
4600    fn consensus_heuristic_alone_is_insufficient() {
4601        // Heuristic (0.60) on its own is below the 0.75 threshold — the
4602        // design deliberately requires a second corroborating signal to
4603        // avoid gluing spaces into existing CamelCase identifiers that
4604        // have no geometric break.
4605        let dev = make_device_with_median(6.0);
4606        assert!(!dev.evaluate_space_consensus(0.5, 12.0, "camel", "Case"));
4607    }
4608
4609    #[test]
4610    fn consensus_falls_back_to_font_size_when_no_median() {
4611        // No samples → median is 0; geometric reference uses font-size.
4612        let dev = TextExtractionDevice::new();
4613        // gap 1.9 > 0.15 * 12.0 = 1.8 → gap signal fires
4614        assert!(dev.evaluate_space_consensus(1.9, 12.0, "a", "b"));
4615        // gap 1.5 < 1.8 → no signal
4616        assert!(!dev.evaluate_space_consensus(1.5, 12.0, "a", "b"));
4617    }
4618
4619    #[test]
4620    fn consensus_ignores_tiny_tj_offsets() {
4621        // TJ offsets below the threshold are kerning, not word breaks.
4622        let mut dev = make_device_with_median(6.0);
4623        dev.pending_tj_offset = 50.0;
4624        assert!(!dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
4625    }
4626
4627    #[test]
4628    fn consensus_accepts_negative_tj_offsets() {
4629        // A negative TJ offset still represents an explicit inter-substring
4630        // shift and counts toward the consensus (|amount| check).
4631        let mut dev = make_device_with_median(6.0);
4632        dev.pending_tj_offset = -250.0;
4633        assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
4634    }
4635
4636    #[test]
4637    fn text_adjustment_accumulates_until_glyph() {
4638        let mut dev = TextExtractionDevice::new();
4639        dev.text_adjustment(120.0);
4640        dev.text_adjustment(140.0);
4641        assert!((dev.pending_tj_offset - 260.0).abs() < 1e-6);
4642    }
4643
4644    // --- TEX2 XY-Cut tests ---
4645
4646    #[test]
4647    fn xy_cut_header_body_footer_with_two_columns() {
4648        // Header and footer sit in the mid-x range that would
4649        // accidentally fall into a left-column bucket with a naive
4650        // vertical-first cut. The largest-gap-first rule plus the
4651        // alignment guard ensure header and footer bracket the
4652        // columnar body.
4653        let texts = block_texts(vec![
4654            span("HEADLINE TITLE", 180.0, 760.0, 120.0),
4655            span("Left col line A", 40.0, 700.0, 110.0),
4656            span("Right col line A", 320.0, 700.0, 115.0),
4657            span("Left col line B", 40.0, 684.0, 110.0),
4658            span("Right col line B", 320.0, 684.0, 115.0),
4659            span("Left col line C", 40.0, 668.0, 110.0),
4660            span("Right col line C", 320.0, 668.0, 115.0),
4661            span("FOOTER LINE TEXT", 180.0, 600.0, 120.0),
4662        ]);
4663        assert_eq!(texts.first().map(String::as_str), Some("HEADLINE TITLE"));
4664        assert_eq!(texts.last().map(String::as_str), Some("FOOTER LINE TEXT"));
4665        // Left column lines all come before right column lines.
4666        let left_c_idx = texts.iter().position(|s| s == "Left col line C").unwrap();
4667        let right_a_idx = texts.iter().position(|s| s == "Right col line A").unwrap();
4668        assert!(
4669            left_c_idx < right_a_idx,
4670            "expected column-major ordering in body: {texts:?}"
4671        );
4672    }
4673
4674    #[test]
4675    fn xy_cut_rejects_column_split_on_table_rows() {
4676        // The density guard must still reject the 280pt inter-cell gap
4677        // in a short-cell table, preserving row-major reading order.
4678        let texts = block_texts(vec![
4679            span("Name", 40.0, 700.0, 30.0),
4680            span("Age", 320.0, 700.0, 20.0),
4681            span("Alice", 40.0, 684.0, 35.0),
4682            span("30", 320.0, 684.0, 15.0),
4683        ]);
4684        assert_eq!(texts, vec!["Name Age", "Alice 30"]);
4685    }
4686
4687    #[test]
4688    fn xy_cut_rejects_column_split_when_one_band_is_full_width() {
4689        // The alignment guard catches a full-width paragraph that
4690        // would otherwise be forced into the left column of a 2-column
4691        // region below it.
4692        let texts = block_texts(vec![
4693            span(
4694                "Full width intro spanning both columns here",
4695                40.0,
4696                740.0,
4697                360.0,
4698            ),
4699            span("Left A", 40.0, 700.0, 50.0),
4700            span("Right A", 320.0, 700.0, 50.0),
4701            span("Left B", 40.0, 684.0, 50.0),
4702            span("Right B", 320.0, 684.0, 50.0),
4703        ]);
4704        assert!(
4705            texts[0].contains("Full width intro"),
4706            "expected full-width intro first: {texts:?}"
4707        );
4708    }
4709
4710    #[test]
4711    fn xy_cut_horizontal_split_for_zone_boundaries() {
4712        // Pure horizontal cut on a single-column page with a big
4713        // vertical gap between paragraphs — the cut fires and both
4714        // paragraphs stay in their own blocks.
4715        let texts = block_texts(vec![
4716            span("First paragraph body text", 40.0, 740.0, 200.0),
4717            span("Second paragraph body", 40.0, 680.0, 180.0),
4718        ]);
4719        assert_eq!(texts.len(), 2);
4720        assert!(texts[0].starts_with("First"));
4721        assert!(texts[1].starts_with("Second"));
4722    }
4723
4724    #[test]
4725    fn xy_cut_recursion_terminates_with_single_span() {
4726        let texts = block_texts(vec![span("Only one span on the page", 40.0, 700.0, 180.0)]);
4727        assert_eq!(texts, vec!["Only one span on the page"]);
4728    }
4729
4730    #[test]
4731    fn median_font_size_handles_mixed_sizes() {
4732        let spans = vec![
4733            TextSpan {
4734                text: "small".into(),
4735                width: 10.0,
4736                height: 8.0,
4737                font_size: 8.0,
4738                ..TextSpan::default()
4739            },
4740            TextSpan {
4741                text: "medium".into(),
4742                width: 10.0,
4743                height: 12.0,
4744                font_size: 12.0,
4745                ..TextSpan::default()
4746            },
4747            TextSpan {
4748                text: "large".into(),
4749                width: 10.0,
4750                height: 24.0,
4751                font_size: 24.0,
4752                ..TextSpan::default()
4753            },
4754        ];
4755        assert!((median_font_size(&spans) - 12.0).abs() < 1e-9);
4756    }
4757
4758    #[test]
4759    fn columns_band_aligned_accepts_aligned_columns() {
4760        let spans = vec![
4761            span("L1", 40.0, 700.0, 60.0),
4762            span("R1", 300.0, 700.0, 60.0),
4763            span("L2", 40.0, 684.0, 60.0),
4764            span("R2", 300.0, 684.0, 60.0),
4765        ];
4766        let stats = PageStats::from_spans(&spans);
4767        // cut_x between 100 and 300 → 200. Every band straddles the cut.
4768        assert!(columns_are_band_aligned(&spans, 200.0, 40.0, 360.0, &stats));
4769    }
4770
4771    #[test]
4772    fn columns_band_aligned_rejects_wide_single_side_band() {
4773        let spans = vec![
4774            span("Wide banner line across top", 40.0, 740.0, 280.0),
4775            span("L1", 40.0, 700.0, 60.0),
4776            span("R1", 300.0, 700.0, 60.0),
4777        ];
4778        let stats = PageStats::from_spans(&spans);
4779        // cut_x = 200. Banner only in left group (midpoint < 200). Width
4780        // exceeds 0.7 × left column width → rejected.
4781        assert!(!columns_are_band_aligned(
4782            &spans, 200.0, 40.0, 360.0, &stats
4783        ));
4784    }
4785
4786    #[test]
4787    fn page_stats_computes_median_values() {
4788        let spans = vec![
4789            span("one", 40.0, 700.0, 30.0),
4790            span("two", 40.0, 680.0, 30.0),
4791            span("three", 40.0, 660.0, 50.0),
4792        ];
4793        let stats = PageStats::from_spans(&spans);
4794        assert!((stats.median_font_size - 12.0).abs() < 1e-9);
4795        // char width = width / chars. one=30/3=10, two=30/3=10, three=50/5=10. median=10.
4796        assert!((stats.median_char_width - 10.0).abs() < 1e-9);
4797        // line spacing: bands at 700, 680, 660. gaps = 20, 20. median = 20.
4798        assert!((stats.median_line_spacing - 20.0).abs() < 1e-9);
4799    }
4800
4801    #[test]
4802    fn page_stats_handles_empty_input() {
4803        let stats = PageStats::from_spans(&[]);
4804        assert!((stats.median_font_size - 12.0).abs() < 1e-9);
4805        assert!((stats.median_char_width - 6.0).abs() < 1e-9);
4806        assert_eq!(stats.median_line_spacing, 0.0);
4807    }
4808
4809    #[test]
4810    fn narrow_gutter_detected_with_adaptive_threshold() {
4811        // Academic paper layout: 12pt gutter between columns.
4812        // With old fixed 20pt threshold, this was not detected as columnar.
4813        // With adaptive: median word gap ~4pt, threshold = 12pt → detects 12pt gutter.
4814        let mut spans = Vec::new();
4815        for y in [700.0, 684.0, 668.0] {
4816            // Left column: two words with 4pt gap, ending at x=145
4817            spans.push(span("Lorem ipsum", 40.0, y, 100.0));
4818            spans.push(span("dolor sit", 144.0, y, 80.0));
4819            // Right column starts at 236 (gap = 12pt from 224)
4820            spans.push(span("amet consec", 236.0, y, 100.0));
4821            spans.push(span("tetur adipi", 340.0, y, 80.0));
4822        }
4823        let texts = block_texts(spans);
4824        // Should detect 2-column layout and read column-major
4825        assert!(
4826            texts.len() >= 6,
4827            "expected column-major output, got {texts:?}"
4828        );
4829        // First three blocks should be left column lines
4830        assert!(
4831            texts[0].contains("Lorem"),
4832            "first block should be left column: {texts:?}"
4833        );
4834    }
4835
4836    #[test]
4837    fn xy_cut_leaf_falls_back_to_legacy_columns_for_header_plus_three_columns() {
4838        let texts = block_texts(vec![
4839            span("73022", 45.0, 750.0, 70.0),
4840            span("Federal Register banner", 125.6, 750.0, 260.0),
4841            span("Left column line one", 45.0, 725.0, 140.0),
4842            span("Middle column line one", 222.0, 725.0, 140.0),
4843            span("Right column line one", 399.0, 725.0, 120.0),
4844            span("Left column line two", 45.0, 715.0, 140.0),
4845            span("Middle column line two", 210.0, 715.0, 152.0),
4846            span("Right column line two", 388.0, 715.0, 132.0),
4847            span("Left column line three", 45.0, 705.0, 140.0),
4848            span("Middle column line three", 235.0, 705.0, 135.0),
4849            span("Right column line three", 408.0, 705.0, 118.0),
4850        ]);
4851
4852        assert_eq!(
4853            texts,
4854            vec![
4855                "73022 Federal Register banner",
4856                "Left column line one",
4857                "Left column line two",
4858                "Left column line three",
4859                "Middle column line one",
4860                "Middle column line two",
4861                "Middle column line three",
4862                "Right column line one",
4863                "Right column line two",
4864                "Right column line three",
4865            ]
4866        );
4867    }
4868
4869    #[test]
4870    fn overlapping_fake_bold_spans_collapse_to_single_copy() {
4871        let texts = block_texts(vec![
4872            span("1 This is fakebold text.", 25.9, 785.3, 320.0),
4873            span("1 This is fakebold text.", 26.2, 785.3, 320.0),
4874            span("1 This is fakebold text.", 26.4, 785.3, 320.0),
4875            span("1 This is fakebold text.", 26.7, 785.3, 320.0),
4876            span("2 This is a fakebold", 27.0, 714.8, 142.0),
4877            span(" fakebold", 169.8, 714.8, 70.0),
4878            span(" fakebold", 170.1, 714.8, 70.0),
4879            span(" fakebold word.", 170.4, 714.8, 110.0),
4880        ]);
4881
4882        assert_eq!(
4883            texts,
4884            vec!["1 This is fakebold text.", "2 This is a fakebold word.",]
4885        );
4886    }
4887
4888    /// A span drawn at `degrees`, with the transform a text matrix at that
4889    /// angle composes and the origin the glyphs start from.
4890    fn turned_span(text: &str, degrees: f64, x: f64, y: f64, width: f64) -> TextSpan {
4891        let (sin, cos) = degrees.to_radians().sin_cos();
4892        let scale = 12.0 / 1000.0;
4893        TextSpan {
4894            transform: Some([cos * scale, sin * scale, -sin * scale, cos * scale, x, y]),
4895            ..span(text, x, y, width)
4896        }
4897    }
4898
4899    /// Two lines of one rotated block share the place the page's order held
4900    /// them, and they must come back in the order they are read in. Each is
4901    /// inserted at the same index, and an insertion there pushes the last one
4902    /// down -- so inserting them front to back would emit the block backwards
4903    /// (#435, review round 2).
4904    #[test]
4905    fn two_lines_at_one_place_come_back_in_reading_order() {
4906        // A 90-degree run reads up the page, and its next line is a leading
4907        // further along +x: text space moves down, and down is +x once the
4908        // matrix has turned a quarter turn.
4909        let mut spans = vec![span("the page itself", 100.0, 700.0, 80.0)];
4910        for (index, text) in ["first", "second"].iter().enumerate() {
4911            let x = 60.0 + 14.0 * index as f64;
4912            spans.push(turned_span(text, 90.0, x, 200.0, 30.0));
4913        }
4914        let blocks = vec![TextBlock { spans }];
4915
4916        let out = reassemble_cross_direction_runs(blocks);
4917
4918        let lines: Vec<String> = out.iter().map(|block| block.text()).collect();
4919        assert_eq!(
4920            lines,
4921            vec!["first", "second", "the page itself"],
4922            "a rotated block came back with its lines the wrong way round"
4923        );
4924    }
4925
4926    /// The page's direction is weighed in glyphs, and a span holds more
4927    /// characters than glyphs whenever a glyph decodes to several of them or
4928    /// the space consensus put a space between two -- neither of which was
4929    /// drawn. Counting characters lets a minority direction outvote the page's
4930    /// own (#435, review round 2).
4931    #[test]
4932    fn the_pages_direction_is_weighed_in_glyphs_and_not_characters() {
4933        // Eight characters drawn as two glyphs: a pair of ligatures.
4934        let mut upright = span("fifffifl", 72.0, 700.0, 24.0);
4935        upright.char_bounds = vec![[0.0; 4]; 2];
4936        // Five characters drawn as five glyphs.
4937        let mut turned = turned_span("TURNS", 90.0, 300.0, 300.0, 30.0);
4938        turned.char_bounds = vec![[0.0; 4]; 5];
4939
4940        let direction = page_direction(&[TextBlock {
4941            spans: vec![upright, turned],
4942        }])
4943        .expect("a direction");
4944
4945        assert!(
4946            (direction.1).abs() > 0.5,
4947            "two ligature glyphs outvoted five drawn ones, so the count is of characters"
4948        );
4949    }
4950
4951    /// A run drawn at an angle stops where its last glyph stops, and that is
4952    /// further along its baseline than its page-space width says: a 30-degree
4953    /// run advances `cos 30` of its length in x. Measuring the gap to the next
4954    /// span from `x + width` leaves an eighth of the run as phantom gap, which
4955    /// at a style boundary in the middle of a word puts a space there (#435,
4956    /// review round 1).
4957    #[test]
4958    fn a_turned_run_ends_where_its_last_glyph_ends() {
4959        const DEGREES: f64 = 30.0;
4960        const ADVANCE: f64 = 7.2; // Courier at 12 pt
4961        let (sin, cos) = DEGREES.to_radians().sin_cos();
4962
4963        // "HELLO", five glyphs from (100, 400) along the 30-degree baseline,
4964        // as `draw_glyph` accumulates them: page-space bounds per glyph, and a
4965        // width that is the widest reach in page x.
4966        let mut hello = turned_span("HELLO", DEGREES, 100.0, 400.0, 0.0);
4967        for index in 0..5 {
4968            let x = 100.0 + cos * ADVANCE * index as f64;
4969            let y = 400.0 + sin * ADVANCE * index as f64;
4970            hello.char_bounds.push([x, y, x + ADVANCE, y + 12.0]);
4971            hello.width = hello.width.max(x + ADVANCE - 100.0);
4972        }
4973        // "WORLD" begins exactly where the fifth glyph's advance ended.
4974        let world = turned_span(
4975            "WORLD",
4976            DEGREES,
4977            100.0 + cos * ADVANCE * 5.0,
4978            400.0 + sin * ADVANCE * 5.0,
4979            ADVANCE * 5.0,
4980        );
4981
4982        let block = TextBlock {
4983            spans: vec![hello, world],
4984        };
4985        assert_eq!(
4986            block.text(),
4987            "HELLOWORLD",
4988            "a space was invented at a seam the document draws closed"
4989        );
4990    }
4991
4992    /// A page written entirely at an angle is a page of upright text seen
4993    /// turned, and its own bands read it correctly. Nothing is taken out of
4994    /// them: the direction most of the glyphs are drawn in IS the page's,
4995    /// whatever that direction is (#435).
4996    #[test]
4997    fn a_page_written_entirely_at_an_angle_keeps_its_own_bands() {
4998        let blocks = vec![
4999            TextBlock {
5000                spans: vec![
5001                    turned_span("first line", 90.0, 100.0, 100.0, 60.0),
5002                    turned_span("carried on", 90.0, 100.0, 160.0, 60.0),
5003                ],
5004            },
5005            TextBlock {
5006                spans: vec![turned_span("second line", 90.0, 120.0, 100.0, 66.0)],
5007            },
5008        ];
5009
5010        let out = reassemble_cross_direction_runs(blocks.clone());
5011
5012        let shape: Vec<Vec<&str>> = out
5013            .iter()
5014            .map(|b| b.spans.iter().map(|s| s.text.as_str()).collect())
5015            .collect();
5016        assert_eq!(
5017            shape,
5018            vec![vec!["first line", "carried on"], vec!["second line"]],
5019            "a page in one direction was rearranged by a rule that is about two"
5020        );
5021    }
5022
5023    /// The page's direction is the one most of its GLYPHS are drawn in, and a
5024    /// tie goes to the direction that was drawn first -- a rule the answer to
5025    /// which must not depend on which way an iterator happens to walk (#435).
5026    #[test]
5027    fn the_pages_direction_is_the_heaviest_and_a_tie_goes_to_the_first() {
5028        let upright = TextBlock {
5029            spans: vec![span("eight ch", 0.0, 700.0, 48.0)],
5030        };
5031        let turned = TextBlock {
5032            spans: vec![turned_span("ninety deg", 90.0, 300.0, 300.0, 60.0)],
5033        };
5034
5035        let heavier = page_direction(&[upright.clone(), turned.clone()]).expect("a direction");
5036        assert!(
5037            (heavier.1).abs() > 0.5,
5038            "ten glyphs at ninety degrees outweigh eight upright ones, and did not"
5039        );
5040
5041        let tie = TextBlock {
5042            spans: vec![span("ninety deg", 0.0, 700.0, 60.0)],
5043        };
5044        let first = page_direction(&[tie, turned]).expect("a direction");
5045        assert!(
5046            (first.1).abs() < 0.5,
5047            "a tie went to the direction that was not drawn first"
5048        );
5049    }
5050
5051    /// A page whose every line runs up it is read in the frame it was written
5052    /// in. Its bands lie across its lines, so without the turn the two lines
5053    /// share one band and come back as one (#546).
5054    #[test]
5055    fn a_turned_page_is_read_in_the_frame_it_was_written_in() {
5056        // A quarter-turned page reads up the page, and its next line is a
5057        // leading further along +x: text space moves down, and down is +x once
5058        // the matrix has turned a quarter turn.
5059        let spans: Vec<TextSpan> = ["the first line", "the second line"]
5060            .iter()
5061            .enumerate()
5062            .map(|(index, text)| turned_span(text, 90.0, 100.0 + 16.0 * index as f64, 100.0, 80.0))
5063            .collect();
5064
5065        let lines = block_texts(spans);
5066
5067        assert_eq!(
5068            lines,
5069            vec!["the first line", "the second line"],
5070            "the lines of a turned page were read in the bands of the frame it is drawn in"
5071        );
5072    }
5073
5074    /// The turn into the page's own frame and the turn back move no coordinate
5075    /// by a single bit: a quarter turn's sine and cosine are 0 and ±1, so what
5076    /// a caller reads off a span of a turned page is the page's own number and
5077    /// not a rounding of it (#546).
5078    #[test]
5079    fn the_turn_into_the_frame_and_back_leaves_every_number_as_it_was() {
5080        // Courier at 12 pt, and a run of six glyphs up the page.
5081        const ADVANCE: f64 = 7.2;
5082        const X: f64 = 123.75;
5083        const Y: f64 = 456.5;
5084        // The width is what `draw_glyph` accumulates: the furthest a glyph
5085        // reaches in page x, which for a quarter turn is one advance however
5086        // long the run is. Written as that subtraction and not as the advance,
5087        // because `x + advance - x` is not `advance` in binary and the number
5088        // the caller gets back is the number the device measured.
5089        let mut original = turned_span("TURNED", 90.0, X, Y, (X + ADVANCE) - X);
5090        for index in 0..6 {
5091            let y = Y + ADVANCE * index as f64;
5092            original.char_bounds.push([X, y, X + ADVANCE, y + 12.0]);
5093        }
5094
5095        for turn in [PageTurn::Quarter, PageTurn::Half, PageTurn::ThreeQuarter] {
5096            let spans = turn.into_frame(vec![original.clone()]);
5097            let blocks = turn.back(vec![TextBlock { spans }]);
5098            let span = &blocks[0].spans[0];
5099
5100            assert_eq!(
5101                (span.x, span.y),
5102                (original.x, original.y),
5103                "{turn:?} moved the span's origin"
5104            );
5105            assert_eq!(
5106                span.transform, original.transform,
5107                "{turn:?} left the baseline turned"
5108            );
5109            assert_eq!(
5110                span.char_bounds, original.char_bounds,
5111                "{turn:?} moved a glyph bound"
5112            );
5113            assert_eq!(
5114                span.width, original.width,
5115                "{turn:?} left a frame width behind"
5116            );
5117        }
5118    }
5119
5120    /// Only a page written in a quarter turn is turned: an upright one, a page
5121    /// drawn entirely at a free angle, and a page whose turned text is the
5122    /// minority all keep the bands they have (#546).
5123    #[test]
5124    fn a_page_is_turned_by_a_quarter_turn_or_by_nothing() {
5125        let cases = [
5126            (0.0, PageTurn::None),
5127            (90.0, PageTurn::Quarter),
5128            (180.0, PageTurn::Half),
5129            (270.0, PageTurn::ThreeQuarter),
5130            (45.0, PageTurn::None),
5131            (-52.0, PageTurn::None),
5132        ];
5133        for (degrees, expected) in cases {
5134            let spans = vec![turned_span("a line of it", degrees, 100.0, 100.0, 72.0)];
5135            assert_eq!(
5136                PageTurn::of(&spans),
5137                expected,
5138                "a page written at {degrees} degrees"
5139            );
5140        }
5141
5142        // The watermark of #435: turned, and outnumbered by the page it is on.
5143        let page = vec![
5144            span(
5145                "the body of the page is written upright",
5146                72.0,
5147                700.0,
5148                230.0,
5149            ),
5150            turned_span("SANDBOX", 90.0, 300.0, 200.0, 42.0),
5151        ];
5152        assert_eq!(
5153            PageTurn::of(&page),
5154            PageTurn::None,
5155            "a turned watermark turned the page it is drawn on"
5156        );
5157    }
5158
5159    /// A half-turned page keeps the ruling lines the page drew, because a half
5160    /// turn maps a horizontal line to a horizontal line; a quarter-turned page
5161    /// keeps none, because what the device recorded there runs the other way
5162    /// across the page (#546, review round 2).
5163    #[test]
5164    fn only_a_half_turn_keeps_the_pages_ruling_lines() {
5165        let rule = HorizontalRule {
5166            left: 72.0,
5167            right: 540.0,
5168            y: 400.0,
5169        };
5170
5171        let drawn = [rule];
5172        let upright = PageTurn::None.rules_in_frame(&drawn);
5173        assert_eq!(
5174            (
5175                upright.len(),
5176                upright[0].left,
5177                upright[0].right,
5178                upright[0].y
5179            ),
5180            (1, rule.left, rule.right, rule.y),
5181            "an upright page's rules were touched at all"
5182        );
5183
5184        let turned = PageTurn::Half.rules_in_frame(&drawn);
5185        let rotation = PageTurn::Half.rotation();
5186        let (left, y) = turn_point(rule.right, rule.y, rotation);
5187        let (right, _) = turn_point(rule.left, rule.y, rotation);
5188        assert_eq!(
5189            (turned.len(), turned[0].left, turned[0].right, turned[0].y),
5190            (1, left, right, y),
5191            "a half turn lost the page's rules, or left them where they were drawn"
5192        );
5193        assert!(
5194            turned[0].left < turned[0].right,
5195            "the ends of a mirrored rule did not swap, so it reads as empty"
5196        );
5197
5198        for turn in [PageTurn::Quarter, PageTurn::ThreeQuarter] {
5199            assert!(
5200                turn.rules_in_frame(&drawn).is_empty(),
5201                "{turn:?} kept a rule that runs across its reading direction"
5202            );
5203        }
5204    }
5205
5206    /// A page has to be WRITTEN in a direction before it is read in it: with
5207    /// its glyphs split four ways, as pdfbox `AcroFormsRotation` splits them
5208    /// over four blocks of form fields at the four rotations, the heaviest
5209    /// direction is a quarter of the page, and turning by it moves every block
5210    /// on a page that was never turned at all (#546).
5211    #[test]
5212    fn a_page_with_no_majority_direction_is_not_turned() {
5213        let quarter_each = vec![
5214            span("rotation zero", 72.0, 700.0, 78.0),
5215            turned_span("rotation ninety", 90.0, 200.0, 200.0, 90.0),
5216            turned_span("rotation oneeighty", 180.0, 400.0, 400.0, 108.0),
5217            turned_span("rotation twoseventy", 270.0, 500.0, 600.0, 114.0),
5218        ];
5219        assert_eq!(
5220            PageTurn::of(&quarter_each),
5221            PageTurn::None,
5222            "a plurality of a quarter of the glyphs turned the whole page"
5223        );
5224
5225        // The same page with the turned block carrying most of the glyphs is
5226        // a page written in that direction, and is read in it.
5227        let mut majority = quarter_each;
5228        majority[1] = turned_span(
5229            "a line of ninety degree text that is most of this page by a street",
5230            90.0,
5231            200.0,
5232            200.0,
5233            400.0,
5234        );
5235        assert_eq!(
5236            PageTurn::of(&majority),
5237            PageTurn::Quarter,
5238            "a direction carrying most of the page did not turn it"
5239        );
5240    }
5241
5242    /// A span drawn by a horizontal reflection, `[-1, 0, 0, 1, e, f]`. Its
5243    /// baseline runs in `(-1, 0)`, which is where a half turn puts one too --
5244    /// and its vertical axis is upright, which is where a half turn does not.
5245    fn mirrored_span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
5246        let scale = 12.0 / 1000.0;
5247        TextSpan {
5248            transform: Some([-scale, 0.0, 0.0, scale, x, y]),
5249            ..span(text, x, y, width)
5250        }
5251    }
5252
5253    /// A page drawn mirrored is not a page drawn upside down, and the baseline
5254    /// vector cannot tell them apart: both run in `(-1, 0)`. Turning it half a
5255    /// turn negates the vertical axis as well, so a block whose lines descend
5256    /// the page is read bottom to top -- an order invented from a page that was
5257    /// never turned. The determinant separates the two. (codex, review round 2)
5258    #[test]
5259    fn a_mirrored_page_is_not_a_half_turned_page() {
5260        let mirrored = vec![
5261            mirrored_span("the first line of a mirrored page", 400.0, 700.0, 300.0),
5262            mirrored_span("and the second line below it", 400.0, 686.0, 260.0),
5263        ];
5264        assert_eq!(
5265            PageTurn::of(&mirrored),
5266            PageTurn::None,
5267            "a reflection was read as a half turn, and turning it reverses the page"
5268        );
5269
5270        // The control, so the assertion above cannot pass by refusing every
5271        // page: the same two lines actually drawn upside down ARE a half turn.
5272        let half_turned: Vec<TextSpan> = vec![
5273            turned_span(
5274                "the first line of a turned page",
5275                180.0,
5276                400.0,
5277                700.0,
5278                300.0,
5279            ),
5280            turned_span("and the second line below it", 180.0, 400.0, 686.0, 260.0),
5281        ];
5282        assert_eq!(
5283            PageTurn::of(&half_turned),
5284            PageTurn::Half,
5285            "a page genuinely drawn at 180 degrees is no longer turned into its frame"
5286        );
5287
5288        // And a page that is mostly mirrored with an upright minority stays
5289        // untouched rather than being read in the minority's direction: the
5290        // share is of the whole page, not of the rotations on it.
5291        let mut mostly_mirrored = mirrored;
5292        mostly_mirrored.push(span("a short upright caption", 72.0, 100.0, 40.0));
5293        assert_eq!(
5294            PageTurn::of(&mostly_mirrored),
5295            PageTurn::None,
5296            "an upright minority carried the vote on a mirrored page"
5297        );
5298    }
5299
5300    /// The seam allowance of `second_run_carries_on_from_the_first` is one
5301    /// GLYPH of mean advance, and a glyph may decode to more than one
5302    /// character. Six characters drawn as five glyphs -- `office` with a `fi`
5303    /// ligature -- have a 6 pt allowance, where dividing the same advance by
5304    /// the six characters would leave 5 pt and reject a 5.5 pt seam that a
5305    /// re-drawn word arrives with (#432, review round 2).
5306    #[test]
5307    fn the_seam_allowance_counts_glyphs_and_not_characters() {
5308        let prev = span("first", 0.0, 700.0, 30.0);
5309        let mut curr = span("office", 24.5, 700.0, 30.0);
5310        curr.char_bounds = vec![[0.0; 4]; 5];
5311
5312        assert!(
5313            second_run_carries_on_from_the_first(&prev, &curr),
5314            "a ligature shrank the seam allowance to the character count"
5315        );
5316
5317        curr.char_bounds.clear();
5318        assert!(
5319            !second_run_carries_on_from_the_first(&prev, &curr),
5320            "the character count is the fallback, so it has to be the stricter one here"
5321        );
5322    }
5323
5324    // ---- G1: read-only metadata field tests ----
5325
5326    #[test]
5327    fn g1_default_text_span_has_empty_metadata() {
5328        let s = TextSpan::default();
5329        assert_eq!(s.font_name, None);
5330        assert!(!s.is_bold);
5331        assert!(!s.is_italic);
5332        assert_eq!(s.color, None);
5333    }
5334
5335    #[test]
5336    fn g1_strip_subset_prefix_handles_six_char_prefix() {
5337        assert_eq!(strip_subset_prefix("AAAAAA+Helvetica"), "Helvetica");
5338        // Non-6-char prefix → keep verbatim.
5339        assert_eq!(strip_subset_prefix("ABC+Helvetica"), "ABC+Helvetica");
5340        // No `+` → unchanged.
5341        assert_eq!(strip_subset_prefix("Helvetica-Bold"), "Helvetica-Bold");
5342    }
5343
5344    #[test]
5345    fn g1_name_style_hints_match_pdf_interpret_rules() {
5346        assert_eq!(name_style_hints("Helvetica-Bold"), (true, false));
5347        assert_eq!(name_style_hints("Times-Italic"), (false, true));
5348        assert_eq!(name_style_hints("MyFont-BoldOblique"), (true, true));
5349        assert_eq!(name_style_hints("Helvetica"), (false, false));
5350        // Semibold / Demi / Heavy / Black variants → bold.
5351        assert_eq!(name_style_hints("Roboto-DemiBold"), (true, false));
5352        assert_eq!(name_style_hints("Roboto-Black"), (true, false));
5353        // Oblique / slant variants → italic.
5354        assert_eq!(name_style_hints("Roboto-Oblique"), (false, true));
5355        assert_eq!(name_style_hints("MyFont-Slanted"), (false, true));
5356    }
5357
5358    // ---- G2: widthSource + char_bounds on TextSpan ----
5359
5360    #[test]
5361    fn g2_default_text_span_has_estimate_width_source() {
5362        let s = TextSpan::default();
5363        assert_eq!(s.width_source, WidthSource::Estimate);
5364        assert!(s.char_bounds.is_empty());
5365    }
5366
5367    /// Verify that a single-glyph span has exactly one char_bound entry
5368    /// and that the bound matches the span's x / width.
5369    #[test]
5370    fn g2_single_glyph_span_has_one_char_bound() {
5371        let s = TextSpan {
5372            text: "A".into(),
5373            x: 10.0,
5374            y: 100.0,
5375            width: 7.22,
5376            height: 10.0,
5377            font_size: 10.0,
5378            width_source: WidthSource::Metric,
5379            char_bounds: vec![[10.0, 100.0, 17.22, 110.0]],
5380            ..Default::default()
5381        };
5382
5383        assert_eq!(s.char_bounds.len(), 1);
5384        let [x0, y0, x1, y1] = s.char_bounds[0];
5385        assert!((x0 - 10.0).abs() < 0.001);
5386        assert!((x1 - 17.22).abs() < 0.001);
5387        assert!((y1 - y0 - s.font_size).abs() < 0.001);
5388    }
5389
5390    /// When merging glyphs into a span the width_source degrades to Estimate
5391    /// if any glyph was estimated.
5392    #[test]
5393    fn g2_merged_span_degrades_width_source_on_estimate() {
5394        let mut s = TextSpan {
5395            width_source: WidthSource::Metric,
5396            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
5397            ..Default::default()
5398        };
5399
5400        // Simulate what draw_glyph does on merge: push bound + downgrade.
5401        s.char_bounds.push([7.0, 0.0, 12.0, 10.0]);
5402        s.width_source = WidthSource::Estimate; // second glyph had no advance
5403
5404        assert_eq!(s.width_source, WidthSource::Estimate);
5405        assert_eq!(s.char_bounds.len(), 2);
5406    }
5407
5408    /// Verify WidthSource enum serialises to the two expected string literals.
5409    #[test]
5410    fn g2_width_source_variants_are_correct() {
5411        assert_eq!(format!("{:?}", WidthSource::Metric), "Metric");
5412        assert_eq!(format!("{:?}", WidthSource::Estimate), "Estimate");
5413        assert_ne!(WidthSource::Metric, WidthSource::Estimate);
5414        assert_eq!(WidthSource::default(), WidthSource::Estimate);
5415    }
5416
5417    // ---- M3: RichGeometry / tight glyph bounds tests ----
5418
5419    #[test]
5420    fn m3_default_text_span_has_basic_mode() {
5421        let s = TextSpan::default();
5422        assert_eq!(s.geometry_mode, GeometryMode::Basic);
5423        assert_eq!(s.bounds_source, BoundsSource::Advance);
5424        assert!(s.tight_char_bounds.is_empty());
5425        assert!(s.glyph_advances.is_empty());
5426    }
5427
5428    #[test]
5429    fn m3_geometry_mode_default_is_basic() {
5430        assert_eq!(GeometryMode::default(), GeometryMode::Basic);
5431    }
5432
5433    #[test]
5434    fn m3_bounds_source_default_is_advance() {
5435        assert_eq!(BoundsSource::default(), BoundsSource::Advance);
5436    }
5437
5438    #[test]
5439    fn m3_geometry_mode_variants_are_distinct() {
5440        assert_ne!(GeometryMode::Basic, GeometryMode::RichGeometry);
5441        assert_eq!(format!("{:?}", GeometryMode::Basic), "Basic");
5442        assert_eq!(format!("{:?}", GeometryMode::RichGeometry), "RichGeometry");
5443    }
5444
5445    #[test]
5446    fn m3_bounds_source_variants_are_distinct() {
5447        assert_ne!(BoundsSource::Advance, BoundsSource::Tight);
5448        assert_ne!(BoundsSource::Tight, BoundsSource::Estimate);
5449        assert_eq!(format!("{:?}", BoundsSource::Advance), "Advance");
5450        assert_eq!(format!("{:?}", BoundsSource::Tight), "Tight");
5451        assert_eq!(format!("{:?}", BoundsSource::Estimate), "Estimate");
5452    }
5453
5454    #[test]
5455    fn m3_text_span_rich_geometry_preserves_existing_fields() {
5456        let s = TextSpan {
5457            text: "Hello".into(),
5458            x: 10.0,
5459            y: 100.0,
5460            width: 30.0,
5461            height: 12.0,
5462            font_size: 12.0,
5463            font_name: Some("Helvetica".into()),
5464            is_bold: true,
5465            is_italic: false,
5466            color: Some([0, 0, 0, 255]),
5467            width_source: WidthSource::Metric,
5468            char_bounds: vec![[10.0, 100.0, 40.0, 112.0]],
5469            geometry_mode: GeometryMode::RichGeometry,
5470            bounds_source: BoundsSource::Tight,
5471            tight_char_bounds: vec![[8.5, 98.5, 41.5, 114.0]],
5472            glyph_advances: vec![30.0],
5473            glyph_bounds_sources: vec![BoundsSource::Tight],
5474            ..Default::default()
5475        };
5476
5477        // Existing fields preserved.
5478        assert_eq!(s.text, "Hello");
5479        assert!((s.x - 10.0).abs() < 0.001);
5480        assert!((s.y - 100.0).abs() < 0.001);
5481        assert_eq!(s.width_source, WidthSource::Metric);
5482        assert_eq!(s.char_bounds.len(), 1);
5483
5484        // M3 fields populated.
5485        assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
5486        assert_eq!(s.bounds_source, BoundsSource::Tight);
5487        assert_eq!(s.tight_char_bounds.len(), 1);
5488        assert_eq!(s.glyph_advances.len(), 1);
5489        assert!((s.glyph_advances[0] - 30.0).abs() < 0.001);
5490    }
5491
5492    #[test]
5493    fn m3_text_span_basic_mode_does_not_populate_tight_fields() {
5494        let s = TextSpan {
5495            text: "A".into(),
5496            x: 0.0,
5497            y: 0.0,
5498            width: 7.0,
5499            height: 10.0,
5500            font_size: 10.0,
5501            geometry_mode: GeometryMode::Basic,
5502            bounds_source: BoundsSource::Advance,
5503            ..Default::default()
5504        };
5505
5506        assert!(s.tight_char_bounds.is_empty());
5507        assert!(s.glyph_advances.is_empty());
5508    }
5509
5510    #[test]
5511    fn m3_tight_char_bounds_are_optional_in_basic_mode() {
5512        // A span from Basic mode should have empty tight fields.
5513        let s = TextSpan {
5514            text: "test".into(),
5515            x: 0.0,
5516            y: 0.0,
5517            width: 40.0,
5518            height: 12.0,
5519            font_size: 12.0,
5520            geometry_mode: GeometryMode::Basic,
5521            ..Default::default()
5522        };
5523        assert!(s.tight_char_bounds.is_empty());
5524        assert!(s.glyph_advances.is_empty());
5525    }
5526
5527    #[test]
5528    fn m3_test_span_constructor_preserves_all_fields() {
5529        // Verify the span constructor used in existing tests still works.
5530        let s = span("hello", 10.0, 100.0, 30.0);
5531        assert_eq!(s.geometry_mode, GeometryMode::Basic);
5532        assert_eq!(s.bounds_source, BoundsSource::Advance);
5533        assert!(s.tight_char_bounds.is_empty());
5534    }
5535
5536    #[test]
5537    fn m3_width_source_unchanged_by_m3() {
5538        // WidthSource should not be affected by RichGeometry.
5539        assert_eq!(WidthSource::Metric, WidthSource::Metric);
5540        assert_eq!(WidthSource::Estimate, WidthSource::Estimate);
5541        assert_eq!(WidthSource::default(), WidthSource::Estimate);
5542    }
5543
5544    #[test]
5545    fn m3_transform_bbox_corners_identity() {
5546        let bbox = [0.0, 0.0, 10.0, 12.0];
5547        let identity = kurbo::Affine::IDENTITY;
5548        let result = transform_bbox_corners(&bbox, &identity);
5549        assert!((result[0] - 0.0).abs() < 0.001);
5550        assert!((result[1] - 0.0).abs() < 0.001);
5551        assert!((result[2] - 10.0).abs() < 0.001);
5552        assert!((result[3] - 12.0).abs() < 0.001);
5553    }
5554
5555    #[test]
5556    fn m3_transform_bbox_corners_translation() {
5557        let bbox = [0.0, 0.0, 10.0, 12.0];
5558        let t = kurbo::Affine::translate((50.0, 100.0));
5559        let result = transform_bbox_corners(&bbox, &t);
5560        assert!((result[0] - 50.0).abs() < 0.001);
5561        assert!((result[1] - 100.0).abs() < 0.001);
5562        assert!((result[2] - 60.0).abs() < 0.001);
5563        assert!((result[3] - 112.0).abs() < 0.001);
5564    }
5565
5566    #[test]
5567    fn m3_transform_bbox_corners_scale() {
5568        let bbox = [0.0, 0.0, 10.0, 12.0];
5569        let s = kurbo::Affine::scale(2.0);
5570        let result = transform_bbox_corners(&bbox, &s);
5571        assert!((result[0] - 0.0).abs() < 0.001);
5572        assert!((result[1] - 0.0).abs() < 0.001);
5573        assert!((result[2] - 20.0).abs() < 0.001);
5574        assert!((result[3] - 24.0).abs() < 0.001);
5575    }
5576
5577    #[test]
5578    fn m3_transform_bbox_corners_with_negative_bbox() {
5579        // Glyphs with descenders have negative y0 in font space.
5580        let bbox = [-5.0, -200.0, 15.0, 800.0];
5581        let s = kurbo::Affine::scale(0.012); // 12pt font at 1000-upem
5582        let result = transform_bbox_corners(&bbox, &s);
5583        assert!((result[0] - (-0.06)).abs() < 0.01);
5584        assert!((result[1] - (-2.4)).abs() < 0.01);
5585        assert!((result[2] - 0.18).abs() < 0.01);
5586        assert!((result[3] - 9.6).abs() < 0.01);
5587    }
5588
5589    #[test]
5590    fn m3_tight_char_bounds_descender_detection() {
5591        // A glyph with a descender should have tight_char_bounds.y0 < origin.y0.
5592        let s = TextSpan {
5593            text: "g".into(),
5594            x: 100.0,
5595            y: 200.0, // baseline
5596            width: 8.0,
5597            height: 12.0,
5598            font_size: 12.0,
5599            geometry_mode: GeometryMode::RichGeometry,
5600            bounds_source: BoundsSource::Tight,
5601            tight_char_bounds: vec![[99.0, 197.5, 109.0, 210.0]],
5602            char_bounds: vec![[100.0, 200.0, 108.0, 212.0]],
5603            glyph_advances: vec![8.0],
5604            ..Default::default()
5605        };
5606
5607        // Tight y0 (197.5) < span y (200.0) — descender extends below baseline.
5608        assert!(
5609            s.tight_char_bounds[0][1] < s.y,
5610            "descender should extend below baseline"
5611        );
5612    }
5613
5614    #[test]
5615    fn m3_tight_char_bounds_ascender_detection() {
5616        // A glyph with an ascender should have tight_char_bounds.y1 > advance y1.
5617        let s = TextSpan {
5618            text: "f".into(),
5619            x: 100.0,
5620            y: 200.0,
5621            width: 7.0,
5622            height: 12.0,
5623            font_size: 12.0,
5624            geometry_mode: GeometryMode::RichGeometry,
5625            bounds_source: BoundsSource::Tight,
5626            tight_char_bounds: vec![[95.0, 200.0, 108.0, 214.0]],
5627            char_bounds: vec![[100.0, 200.0, 107.0, 212.0]],
5628            glyph_advances: vec![7.0],
5629            ..Default::default()
5630        };
5631
5632        // Tight y1 (214.0) > advance y1 (200.0 + 12.0 = 212.0).
5633        let advance_y1 = s.y + s.height;
5634        assert!(
5635            s.tight_char_bounds[0][3] > advance_y1,
5636            "ascender should extend above advance bounds"
5637        );
5638    }
5639
5640    #[test]
5641    fn m3_tight_char_bounds_differ_from_advance() {
5642        // Verify tight bounds produce a different bbox than advance bounds.
5643        let s = TextSpan {
5644            text: "A".into(),
5645            x: 10.0,
5646            y: 100.0,
5647            width: 7.22,
5648            height: 10.0,
5649            font_size: 10.0,
5650            geometry_mode: GeometryMode::RichGeometry,
5651            bounds_source: BoundsSource::Tight,
5652            char_bounds: vec![[10.0, 100.0, 17.22, 110.0]], // advance
5653            tight_char_bounds: vec![[9.5, 100.2, 17.5, 109.5]], // tight (different)
5654            glyph_advances: vec![7.22],
5655            ..Default::default()
5656        };
5657
5658        let ac = s.char_bounds[0];
5659        let tc = s.tight_char_bounds[0];
5660        let differs = (ac[0] - tc[0]).abs() > 0.01
5661            || (ac[1] - tc[1]).abs() > 0.01
5662            || (ac[2] - tc[2]).abs() > 0.01
5663            || (ac[3] - tc[3]).abs() > 0.01;
5664        assert!(differs, "tight bounds should differ from advance bounds");
5665    }
5666
5667    #[test]
5668    fn m3_device_new_uses_basic_mode() {
5669        let dev = TextExtractionDevice::new();
5670        assert_eq!(dev.geometry_mode, GeometryMode::Basic);
5671        assert!(dev.deferred_rich_glyphs.is_empty());
5672    }
5673
5674    #[test]
5675    fn m3_device_with_mode_rich_geometry() {
5676        let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
5677        assert_eq!(dev.geometry_mode, GeometryMode::RichGeometry);
5678        assert!(dev.deferred_rich_glyphs.is_empty());
5679    }
5680
5681    /// A synthetic rectangular glyph outline in raw design space, plus its
5682    /// bbox. The renderer (`renderer.rs::fill_glyph`) fills this raw outline
5683    /// with `composed = transform * glyph_transform` and NO upem pre-scaling,
5684    /// so the page-space bbox of a tight glyph MUST equal
5685    /// `(composed * outline).bounding_box()`. These tests use that as the
5686    /// ground-truth oracle, which is what guards against scaling regressions.
5687    #[cfg(test)]
5688    fn synthetic_glyph_rect() -> (BezPath, kurbo::Rect) {
5689        let mut path = BezPath::new();
5690        path.move_to((100.0, 0.0));
5691        path.line_to((600.0, 0.0));
5692        path.line_to((600.0, 700.0));
5693        path.line_to((100.0, 700.0));
5694        path.close_path();
5695        let bbox = path.bounding_box();
5696        (path, bbox)
5697    }
5698
5699    #[test]
5700    fn m3_basic_mode_skips_tight_bounds_computation() {
5701        // Basic mode must NOT compute tight bounds, even when a span is present.
5702        let mut dev = TextExtractionDevice::new();
5703        dev.spans.push(TextSpan {
5704            text: "A".into(),
5705            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
5706            ..Default::default()
5707        });
5708        let spans = dev.into_spans();
5709        assert_eq!(spans.len(), 1);
5710        assert!(spans[0].tight_char_bounds.is_empty());
5711        assert!(spans[0].glyph_bounds_sources.is_empty());
5712        assert_eq!(spans[0].bounds_source, BoundsSource::Advance);
5713    }
5714
5715    #[test]
5716    fn m3_rich_geometry_tight_bounds_match_rendered_outline() {
5717        // Axis-aligned scale 0.01: the page-space tight bbox must equal the
5718        // bbox of the transformed outline — i.e. design [100,600]x[0,700]
5719        // scaled by 0.01 → [1.0, 0.0, 6.0, 7.0]. A double-scale regression
5720        // (e.g. an extra font_size/1000) would yield ~[0.01, 0, 0.06, 0.07].
5721        let (path, bbox) = synthetic_glyph_rect();
5722        let composed = Affine::scale(0.01);
5723        let oracle = (composed * path.clone()).bounding_box();
5724
5725        let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
5726        dev.spans.push(TextSpan {
5727            text: "A".into(),
5728            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
5729            geometry_mode: GeometryMode::RichGeometry,
5730            ..Default::default()
5731        });
5732        dev.deferred_rich_glyphs.push(DeferredGlyph {
5733            coeffs: composed.as_coeffs(),
5734            font_size: 10.0,
5735            glyph_width: 7.0,
5736            needs_exact: false,
5737            outline: Some(path),
5738            font_bbox: Some(bbox),
5739        });
5740
5741        let spans = dev.into_spans();
5742        assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
5743        assert_eq!(spans[0].bounds_source, BoundsSource::Tight);
5744        let tb = spans[0].tight_char_bounds[0];
5745        assert!(
5746            (tb[0] - oracle.x0).abs() < 1e-6,
5747            "x0: got {} want {}",
5748            tb[0],
5749            oracle.x0
5750        );
5751        assert!(
5752            (tb[1] - oracle.y0).abs() < 1e-6,
5753            "y0: got {} want {}",
5754            tb[1],
5755            oracle.y0
5756        );
5757        assert!(
5758            (tb[2] - oracle.x1).abs() < 1e-6,
5759            "x1: got {} want {}",
5760            tb[2],
5761            oracle.x1
5762        );
5763        assert!(
5764            (tb[3] - oracle.y1).abs() < 1e-6,
5765            "y1: got {} want {}",
5766            tb[3],
5767            oracle.y1
5768        );
5769        // Sanity: tight height (~7) is commensurate with the advance font_size
5770        // (10), not ~100x smaller — the regression this test exists to catch.
5771        assert!(
5772            tb[3] - tb[1] > 1.0,
5773            "tight height must be commensurate with font size"
5774        );
5775    }
5776
5777    #[test]
5778    fn m3_rich_geometry_tight_bounds_rotation_is_exact() {
5779        // 90° rotation routes through the needs_exact (transform-then-bound)
5780        // path. Oracle = bbox of the rotated outline.
5781        let (path, bbox) = synthetic_glyph_rect();
5782        let composed = Affine::scale(0.01) * Affine::rotate(std::f64::consts::FRAC_PI_2);
5783        let oracle = (composed * path.clone()).bounding_box();
5784
5785        let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
5786        dev.spans.push(TextSpan {
5787            text: "A".into(),
5788            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
5789            geometry_mode: GeometryMode::RichGeometry,
5790            ..Default::default()
5791        });
5792        dev.deferred_rich_glyphs.push(DeferredGlyph {
5793            coeffs: composed.as_coeffs(),
5794            font_size: 10.0,
5795            glyph_width: 7.0,
5796            needs_exact: true,
5797            outline: Some(path),
5798            font_bbox: Some(bbox),
5799        });
5800
5801        let spans = dev.into_spans();
5802        assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
5803        let tb = spans[0].tight_char_bounds[0];
5804        assert!(
5805            (tb[0] - oracle.x0).abs() < 1e-6,
5806            "x0: got {} want {}",
5807            tb[0],
5808            oracle.x0
5809        );
5810        assert!(
5811            (tb[1] - oracle.y0).abs() < 1e-6,
5812            "y0: got {} want {}",
5813            tb[1],
5814            oracle.y0
5815        );
5816        assert!(
5817            (tb[2] - oracle.x1).abs() < 1e-6,
5818            "x1: got {} want {}",
5819            tb[2],
5820            oracle.x1
5821        );
5822        assert!(
5823            (tb[3] - oracle.y1).abs() < 1e-6,
5824            "y1: got {} want {}",
5825            tb[3],
5826            oracle.y1
5827        );
5828    }
5829
5830    #[test]
5831    fn m3_empty_device_rich_geometry_no_panic() {
5832        let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
5833        let text = dev.into_text();
5834        assert!(text.is_empty());
5835    }
5836
5837    #[test]
5838    fn m3_bounds_source_estimate_fallback() {
5839        // Verify that BoundsSource::Estimate can be set on a span.
5840        let s = TextSpan {
5841            geometry_mode: GeometryMode::RichGeometry,
5842            bounds_source: BoundsSource::Estimate,
5843            ..Default::default()
5844        };
5845        assert_eq!(s.bounds_source, BoundsSource::Estimate);
5846        assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
5847        // Fallback still has empty tight fields (no glyphs).
5848        assert!(s.tight_char_bounds.is_empty());
5849    }
5850
5851    #[test]
5852    fn m3_multiple_glyphs_tight_bounds_consistent_count() {
5853        // Simulate a span with 3 glyphs — tight_char_bounds and glyph_advances
5854        // should have the same length.
5855        let s = TextSpan {
5856            text: "abc".into(),
5857            x: 0.0,
5858            y: 0.0,
5859            width: 25.0,
5860            height: 12.0,
5861            font_size: 12.0,
5862            geometry_mode: GeometryMode::RichGeometry,
5863            bounds_source: BoundsSource::Tight,
5864            char_bounds: vec![[0., 0., 8., 12.], [8., 0., 16., 12.], [16., 0., 25., 12.]],
5865            tight_char_bounds: vec![
5866                [-1., -2., 9., 14.],
5867                [7., -2., 17., 14.],
5868                [15., -2., 26., 14.],
5869            ],
5870            glyph_advances: vec![8.0, 8.0, 9.0],
5871            ..Default::default()
5872        };
5873
5874        assert_eq!(s.tight_char_bounds.len(), 3);
5875        assert_eq!(s.glyph_advances.len(), 3);
5876        assert_eq!(s.char_bounds.len(), 3);
5877        // Every glyph has advance >= 0.
5878        for &adv in &s.glyph_advances {
5879            assert!(adv >= 0.0, "glyph advance must be non-negative");
5880        }
5881    }
5882}