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, GlyphPlace, OutlineGlyph};
12use pdf_render::pdf_interpret::{
13    BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
14};
15use std::borrow::Cow;
16use std::cell::Cell;
17use std::cmp::Ordering;
18use std::collections::HashSet;
19use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
20
21use crate::blocks::{Block, BlockKind, TableStrategy};
22#[cfg(test)]
23use crate::table_trace::TableOrigin;
24
25/// Minimum Y tolerance for grouping spans into horizontal bands. The
26/// effective tolerance is typically `median_font_size * BAND_Y_FRACTION`
27/// per ANN[r17/TEX4]; this constant acts as the absolute floor.
28const BAND_Y_TOLERANCE: f64 = 5.0;
29/// Fraction of the page's median font size used as the band-Y
30/// tolerance. Empirically 0.30× works across common typography —
31/// below typical leading (~1.2×) so adjacent lines never collapse,
32/// above sub-pixel baseline drift.
33const BAND_Y_FRACTION: f64 = 0.30;
34/// Multiplier applied to median line spacing to derive the horizontal
35/// paragraph-break cut threshold. Normal line-to-line progression is
36/// ~1.0× the median; paragraph breaks typically show 1.5× or more.
37const PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER: f64 = 1.8;
38/// How far a continuation must sit in from its marker line's left edge to
39/// read as hung under the marker's text (#574). Past a third of an em the
40/// indent is the list's own hang and not the page's drift.
41const MARKER_HANG_MIN_EM: f64 = 0.3;
42/// How far in it may sit and still hang from that line (#574). Six ems is
43/// the widest marker-plus-gap on the measured pages; past it the line is a
44/// new indent, not a continuation.
45const MARKER_HANG_MAX_EM: f64 = 6.0;
46/// How close a hung line must start to the marker line's own text column
47/// where the page drew the marker in a span of its own (#574). Half an em
48/// either way; the column is exact, so the tolerance is the drift.
49const MARKER_TEXT_LEFT_TOL_EM: f64 = 0.5;
50/// How far short of the wider line's right edge a wrapping marker line may
51/// stop (#574). A line that ended an em and a half early turned back; one
52/// that ran to the measure wrapped.
53const MARKER_WRAP_SLACK_EM: f64 = 1.5;
54/// The most white a hung continuation may leave above itself (#574). Half
55/// an em over the glyphs is normal leading; more is a paragraph break.
56const MARKER_GAP_MAX_EM: f64 = 0.5;
57
58// ANN[r17/TEX1] Multi-signal consensus thresholds.
59// The previous single-threshold scheme (gap > 0.15 * font_size) missed
60// word boundaries when kerning or narrow fonts produced small measured
61// gaps even though the PDF emitted an explicit TJ backward shift, and
62// over-emitted spaces for condensed fonts where 0.15em of kerning is
63// well below an actual word space. The consensus system weights three
64// signals and inserts a space when the combined confidence exceeds
65// SPACE_CONSENSUS_THRESHOLD.
66/// Raw TJ backward adjustment (positive in PDF TJ units) that is
67/// definitively a word break. Matches pdftotext / MuPDF heuristics —
68/// a space glyph is typically emitted as either a literal 0x20 or as
69/// a TJ adjustment of around 250 1/1000 em. 100 units is a safely
70/// conservative floor.
71const TJ_SPACE_THRESHOLD_UNITS: f32 = 100.0;
72/// Weight of the TJ offset signal when confidence is high.
73const TJ_SIGNAL_WEIGHT: f64 = 0.95;
74/// Weight of the purely geometric gap signal.
75const GAP_SIGNAL_WEIGHT: f64 = 0.80;
76/// Weight of character-heuristic signals (CamelCase, digit↔letter).
77const HEURISTIC_SIGNAL_WEIGHT: f64 = 0.60;
78/// Combined weight at which a space is inserted.
79const SPACE_CONSENSUS_THRESHOLD: f64 = 0.75;
80
81/// How many EARLIER gaps have to agree with the gap being judged before a run
82/// reads as tracked.
83///
84/// Four earlier ones, so five agreeing gaps in all and a run of at least six
85/// glyphs. Two letters set the same distance apart is a coincidence of two
86/// sidebearings; five in a row is the typesetter holding a key down.
87///
88/// THE NAME USED TO SAY `PAIRS` AND THE COUNT WAS READ AS FOUR, which is the
89/// gap the review found (#582): the current gap is recorded AFTER it is
90/// judged, so a five-glyph word offers only three earlier gaps at its last
91/// pair and never reaches this. `TITLE` and `NOTES` therefore come back as
92/// `T I T L E` and `N O T E S` while `REGION` is joined -- at the glyph.
93///
94/// It is left at four rather than lowered to three: while a glyph is being
95/// read the rest of its span is not known. The short words are reached
96/// instead once the span is finished -- `retrack_a_finished_span` asks the
97/// same question of all its gaps at once -- which #1973 measured on the 6,446
98/// DocLayNet validation pages: nine pages change, each a tracked word read
99/// right, none a line of single-letter words. That is the measurement #582
100/// asked for, and `a_five_letter_tracked_word_is_read_once_its_span_is_finished`
101/// pins where the boundary now is.
102const TRACKING_MIN_PAIRS: usize = 4;
103
104/// How far the gaps of a tracked run may differ from one another, as a
105/// fraction of the gap being judged.
106///
107/// A quarter. Tracking is a constant added to every advance, so what varies
108/// inside a tracked run is only the kerning of the pair -- which is why
109/// `D I R E C TO R S` keeps `TO` and `RS` together: those two pairs kern
110/// closed far enough to fall back under the threshold on their own.
111const TRACKING_TOLERANCE: f64 = 0.25;
112
113/// The smallest median gap between a span's glyphs, as a share of its size,
114/// that is letters set apart rather than set solid (#1973). `P E N N` at 6.2
115/// points stands 0.145 em apart, a 60-point display line 0.05, and prose 0 --
116/// its boxes are advances and touch. 0.04 is under the lightest tracking seen
117/// by a margin and far over prose.
118const TRACKED_GAP_MIN_EM: f64 = 0.04;
119/// Fraction of a median character width above which a gap contributes
120/// to the geometric signal (pdf_oxide uses ~0.30).
121const GAP_TO_MEDIAN_CHAR_FRACTION: f64 = 0.30;
122/// Fallback gap fraction relative to `font_size` when the running
123/// median character width has not yet been established.
124const GAP_TO_FONT_SIZE_FALLBACK_FRACTION: f64 = 0.15;
125
126/// Minimum horizontal gap treated as a column gutter (adaptive fallback).
127const COLUMN_GAP_THRESHOLD_MIN: f64 = 10.0;
128/// Maximum adaptive column gap threshold.
129const COLUMN_GAP_THRESHOLD_MAX: f64 = 40.0;
130/// Multiplier applied to median inter-word gap to derive column threshold.
131const COLUMN_GAP_MEDIAN_MULTIPLIER: f64 = 3.0;
132/// Fallback column gap threshold when median cannot be computed.
133const COLUMN_GAP_THRESHOLD_FALLBACK: f64 = 20.0;
134/// Maximum drift allowed when matching gutters across neighboring bands.
135const COLUMN_GAP_MATCH_TOLERANCE: f64 = 12.0;
136/// Minimum number of gapped bands required before we enable column mode.
137const MIN_COLUMN_GAPPED_BANDS: usize = 3;
138/// Minimum fraction of bands in a region that must expose the shared gutters.
139const MIN_COLUMN_GAP_SUPPORT: f64 = 0.80;
140/// Minimum fraction of non-empty column slices that must look like prose.
141const MIN_DENSE_SLICE_RATIO: f64 = 0.35;
142/// How much of its own column the typical slice of a region may cover before
143/// the region reads as prose set in columns rather than as a table (#570).
144/// Under two thirds is a cell with white beside it; a line of prose fills its
145/// measure.
146const GRID_SLICE_FILL: f64 = 0.6;
147/// How many of a region's rows a column has to appear in before the region can
148/// be a grid. Three quarters, so that a table with an empty cell here and there
149/// is still a table and a sidebar beside one column is not.
150const GRID_COLUMN_SUPPORT: f64 = 0.75;
151/// The fewest rows a grid has. Three, because two rows of two short slices is
152/// the shape of a heading beside a date.
153const GRID_MIN_ROWS: usize = 3;
154/// The fewest labels a column of labels has. Three: two names beside two
155/// paragraphs is a heading over a note as often as it is a table.
156const LABEL_MIN_ROWS: usize = 3;
157/// The fewest bands a side of a proposed column cut has to have ink on before
158/// the gaps inside it say anything. Three, the same evidence a grid is asked
159/// for: two bands that both break once are a heading beside a date.
160const BANK_MIN_ROWS: usize = 3;
161/// How many of a side's own bands have to break at a gap as wide as the cut's
162/// before the cut is read as one gutter of a table rather than the gutter
163/// between two columns. More than half: a table breaks every row of both
164/// banks, and a paper set in columns with ONE table in one of its columns
165/// breaks a minority of that column's lines -- which is a page whose column
166/// cut is right and has to stay.
167const BANK_BREAK_SHARE: f64 = 0.5;
168/// How much wider than the wide side's leading a step has to be before it is
169/// the start of something rather than the next line of it. The same question
170/// `blocks::wrapped_leading` asks of a table's rows, and the same answer.
171const LABEL_PITCH_JUMP: f64 = 1.4;
172
173/// Two span starts this far apart are the same cell edge (#434). A cell's
174/// wrapped line is set from the cell's own left edge, so the two lines start on
175/// the same coordinate up to the rounding of the text matrix.
176const CELL_EDGE_TOLERANCE: f64 = 2.0;
177/// How far below its row a line may sit and still be that row's continuation,
178/// as a multiple of the row's tallest glyph. One line of leading is ~1.2; the
179/// next row of a table sits further down than this.
180const WRAPPED_CELL_LINE_GAP_FACTOR: f64 = 1.8;
181/// How far above the row the band that corroborates the grid may sit, counted
182/// in bands. The lines between are the wrapped cells of the row above, so a
183/// couple of them is all a row can put in the way.
184const TABLE_GRID_MAX_LOOKBACK: usize = 4;
185/// The share of the grid's own row pitch a continuation may sit below the line
186/// it continues. A row's pitch is one line plus the cell's padding and a
187/// wrapped line is one line, so a line at the full pitch is the next row.
188const WRAPPED_CELL_MAX_PITCH_FRACTION: f64 = 0.9;
189/// A grid is a claim about columns, so the band that corroborates it has to
190/// carry at least this many of them. Two, because a row of the table is
191/// allowed an empty cell.
192const TABLE_GRID_MIN_COLUMNS: usize = 2;
193/// A drawn path this flat is a line and not a box (#545). The ruling lines of
194/// the Isartor manual's appendix are 0.18 pt of ink; a hairline stroke is a
195/// path of no height at all, and a table's cell shading is eighteen points.
196const RULE_MAX_THICKNESS: f64 = 3.0;
197/// A line shorter than this separates nothing: it is a tick, a bullet or the
198/// underline of a word.
199const RULE_MIN_LENGTH: f64 = 24.0;
200/// How many path boxes are kept per page before they are clustered: a map
201/// drawn from ten thousand paths is one figure, and past this the rest add
202/// nothing to where it is.
203const MAX_RECORDED_PATH_BOXES: usize = 4000;
204
205/// How far apart two path boxes may be, in points, and still be one figure.
206const GRAPHIC_GAP: f64 = 2.0;
207
208fn touches(a: &[f64; 4], b: &[f64; 4], gap: f64) -> bool {
209    a[0] <= b[2] + gap && b[0] <= a[2] + gap && a[1] <= b[3] + gap && b[1] <= a[3] + gap
210}
211
212fn union(a: &[f64; 4], b: &[f64; 4]) -> [f64; 4] {
213    [
214        a[0].min(b[0]),
215        a[1].min(b[1]),
216        a[2].max(b[2]),
217        a[3].max(b[3]),
218    ]
219}
220
221/// How many image boxes are kept per page: a page tiled from hundreds of
222/// image strips is a scan, not a page of pictures to caption (#636).
223const MAX_RECORDED_IMAGES: usize = 256;
224
225/// How many rules are kept. A chart draws thousands of short flat segments and
226/// none of them rules a row; the cap bounds the memory a pathological page can
227/// ask for without changing what any real table looks like.
228const MAX_RECORDED_RULES: usize = 512;
229/// How many lattice rulings are kept (#574). A timetable draws its cells as
230/// several hundred boxes, four edges each; a chart can draw tens of thousands
231/// of segments, and past this the page is not a grid a lattice should read.
232const MAX_RECORDED_RULINGS: usize = 8192;
233/// Grey at or above this (0..255) is paper, not shading (#574).
234const FILL_WHITE_ABOVE: u32 = 247;
235/// The smallest fill, in points, that can stand behind a line of text.
236const FILL_MIN_WIDTH: f64 = 10.0;
237/// The smallest fill, in points, that can stand behind a line of text.
238const FILL_MIN_HEIGHT: f64 = 4.0;
239/// A lattice edge shorter than this is a tick, not a cell border.
240const RULING_MIN_LENGTH: f64 = 3.0;
241/// The share of a region's width a rule has to cross before it counts as
242/// separating that region's rows. A rule between two rows runs the whole table;
243/// a rule under one cell does not.
244const RULE_SPAN_FRACTION: f64 = 0.9;
245/// How many of a region's band gaps have to carry a rule before the region is
246/// a ruled table. Two, so that a single horizontal line -- a header rule, the
247/// line above a footnote -- never speaks for the page, while a region the
248/// recursion has already cut down to three rows can still be recognised as the
249/// table it is. Each of the two has to cross the whole region and lie between
250/// two bands that follow each other, which one rule under a headline does not.
251const MIN_RULED_ROW_SEPARATORS: usize = 2;
252/// The share of a region's band gaps that have to carry such a rule. Half,
253/// because a row whose cell wraps puts a band inside itself, and that gap has
254/// no rule in it.
255const MIN_RULED_GAP_SUPPORT: f64 = 0.5;
256
257/// How many cells the row itself has to fill before a line under it is read as
258/// one of its cells running on.
259///
260/// Three, because two is what a page set in columns looks like: its lines
261/// start on the same two edges as a two-cell row does, one of its columns ends
262/// before the other, and the line left over then reads exactly like a wrapped
263/// cell. On `tracemonkey` p13 it is one -- and the page is two columns of
264/// prose, where moving a line into "its cell" is the column cut of #389 class
265/// O-B done on one band instead of the region. A two-column table pays for
266/// that: its wrapped cells are the other half of #545.
267const TABLE_ROW_MIN_CELLS: usize = 3;
268
269/// Whether a text span's width was computed from real font metrics or estimated.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
271#[cfg_attr(feature = "serde", derive(serde::Serialize))]
272pub enum WidthSource {
273    /// Width derived from the font's actual glyph advance (hmtx, CFF, Type1 charstring).
274    Metric,
275    /// Width estimated at 50 % of font size — no glyph metric was available.
276    #[default]
277    Estimate,
278}
279
280impl WidthSource {
281    /// Stable label used across SDK bindings and the JSON wire form
282    /// (`"Metric"` / `"Estimate"`). Single source of truth for the string; the
283    /// `serde` derive on the enum produces identical values.
284    pub fn as_str(&self) -> &'static str {
285        match self {
286            WidthSource::Metric => "Metric",
287            WidthSource::Estimate => "Estimate",
288        }
289    }
290}
291
292/// Controls the richness of per-glyph geometry extraction.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
294pub enum GeometryMode {
295    /// Advance-based bounds only (current default).
296    #[default]
297    Basic,
298    /// Full tight glyph bounds from outline paths.
299    RichGeometry,
300}
301
302/// Provenance of per-glyph bounding boxes.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
304pub enum BoundsSource {
305    /// Advance bounds.
306    #[default]
307    Advance,
308    /// Path-derived tight bounds.
309    Tight,
310    /// Fallback (no outline available).
311    Estimate,
312}
313
314/// Vertical font metrics in /1000 em (1000 units-per-em), sourced from the
315/// embedded font's OS/2 / hhea tables via skrifa. `ascent` is positive (above
316/// the baseline); `descent` is negative (below it). The whole struct is `None`
317/// on a span when no embedded font binary exposes metrics (non-embedded
318/// standard-14, CFF, Type1, Type3).
319#[derive(Debug, Clone, Copy, PartialEq, Default)]
320#[cfg_attr(feature = "serde", derive(serde::Serialize))]
321pub struct FontMetrics {
322    /// Ascent above the baseline, in /1000 em.
323    pub ascent: f64,
324    /// Descent below the baseline (negative), in /1000 em.
325    pub descent: f64,
326    /// Cap height in /1000 em, when present in the font.
327    #[cfg_attr(
328        feature = "serde",
329        serde(rename = "capHeight", skip_serializing_if = "Option::is_none")
330    )]
331    pub cap_height: Option<f64>,
332    /// x-height in /1000 em, when present in the font.
333    #[cfg_attr(
334        feature = "serde",
335        serde(rename = "xHeight", skip_serializing_if = "Option::is_none")
336    )]
337    pub x_height: Option<f64>,
338}
339
340/// A single text span at a specific position.
341#[derive(Debug, Clone, Default)]
342pub struct TextSpan {
343    /// The extracted text.
344    pub text: String,
345    /// X position in user space.
346    pub x: f64,
347    /// Y position in user space.
348    pub y: f64,
349    /// Approximate bounding-box width in user space.
350    pub width: f64,
351    /// Approximate bounding-box height in user space.
352    pub height: f64,
353    /// Font size (approximate, from transform).
354    pub font_size: f64,
355    /// Whether a content run ends after this span: a consumer joining
356    /// same-style spans must not merge past it (#643). Only set on the raw
357    /// path; the default path leaves every span open, as before.
358    pub complete_run: bool,
359
360    // ---- G1 read-only metadata (added 2026-05; backward-compatible) ----
361    /// PostScript name of the font, with any 6-character subset prefix stripped
362    /// (e.g. `Helvetica-Bold`, `TimesNewRomanPS-BoldMT`). `None` for Type1
363    /// standard-14 fonts and Type3 fonts where no embedded font data is
364    /// available through the public `pdf-interpret` API.
365    pub font_name: Option<String>,
366    /// Inferred bold style: `weight >= 700` or PostScript name suggests bold
367    /// ("bold", "demi", "semibold", "heavy", "black"). Defaults `false` when
368    /// no descriptor data is reachable.
369    pub is_bold: bool,
370    /// Inferred italic style: FontDescriptor /Italic flag set or PostScript
371    /// name suggests italic/oblique/slant. Defaults `false` when no descriptor
372    /// data is reachable.
373    pub is_italic: bool,
374    /// Fill color as sRGB RGBA, derived from `Paint::Color(c).to_rgba().to_rgba8()`
375    /// at the moment of glyph paint. `None` for `Paint::Pattern` (tiling /
376    /// shading) — the editor falls back to "auto" in that case.
377    pub color: Option<[u8; 4]>,
378
379    // ---- G2 glyph-level metrics (added 2026-05) ----
380    /// Whether glyph widths were measured from real font advance data or estimated.
381    pub width_source: WidthSource,
382    /// Per-glyph bounding boxes in user-space, one entry per source glyph.
383    /// `[x0, y0, x1, y1]` with y0 < y1 (PDF coordinate frame).
384    pub char_bounds: Vec<[f64; 4]>,
385
386    // ---- M3 rich geometry (added 2026-06; backward-compatible) ----
387    #[doc(hidden)]
388    pub geometry_mode: GeometryMode,
389    #[doc(hidden)]
390    pub bounds_source: BoundsSource,
391    #[doc(hidden)]
392    pub tight_char_bounds: Vec<[f64; 4]>,
393    #[doc(hidden)]
394    pub glyph_advances: Vec<f64>,
395    #[doc(hidden)]
396    pub glyph_bounds_sources: Vec<BoundsSource>,
397
398    // ---- Golf 1 typographic metadata (added 2026-06; backward-compatible) ----
399    /// Full affine transform of the span's first glyph in user space,
400    /// `[a, b, c, d, e, f]` (kurbo coeffs of CTM × text-matrix). Captures
401    /// rotation and shear that `(x, y, font_size)` discards. Text placed by a
402    /// rectangle rather than drawn by a glyph (a form field's value with no
403    /// appearance stream, #549) carries the placement in the same form.
404    /// `None` for spans built from neither (e.g. synthetic markers).
405    pub transform: Option<[f64; 6]>,
406    /// Numeric font weight (~100–900) from the embedded font's OS/2 table when
407    /// available. `None` for non-embedded / standard-14 fonts.
408    pub font_weight: Option<u16>,
409    /// Whether the font is serif, when determinable from embedded font data.
410    /// `None` when no embedded descriptor is reachable.
411    pub is_serif: Option<bool>,
412    /// Whether the font is monospace, when determinable from embedded font data.
413    /// `None` when no embedded descriptor is reachable.
414    pub is_monospace: Option<bool>,
415    /// Coarse PDF text render mode: `0` fill, `1` stroke, `3` invisible — the
416    /// only three values the renderer's `GlyphDrawMode` expresses (the
417    /// fill+stroke / clip modes 2 and 4–7 are not distinguished). Reflects the
418    /// span's first glyph, and `0` for text placed by a rectangle rather than
419    /// drawn (#549). `None` for spans built from neither.
420    pub render_mode: Option<u8>,
421    /// Whether the span was set in the engine's own OCR layer resource
422    /// ([`pdf_ocr::pipeline::OCR_FONT_NAME`]). The hidden-text drop keeps
423    /// these spans while author-hidden text goes, because the layer is the
424    /// engine's own voice, not ink the author hid.
425    pub is_ocr_layer: bool,
426
427    // ---- Golf 2 font metrics (added 2026-06; backward-compatible) ----
428    /// Vertical font metrics (ascent/descent, optional cap/x-height) in /1000
429    /// em from the embedded font binary. `None` for non-embedded fonts.
430    pub font_metrics: Option<FontMetrics>,
431}
432
433impl TextSpan {
434    /// Whether this span is text the page hides: drawn with render mode 3
435    /// (invisible) or painted with zero alpha (lane L1).
436    ///
437    /// The render mode comes from the span's first glyph and the alpha from
438    /// its fill paint; a span with no paint information counts as visible.
439    pub fn is_hidden(&self) -> bool {
440        if self.render_mode == Some(3) {
441            return true;
442        }
443        if let Some(color) = self.color {
444            if color[3] == 0 {
445                return true;
446            }
447        }
448        false
449    }
450
451    /// Conservative right edge using whichever is wider: measured or estimated.
452    /// Used by column detection to avoid underestimating span extent.
453    ///
454    /// Measured only for a quarter-turned span (#574): the estimate counts
455    /// characters times a nominal advance, which runs ALONG the baseline, and
456    /// for a baseline that stands up the page that length is not an x extent
457    /// at all. Widening x by it lays a 29-glyph column head flat across the
458    /// next column and closes whatever gutter it lands on. A free-angle span
459    /// keeps the estimate, as before: its baseline still advances in x.
460    fn right(&self) -> f64 {
461        if span_is_quarter_turned(self) {
462            return self.x + self.width;
463        }
464        self.x + self.width.max(self.estimated_width())
465    }
466
467    /// Right edge from measured glyph positions only.
468    fn measured_right(&self) -> f64 {
469        self.x + self.width
470    }
471
472    fn estimated_width(&self) -> f64 {
473        let char_count = self.text.chars().count() as f64;
474        if char_count <= 0.0 {
475            self.font_size * 0.5
476        } else {
477            self.font_size * 0.5 * char_count
478        }
479    }
480}
481
482/// A block of text (grouped by reading order).
483#[derive(Debug, Clone)]
484pub struct TextBlock {
485    /// Spans within this block, sorted by position.
486    pub spans: Vec<TextSpan>,
487}
488
489impl TextBlock {
490    /// Concatenate all spans into a single string, in reading order.
491    ///
492    /// Spans that are close together are joined without a separator;
493    /// a space is inserted when the gap between spans exceeds half
494    /// the average character width.
495    ///
496    /// ANN[r17/TEX5] The spans are in the order the page draws them, left to
497    /// right, and for Arabic or Hebrew that is the reverse of the order they
498    /// are read in (#436, class R-H of #389). The assembled line therefore goes
499    /// through [`crate::bidi::logical_order`] before it is returned: a line
500    /// with no right-to-left character comes back untouched, and one that has
501    /// them comes back with each of its runs the way a reader would type it.
502    /// The spans themselves are left alone -- they carry the geometry, and the
503    /// geometry is visual.
504    pub fn text(&self) -> String {
505        crate::bidi::logical_order(&self.visual_text()).into_owned()
506    }
507
508    /// [`text`](Self::text) with the cell-path word rules where
509    /// `cell_path_words` is true: a word space set at exactly a quarter em
510    /// reads as a space, exactly as without the option otherwise.
511    pub(crate) fn text_with_cell_path_words(&self, cell_path_words: bool) -> String {
512        crate::bidi::logical_order(&self.visual_text_with(cell_path_words)).into_owned()
513    }
514
515    /// The line as the page draws it, left to right. The order every geometric
516    /// decision above this point was made in.
517    fn visual_text(&self) -> String {
518        self.visual_text_with(false)
519    }
520
521    /// [`visual_text`](Self::visual_text) with the cell-path word rules where
522    /// `cell_path_words` is true.
523    fn visual_text_with(&self, cell_path_words: bool) -> String {
524        if self.spans.is_empty() {
525            return String::new();
526        }
527        let spans: &[TextSpan] = &self.spans;
528        // What goes between each pair, decided pair by pair first and then
529        // looked at as a row: letter-spacing is a property of the row.
530        enum Join {
531            Overlap(String),
532            Gap(f64, bool),
533        }
534        let mut joins: Vec<Join> = Vec::with_capacity(spans.len());
535        // ANN[r17/TEX5] Where the line got to, which is not always the span
536        // before this one: a combining mark is drawn on the glyph before it and
537        // moves the pen nowhere, so its right edge sits an advance short and
538        // the letter after it looks a word apart. A Hebrew word with points
539        // came out `שָׁל וֹם` -- one word, two "words" (#436). A span that is
540        // nothing but marks does not say where the pen is, so the last one that
541        // does answers instead.
542        let mut anchor = &spans[0];
543        for pair in spans.windows(2) {
544            let prev = &pair[0];
545            let curr = &pair[1];
546            if !crate::bidi::is_only_marks(&prev.text) {
547                anchor = prev;
548            }
549            // The gap is measured along the baseline the anchor was drawn on:
550            // for upright text that is `curr.x - anchor.measured_right()` to
551            // the last bit, for a rotated run it is the distance the reader
552            // sees between the two (#435).
553            let direction = span_direction(anchor);
554            let (curr_along, _) = baseline_coordinates(curr, direction);
555            let gap = curr_along - baseline_end(anchor, direction);
556            // W1 (#663): the text join beside the shared word-join rule. A gap
557            // under 0.15 em is under 0.25 em, so `pieces_join` implies the rule
558            // below by arithmetic: this asserts without changing a byte, and
559            // shows the rewrite where the shared call goes.
560            debug_assert!(
561                !crate::words_join::pieces_join(
562                    gap,
563                    (baseline_coordinates(curr, direction).1
564                        - baseline_coordinates(anchor, direction).1)
565                        .abs(),
566                    prev.font_size
567                ) || gap <= prev.font_size * 0.25,
568                "the shared join implies the text join"
569            );
570            if gap <= prev.font_size * 0.12 && second_run_carries_on_from_the_first(prev, curr) {
571                if let Some(trimmed) = trim_overlapping_word_prefix(&prev.text, &curr.text) {
572                    joins.push(Join::Overlap(trimmed));
573                    continue;
574                }
575            }
576            if space_drawn_over_previous_run(prev, curr, gap) {
577                joins.push(Join::Overlap(curr.text.trim_start_matches(' ').to_string()));
578                continue;
579            }
580            let space = if cell_path_words {
581                // At the boundary, a space: a word space set at exactly a
582                // quarter em is still a word space (`financial officer`).
583                gap >= prev.font_size * 0.25
584            } else {
585                gap > prev.font_size * 0.25
586            };
587            joins.push(Join::Gap(gap, space));
588        }
589        untrack_spaced_spans(spans, &mut joins, |j| match j {
590            Join::Gap(gap, space) => Some((*gap, space)),
591            Join::Overlap(_) => None,
592        });
593        let mut result = spans[0].text.clone();
594        for (curr, join) in spans[1..].iter().zip(joins) {
595            match join {
596                Join::Overlap(trimmed) => result.push_str(&trimmed),
597                Join::Gap(_, space) => {
598                    if space {
599                        result.push(' ');
600                    }
601                    result.push_str(&curr.text);
602                }
603            }
604        }
605        result
606    }
607}
608
609/// Letter-spacing drawn as a row of one-letter spans (#1973).
610///
611/// #582 reads tracking inside a span. When the tracking is wide -- a space
612/// glyph after every capital, as in `P O W E L L  I N D U S T R I E S` set at
613/// five points -- every letter is a span of its own, the space glyphs never
614/// reach the text, and each gap between the spans is over the word-space
615/// threshold: the line came out one letter per word. On the evidence of the
616/// 24-09 round that is the largest class of text we write that the page does
617/// not hold (0.74 % of our words on DocLayNet, against Docling's 0.07 %).
618///
619/// A run of four one-letter spans or more (a kerned pair such as `AT` drawn as
620/// one does not end it) is read as letter-spacing only when THE PAGE SAYS
621/// WHERE ITS WORDS PART: a space glyph drawn as a span of its own between them
622/// (`P O W E L L` + a space span + `I N D U S T R I E S`), or gaps that fall in
623/// two -- the letters of a word one distance apart and the words clearly
624/// further, the widest gap at least 1.6 times the narrowest. The letters of a
625/// word then lose their spaces and the words keep one. A row whose gaps all agree
626/// says nothing about where its words part, and is left as it is: letters set
627/// apart on purpose and letters tracked apart are the same page, and that is
628/// the choice `a_line_of_single_letter_words_reads_as_tracking_and_that_is_the_choice`
629/// already pins inside a span.
630fn untrack_spaced_spans<J>(
631    spans: &[TextSpan],
632    joins: &mut [J],
633    mut gap_of: impl FnMut(&mut J) -> Option<(f64, &mut bool)>,
634) {
635    let blank = |span: &TextSpan| span.text.trim().is_empty();
636    // Capitals, and the punctuation a line of them carries: letter-spaced
637    // display type is set in capitals, and a row of lower-case letters,
638    // digits or symbols one apart is a formula or a listing (`NK(S) ∩ T`,
639    // `# 0 1 2 3`), which this must not touch.
640    let small = |span: &TextSpan| {
641        let t = span.text.trim();
642        let n = t.chars().count();
643        (n == 1 || n == 2)
644            && t.chars()
645                .all(|c| c.is_uppercase() || matches!(c, ',' | '.' | '\'' | '’' | '&' | '-'))
646            && (n == 1 || t.chars().any(char::is_uppercase))
647    };
648    let mut start = 0;
649    while start < spans.len() {
650        if !small(&spans[start]) {
651            start += 1;
652            continue;
653        }
654        let mut end = start + 1;
655        while end < spans.len()
656            && (small(&spans[end]) || blank(&spans[end]))
657            && gap_of(&mut joins[end - 1]).is_some()
658        {
659            end += 1;
660        }
661        // A space drawn at the end of the run is not inside it, and neither
662        // is a last "word" of one letter after it: `F C F C` + a space + `S a
663        // m p l e` is a figure's label beside a word, not two tracked words.
664        loop {
665            while end > start + 1 && blank(&spans[end - 1]) {
666                end -= 1;
667            }
668            let tail = spans[start..end].iter().rposition(blank);
669            match tail {
670                Some(at)
671                    if spans[start + at + 1..end]
672                        .iter()
673                        .filter(|s| s.text.trim().chars().any(char::is_uppercase))
674                        .count()
675                        < 2 =>
676                {
677                    end = start + at;
678                }
679                _ => break,
680            }
681        }
682        let singles = spans[start..end]
683            .iter()
684            .filter(|s| {
685                let t = s.text.trim();
686                t.chars().count() == 1 && t.chars().all(char::is_uppercase)
687            })
688            .count();
689        // THE PAGE DREW ITS WORD SPACES: a space glyph set as a span of its
690        // own where the words part, which is the boundary. The gaps between the
691        // letters are tracking and lose their space; a gap much wider than the
692        // run's tracking keeps it -- the end of a line, or a word the page
693        // parted without a drawn space. Without a drawn space the row is left
694        // as it is: gaps that fall in two were tried as the boundary and read
695        // figure labels and OCR noise (`B C B C`, `I I I`) as words.
696        if singles >= 4 && spans[start..end].iter().any(blank) {
697            let mut tracking: Vec<f64> = (start..end - 1)
698                .filter(|&k| !blank(&spans[k]) && !blank(&spans[k + 1]))
699                .filter_map(|k| gap_of(&mut joins[k]).map(|(g, _)| g))
700                .collect();
701            tracking.sort_by(|a, b| a.total_cmp(b));
702            let median = tracking.get(tracking.len() / 2).copied().unwrap_or(0.0);
703            for k in start..end - 1 {
704                let beside_a_drawn_space = blank(&spans[k]) || blank(&spans[k + 1]);
705                if let Some((gap, space)) = gap_of(&mut joins[k]) {
706                    if beside_a_drawn_space || gap <= 1.5 * median.max(0.0) {
707                        *space = false;
708                    }
709                }
710            }
711        }
712        // Never stand still: a run that shrank to nothing moves on by one.
713        start = end.max(start + 1);
714    }
715}
716
717#[derive(Debug, Clone)]
718struct TextBand {
719    y: f64,
720    spans: Vec<TextSpan>,
721}
722
723impl TextBand {
724    fn new(span: TextSpan) -> Self {
725        Self {
726            y: span.y,
727            spans: vec![span],
728        }
729    }
730
731    fn sort_spans(&mut self) {
732        self.spans.sort_by(|a, b| {
733            a.x.partial_cmp(&b.x)
734                .unwrap_or(Ordering::Equal)
735                .then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
736        });
737        split_spans_covering_siblings(&mut self.spans);
738        self.spans.sort_by(|a, b| {
739            a.x.partial_cmp(&b.x)
740                .unwrap_or(Ordering::Equal)
741                .then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
742        });
743        collapse_overprinted_spans(&mut self.spans);
744    }
745
746    fn row_block(&self) -> TextBlock {
747        let mut spans = self.spans.clone();
748        spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
749        TextBlock { spans }
750    }
751
752    fn left(&self) -> f64 {
753        self.spans
754            .iter()
755            .map(|span| span.x)
756            .fold(f64::INFINITY, f64::min)
757    }
758
759    fn right(&self) -> f64 {
760        self.spans
761            .iter()
762            .map(TextSpan::right)
763            .fold(f64::NEG_INFINITY, f64::max)
764    }
765
766    fn width(&self) -> f64 {
767        (self.right() - self.left()).max(0.0)
768    }
769
770    fn gap_midpoints(&self, column_gap_threshold: f64) -> Vec<f64> {
771        self.gaps(column_gap_threshold)
772            .into_iter()
773            .map(|gap| (gap.start + gap.end) * 0.5)
774            .collect()
775    }
776
777    fn gaps(&self, column_gap_threshold: f64) -> Vec<BandGap> {
778        if self.spans.len() < 2 {
779            return Vec::new();
780        }
781
782        let mut spans = self.spans.clone();
783        spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
784
785        let mut gaps = Vec::new();
786        let mut prev_right = spans[0].right();
787        for span in spans.iter().skip(1) {
788            let gap = span.x - prev_right;
789            if gap >= column_gap_threshold {
790                gaps.push(BandGap {
791                    start: prev_right,
792                    end: span.x,
793                });
794            }
795            prev_right = prev_right.max(span.right());
796        }
797
798        gaps
799    }
800
801    fn split_by_boundaries(&self, boundaries: &[f64]) -> Vec<Vec<TextSpan>> {
802        let mut columns = vec![Vec::new(); boundaries.len() + 1];
803        for span in &self.spans {
804            // Through `right()` rather than the estimate inline, so a
805            // quarter-turned span is centred on the x it covers and not on the
806            // length of the run stood up on it (#574). The same value for
807            // upright text.
808            let center_x = (span.x + span.right()) * 0.5;
809            let column_idx = boundaries
810                .iter()
811                .position(|boundary| center_x < *boundary)
812                .unwrap_or(boundaries.len());
813            columns[column_idx].push(span.clone());
814        }
815
816        for spans in &mut columns {
817            spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
818        }
819
820        columns
821    }
822
823    fn fits_single_column(
824        &self,
825        boundaries: &[f64],
826        region_left: f64,
827        region_right: f64,
828    ) -> Option<usize> {
829        let mut column_idx: Option<usize> = None;
830        for span in &self.spans {
831            let left = span.x;
832            let right = span.right();
833            if boundaries
834                .iter()
835                .any(|boundary| left < *boundary && right > *boundary)
836            {
837                return None;
838            }
839
840            let center_x = left + (right - left) * 0.5;
841            let idx = boundaries
842                .iter()
843                .position(|boundary| center_x < *boundary)
844                .unwrap_or(boundaries.len());
845            match column_idx {
846                Some(existing) if existing != idx => return None,
847                Some(_) => {}
848                None => column_idx = Some(idx),
849            }
850        }
851        let idx = column_idx?;
852        let mut edges = Vec::with_capacity(boundaries.len() + 2);
853        edges.push(region_left);
854        edges.extend_from_slice(boundaries);
855        edges.push(region_right);
856
857        let column_width = (edges[idx + 1] - edges[idx]).max(0.0);
858        if column_width <= 0.0 || self.width() > column_width * 0.8 {
859            return None;
860        }
861
862        Some(idx)
863    }
864}
865
866#[derive(Debug, Clone, Copy)]
867struct BandGap {
868    start: f64,
869    end: f64,
870}
871
872/// The points a path segment is built from, in the order the segment draws
873/// them. Control points included: a cubic lies inside the hull of its four
874/// points, so a bound taken over all of them is never too small.
875fn element_points(element: &PathEl) -> impl Iterator<Item = Point> {
876    let mut points = [Point::ZERO; 3];
877    let count = match *element {
878        PathEl::MoveTo(p) | PathEl::LineTo(p) => {
879            points[0] = p;
880            1
881        }
882        PathEl::QuadTo(a, b) => {
883            points[0] = a;
884            points[1] = b;
885            2
886        }
887        PathEl::CurveTo(a, b, c) => {
888            points[0] = a;
889            points[1] = b;
890            points[2] = c;
891            3
892        }
893        PathEl::ClosePath => 0,
894    };
895    points.into_iter().take(count)
896}
897
898/// A horizontal rule the page draws, in the page space the spans are measured
899/// in (#545).
900///
901/// This is the one thing on the page that says "table" without guessing at it
902/// from the text's own geometry. A width heuristic cannot tell a table from a
903/// paper set in two columns -- both are slices of a band with a gutter between
904/// them -- and the discriminator tried on slice width fixed the Isartor table
905/// by breaking `tracemonkey`. The ruling lines are drawn by the document
906/// itself, and a page of prose does not draw one between every pair of lines.
907#[derive(Clone, Copy, Debug)]
908#[doc(hidden)]
909pub struct HorizontalRule {
910    pub left: f64,
911    pub right: f64,
912    pub y: f64,
913}
914
915/// One straight, axis-aligned stroke of a drawn grid, in page space (#574):
916/// a thin line in either direction, or one edge of a drawn rectangle.
917///
918/// KEPT APART FROM [`HorizontalRule`] on purpose. Every heuristic that reads
919/// the horizontal rules was tuned on what that list holds; a table's cell
920/// borders and shading boxes would flood it with short segments none of them
921/// was measured against. This list is read by the lattice alone.
922#[derive(Clone, Copy, Debug, PartialEq)]
923#[doc(hidden)]
924pub struct Ruling {
925    /// Left or only x.
926    pub x0: f64,
927    /// Bottom or only y.
928    pub y0: f64,
929    /// Right x; equal to `x0` for a vertical ruling.
930    pub x1: f64,
931    /// Top y; equal to `y0` for a horizontal ruling.
932    pub y1: f64,
933}
934
935/// A Device implementation that captures text from draw_glyph calls.
936///
937/// ANN[r17/TEX1][r17/TEX3] Space detection uses a multi-signal consensus
938/// rather than a single geometric threshold. Three signals vote:
939///   1. `pending_tj_offset`  — raw TJ backward shift surfaced by the
940///      interpreter (confidence 0.95). This is the definitive word-break
941///      signal used by pdftotext / MuPDF.
942///   2. geometric gap        — measured horizontal distance between the
943///      previous glyph's right edge and this glyph's origin (confidence
944///      0.80). Compared against the running median glyph width rather
945///      than a flat em-fraction so condensed/wide fonts are handled
946///      uniformly.
947///   3. character heuristic  — CamelCase transition or digit↔letter
948///      transition at the merge point (confidence 0.60). Catches cases
949///      where the writer relied on typography (e.g. table cells glued
950///      with zero gap: `Qty1Price$5`).
951///
952/// A space is inserted when the weighted sum meets SPACE_CONSENSUS_THRESHOLD.
953/// Span accumulation still merges adjacent glyphs into one TextSpan (TEX3)
954/// so downstream reading-order logic sees logical text runs, not individual
955/// character positions.
956///
957/// One entry on the marked-content stack for `/ActualText`. Sequences nest,
958/// so each `BDC`/`BMC` pushes and `EMC` pops; the innermost entry carrying
959/// a replacement wins for a glyph drawn inside it. The replacement is
960/// emitted once, on the first glyph of its sequence, and later glyphs of
961/// the same sequence are suppressed. A sequence that closes without a glyph
962/// having carried it reads once at its close instead, at the marks it drew.
963struct ActualTextEntry {
964    actual_text: Option<String>,
965    consumed: bool,
966    /// A glyph that reads as drawn text was painted while this sequence was
967    /// open, so the sequence already stands in the text and its close must
968    /// not read the replacement as well.
969    saw_glyph: bool,
970    /// A sequence nested inside this one already answered for these marks --
971    /// its replacement read on a glyph or at its close -- so this close
972    /// stays quiet, and a replacement inside a replacement reads once.
973    child_resolved: bool,
974    /// The marks painted while this sequence was open, in page space: glyph
975    /// boxes, path boxes and image boxes united. Where the replacement reads
976    /// when no glyph carried it.
977    extent: Option<[f64; 4]>,
978}
979
980#[doc(hidden)]
981pub struct TextExtractionDevice {
982    spans: Vec<TextSpan>,
983    last_y: f64,
984    last_end_x: f64,
985    /// The gaps between the last few glyph pairs of the span being built, so
986    /// that a run the typesetter TRACKED can be told from a run with word
987    /// spaces in it (#582). See `a_tracked_run`.
988    recent_gaps: Vec<f64>,
989    /// The last glyph drawn with a fill: where, and what it reads. Text in
990    /// render mode 2 or 6 reaches the device twice per glyph, filled and then
991    /// stroked, and the stroke is the same character on the same spot.
992    last_fill: Option<([f64; 6], Option<BfString>)>,
993    /// How far the last glyph's box was widened past its declared advance by
994    /// the quarter-em floor of `glyph_width_and_source`; the gap after it is
995    /// that much short of the one the page drew.
996    last_widened: f64,
997    /// The face the last span is MERGED on: the program's own name, which is
998    /// what `font_name` was before it learned /BaseFont (#574). A span that
999    /// carries the page's name for a face the program does not name keeps
1000    /// merging as it did -- a formula's CMMI into its CMR -- because a merge
1001    /// across a face change is where a word space is judged, and splitting
1002    /// there lost `where µ measures` on the arXiv pages.
1003    last_merge_face: Option<String>,
1004    /// Where in the text of the span being built the space consensus put a
1005    /// space, and the gap it judged: a drawn space is not in here.
1006    inserted_spaces: Vec<(usize, f64)>,
1007    /// Where in that text the current run of regular gaps began.
1008    run_start: usize,
1009    /// The last gap between two letters of the span being built.
1010    last_letter_gap: Option<f64>,
1011    /// TJ adjustment in raw 1/1000 em units since the last glyph was
1012    /// drawn. Positive values = backward shift (i.e., explicit horizontal
1013    /// space). Reset every time a glyph is drawn.
1014    pending_tj_offset: f32,
1015    /// Running sample of measured glyph widths used as the adaptive
1016    /// reference for the geometric gap signal. Cheap to maintain and
1017    /// avoids having to re-walk all spans per decision.
1018    glyph_widths: Vec<f64>,
1019    /// Cached median glyph width (kept fresh every `MEDIAN_REFRESH`
1020    /// insertions). Zero = not yet established, caller falls back to
1021    /// font-size scaling.
1022    cached_median_char_width: f64,
1023    /// Geometry extraction mode — Basic (advance bounds) or RichGeometry (tight bounds).
1024    geometry_mode: GeometryMode,
1025    /// Per-glyph data recorded during extraction for deferred tight-bounds
1026    /// computation. Only populated in RichGeometry mode. The index into this
1027    /// vec corresponds 1:1 with the glyph order across all spans.
1028    deferred_rich_glyphs: Vec<DeferredGlyph>,
1029    /// Hand back the ligature code points a font declared instead of the
1030    /// letters they stand for. Off, because a search box compares characters
1031    /// and U+FB03 is not `ffi` (#437); see [`crate::ligatures`].
1032    keep_ligatures: bool,
1033    /// Report the document's own font names and the descriptor's metrics on
1034    /// spans. Off, because the default output is what it was before the E34
1035    /// truth existed: substitute-face names and AFM metrics (#643).
1036    document_font_names: bool,
1037    /// The horizontal rules the page draws, in page space (#545).
1038    rules: Vec<HorizontalRule>,
1039    /// Every thin line and rectangle edge the page draws, for the lattice
1040    /// (#574).
1041    rulings: Vec<Ruling>,
1042    /// Every solid, non-white, axis-aligned rectangle the page fills, in page
1043    /// space and clipped: the shading behind a heading, a highlighted row, a
1044    /// box a note is set in (#574, the line network's vector inputs).
1045    fills: Vec<[f64; 4]>,
1046    /// Where the page places its images, in page space: the unit square each
1047    /// image XObject is drawn into, under its transform and clipped to what is
1048    /// visible (#636). A caption sits beside a picture, and the picture is
1049    /// nowhere else in what this device reads.
1050    images: Vec<[f64; 4]>,
1051    /// The box of every path the page fills or strokes that is not a thin
1052    /// line, in page space and clipped (#636): what a chart or a drawn figure
1053    /// is made of. Clustered into figures by [`Self::take_graphics`].
1054    path_boxes: Vec<[f64; 4]>,
1055    /// The clip boxes in force, innermost last, in page space (#545). Each
1056    /// entry is already intersected with the one below it, so the last is the
1057    /// box a path drawn now can reach.
1058    clip_stack: Vec<kurbo::Rect>,
1059    /// Whether glyphs merge into the runs the page draws, for the raw-order
1060    /// path (`ExtractOptions::raw_order`, #643). Off: every glyph stands on
1061    /// its own span, exactly as without the option. On: the gap is judged on
1062    /// the pen the last glyph left, so runs merge as drawn, and a run ends
1063    /// where the marked-content segment does.
1064    raw_runs: bool,
1065    /// The marked-content segment each span was drawn in, in span order; only
1066    /// filled when [`Self::raw_runs`] is on. `None` outside any segment (an
1067    /// untagged page never opens one).
1068    raw_bdc: Vec<Option<u32>>,
1069    /// The open marked-content segments, innermost last; only tracked when
1070    /// [`Self::raw_runs`] is on.
1071    raw_bdc_stack: Vec<u32>,
1072    /// The next marked-content segment number; only used when
1073    /// [`Self::raw_runs`] is on.
1074    raw_bdc_next: u32,
1075    /// Whether a segment opened or closed since the last glyph was drawn;
1076    /// only set when [`Self::raw_runs`] is on. The next glyph starts a fresh
1077    /// span, so a run never crosses the segment it was drawn in.
1078    raw_bdc_changed: bool,
1079    /// The page's declared name of the face the run being built is set in,
1080    /// subset prefix stripped; only tracked when [`Self::raw_runs`] is on.
1081    /// The program substitutes one face for several of the page's --
1082    /// `HelveticaNeueLTStd-BdOu` and `-Blk` both reach the spans as
1083    /// `Helvetica` -- so the merge face cannot tell `20` from `25`, and the
1084    /// page's own name is what parts them.
1085    last_raw_declared: Option<String>,
1086    /// Where the last glyph drawn ended, and the unit advance direction it
1087    /// read in; only tracked when [`Self::raw_runs`] is on. A turned glyph
1088    /// joins its run along the way it reads, so the pen is a point here,
1089    /// not the x the default path keeps.
1090    raw_pen: [f64; 2],
1091    raw_dir: [f64; 2],
1092    /// Whether `raw_pen` holds a glyph; only tracked when [`Self::raw_runs`]
1093    /// is on.
1094    raw_pen_valid: bool,
1095    /// The open marked-content sequences, innermost last. Tracked only while
1096    /// [`Self::actual_text`] applies replacements; ignored otherwise, so the
1097    /// default output is what it was before the replacements were read. The
1098    /// stack survives `Do` into a Form XObject because the same device reads
1099    /// the nested stream.
1100    actual_text_stack: Vec<ActualTextEntry>,
1101    /// Whether `/ActualText` replacements apply. Off, because the default
1102    /// output is what it was before the replacements were read. Set this
1103    /// when the caller asked for the replacements through
1104    /// `ExtractOptions::actual_text` (#663).
1105    actual_text: bool,
1106    /// The annotation appearances being drawn, innermost last, in page
1107    /// space: the rectangle each annotation shows its appearance in. A
1108    /// glyphless replacement inside one is the value the widget shows --
1109    /// the only place that value is painted -- so it reads even where
1110    /// replacements on the page stay off.
1111    annotation_appearances: Vec<kurbo::Rect>,
1112    /// Where the last glyph's own text went, so that it can be read again
1113    /// when the next glyph arrives. See [`LastGlyphText`].
1114    last_glyph_text: Option<LastGlyphText>,
1115    /// This device's number, which names its entry in [`LAST_GLYPH`].
1116    id: u64,
1117    /// Whether [`LAST_GLYPH`] names this device, so that it is written once
1118    /// rather than at every glyph (spec, "Performance").
1119    holds_last_glyph: bool,
1120    /// Clears this device's entry in [`LAST_GLYPH`] when the device goes,
1121    /// unwinding included.
1122    last_glyph_guard: ClearLastGlyph,
1123}
1124
1125/// The number the next [`TextExtractionDevice`] gets.
1126static NEXT_DEVICE: AtomicU64 = AtomicU64::new(0);
1127
1128/// Empties [`LAST_GLYPH`] when dropped if the entry is the device's whose
1129/// number it holds, so no glyph -- and no interpreter font behind it --
1130/// outlives the device that drew it, even when extraction panics (spec
1131/// review 14-10 02:00 (i)).
1132struct ClearLastGlyph(u64);
1133
1134impl ClearLastGlyph {
1135    /// Empties [`LAST_GLYPH`] if it names this device.
1136    fn clear(&self) {
1137        let _ = LAST_GLYPH.try_with(|slot| {
1138            if slot.get() == Some(self.0) {
1139                slot.set(None);
1140            }
1141        });
1142    }
1143}
1144
1145impl Drop for ClearLastGlyph {
1146    fn drop(&mut self) {
1147        self.clear();
1148    }
1149}
1150
1151/// Whether [`LAST_GLYPH`] holds a glyph on this thread.
1152#[cfg(test)]
1153pub(crate) fn last_glyph_is_held() -> bool {
1154    LAST_GLYPH.with(|slot| slot.get().is_some())
1155}
1156
1157thread_local! {
1158    /// The number of the device on this thread that holds a last glyph.
1159    ///
1160    /// The glyph itself is not kept: the device keeps its place on the page
1161    /// (a plain value, so the device stays `Send`), and the next glyph says
1162    /// what the glyph at that place reads as now when drawing it changed
1163    /// that (`OutlineGlyph::reread_previous`).
1164    static LAST_GLYPH: Cell<Option<u64>> = const { Cell::new(None) };
1165}
1166
1167/// Where the last glyph's text went, so that it can be read again.
1168///
1169/// The interpreter offers a combining mark from the AMS name rows (spec
1170/// `specs/text/circlecopyrt.md`, C4 and C5a) to the glyph on the other side
1171/// of it once it has seen both, and the glyph already handed over may then
1172/// read differently: a `c` drawn before its circle reads `c⃝`, and a slash
1173/// drawn before the `=` it strikes reads as nothing, the `=` then reading
1174/// `≠`. The device reads the last glyph again at the next one, but only when
1175/// its text still stands last in the last span, so that what it reads now
1176/// can go in place of what it read then; reading it is what takes the offer,
1177/// and a glyph that cannot be read again leaves the offer to be refused.
1178/// Only the text changes: the glyph's box stays, and the pen stays where the
1179/// first reading left it, except that a glyph that now reads as nothing
1180/// gives its box back.
1181struct LastGlyphText {
1182    /// Where the glyph stands on its page, set when it is remembered.
1183    place: Option<GlyphPlace>,
1184    /// The span the text went into, and where in its text it starts.
1185    span: usize,
1186    offset: usize,
1187    /// How long the text was, in bytes.
1188    len: usize,
1189    /// Whether the glyph read as nothing and so wrote no text and no box.
1190    boxless: bool,
1191    /// Whether the glyph opened the span.
1192    opened: bool,
1193    /// What the span held before this glyph: glyph boxes, width, and (for a
1194    /// glyph that opened it) the previous span's run end under raw runs.
1195    char_bounds: usize,
1196    width: f64,
1197    previous_complete_run: Option<bool>,
1198    /// The rich-geometry records before this glyph's.
1199    deferred: usize,
1200    /// How many spans there were after this glyph, and the last one's text
1201    /// length and box count: the glyph is still last while these hold.
1202    span_count: usize,
1203    tail_text: usize,
1204    tail_boxes: usize,
1205}
1206
1207/// Lightweight glyph data recorded during extraction for deferred tight-bounds
1208/// computation. Avoids computing bounds on the hot path.
1209#[derive(Clone)]
1210struct DeferredGlyph {
1211    /// Transform coefficients [a,b,c,d,e,f] from composed (CTM * glyph_transform)
1212    coeffs: [f64; 6],
1213    /// Font size in points
1214    font_size: f64,
1215    /// Advance width in page-space points
1216    glyph_width: f64,
1217    /// Whether this glyph needs transform-then-bound (rotation/shear)
1218    needs_exact: bool,
1219    /// The glyph outline path in raw glyph space. Retained ONLY for
1220    /// rotated/sheared glyphs (`needs_exact`), where a tight bound requires
1221    /// bounding the transformed path; `None` for axis-aligned glyphs (the
1222    /// `font_bbox` suffices) and for ink-less glyphs (spaces, Type3).
1223    outline: Option<BezPath>,
1224    /// Pre-computed bounding box of the outline in raw glyph space. `None` when
1225    /// the glyph has no ink (space, Type3, or empty outline) → Estimate.
1226    font_bbox: Option<kurbo::Rect>,
1227}
1228
1229const MEDIAN_REFRESH: usize = 32;
1230
1231/// How far apart two glyphs' type sizes may stand and still merge into one
1232/// raw run, as a fraction of the larger (#643). Within a run the size is one
1233/// Tm and never varies; across runs the page changes it wholesale (`MM` at
1234/// 7pt against its 4.95pt space is twenty-nine per cent).
1235const RAW_SIZE_TOLERANCE: f64 = 0.01;
1236
1237impl Default for TextExtractionDevice {
1238    fn default() -> Self {
1239        Self::new()
1240    }
1241}
1242
1243impl TextExtractionDevice {
1244    /// Create a new text extraction device with Basic geometry (default).
1245    pub fn new() -> Self {
1246        Self::with_mode(GeometryMode::Basic)
1247    }
1248
1249    /// Create a new text extraction device with the given geometry mode.
1250    pub fn with_mode(geometry_mode: GeometryMode) -> Self {
1251        let id = NEXT_DEVICE.fetch_add(1, AtomicOrdering::Relaxed);
1252        Self {
1253            spans: Vec::new(),
1254            last_y: f64::NEG_INFINITY,
1255            last_end_x: f64::NEG_INFINITY,
1256            recent_gaps: Vec::new(),
1257            last_fill: None,
1258            last_widened: 0.0,
1259            last_merge_face: None,
1260            inserted_spaces: Vec::new(),
1261            run_start: 0,
1262            last_letter_gap: None,
1263            pending_tj_offset: 0.0,
1264            glyph_widths: Vec::new(),
1265            cached_median_char_width: 0.0,
1266            geometry_mode,
1267            deferred_rich_glyphs: Vec::new(),
1268            keep_ligatures: false,
1269            document_font_names: false,
1270            rules: Vec::new(),
1271            rulings: Vec::new(),
1272            fills: Vec::new(),
1273            images: Vec::new(),
1274            path_boxes: Vec::new(),
1275            clip_stack: Vec::new(),
1276            raw_runs: false,
1277            raw_bdc: Vec::new(),
1278            raw_bdc_stack: Vec::new(),
1279            raw_bdc_next: 0,
1280            raw_bdc_changed: false,
1281            last_raw_declared: None,
1282            raw_pen: [0.0, 0.0],
1283            raw_dir: [1.0, 0.0],
1284            raw_pen_valid: false,
1285            actual_text_stack: Vec::new(),
1286            actual_text: false,
1287            annotation_appearances: Vec::new(),
1288            last_glyph_text: None,
1289            id,
1290            holds_last_glyph: false,
1291            last_glyph_guard: ClearLastGlyph(id),
1292        }
1293    }
1294
1295    fn push_placed_span(&mut self, text: &str, rect: kurbo::Rect) {
1296        if text.is_empty() {
1297            return;
1298        }
1299
1300        let height = if rect.height().is_finite() && rect.height() > 0.0 {
1301            rect.height()
1302        } else {
1303            0.0
1304        };
1305        let width = if rect.width().is_finite() && rect.width() > 0.0 {
1306            rect.width()
1307        } else {
1308            0.0
1309        };
1310
1311        // A NEW SPAN IS A NEW RUN, so what the last one was tracked at says
1312        // nothing about this one (#582).
1313        self.recent_gaps.clear();
1314        self.inserted_spaces.clear();
1315        self.run_start = 0;
1316        self.last_letter_gap = None;
1317        self.last_merge_face = None;
1318        self.spans.push(TextSpan {
1319            text: text.to_string(),
1320            x: rect.x0,
1321            y: rect.y0,
1322            width,
1323            height,
1324            font_size: height,
1325            // Placed text is one synthetic span, never a content run (#643).
1326            complete_run: false,
1327            font_name: None,
1328            is_bold: false,
1329            is_italic: false,
1330            color: None,
1331            width_source: WidthSource::Estimate,
1332            char_bounds: Vec::new(),
1333            // The placement is a real statement about where these characters
1334            // are, in the same form a glyph's is -- scale on the diagonal,
1335            // position in the last pair -- so a caller that lays the text back
1336            // out has what it needs, and the coverage gate over these two
1337            // fields stays a gate rather than acquiring an exception.
1338            transform: Some([height / 1000.0, 0.0, 0.0, height / 1000.0, rect.x0, rect.y0]),
1339            font_weight: None,
1340            is_serif: None,
1341            is_monospace: None,
1342            // Filled: a field value is painted, not stroked and not hidden.
1343            render_mode: Some(0),
1344            // Placed, not recognized: no OCR layer wrote this.
1345            is_ocr_layer: false,
1346            font_metrics: None,
1347            geometry_mode: self.geometry_mode,
1348            bounds_source: BoundsSource::Estimate,
1349            tight_char_bounds: Vec::new(),
1350            glyph_advances: Vec::new(),
1351            glyph_bounds_sources: Vec::new(),
1352        });
1353
1354        // The pen follows, so that anything drawn after this reads its gap
1355        // against where the placed text ended rather than against a glyph
1356        // three widgets back.
1357        self.last_y = rect.y0;
1358        self.last_end_x = rect.x1;
1359        self.last_widened = 0.0;
1360        self.pending_tj_offset = 0.0;
1361    }
1362
1363    /// Hand back the ligature code points the font declared -- `ffi` rather than
1364    /// `ffi` -- from the TEXT outputs ([`Self::into_text`], [`Self::into_blocks`]).
1365    ///
1366    /// The default folds, because the extracted string is read by a search
1367    /// index, a RAG chunker or a person pasting a word into a search box, and
1368    /// none of them will match a ligature code point against the letters
1369    /// somebody types. Set this when the caller wants the file's own
1370    /// characters back: a typesetting tool, or a comparison against the
1371    /// content stream. The paragraph blocks ([`Self::into_paragraph_blocks`])
1372    /// are unfolded either way: they feed the decisions, and the fold happens
1373    /// after them (see [`crate::ligatures`]).
1374    #[must_use]
1375    pub fn keep_ligatures(mut self, keep: bool) -> Self {
1376        self.keep_ligatures = keep;
1377        self
1378    }
1379
1380    /// Merge glyphs into the runs the page draws, for the raw-order path
1381    /// (`ExtractOptions::raw_order`, #643).
1382    pub fn raw_runs(mut self, raw: bool) -> Self {
1383        self.raw_runs = raw;
1384        self
1385    }
1386
1387    /// Report the document's own font names and the descriptor's metrics on
1388    /// spans: the page's /BaseFont and /Ascent//Descent instead of the
1389    /// substitute face's. Off, because the default output is what it was
1390    /// before the E34 truth existed. Set this when the caller asked for the
1391    /// document truth through `ExtractOptions::document_font_names` (#643).
1392    #[must_use]
1393    pub fn document_font_names(mut self, report: bool) -> Self {
1394        self.document_font_names = report;
1395        self
1396    }
1397
1398    /// Apply `/ActualText` replacements to the spans. Off, because the
1399    /// default output is what it was before the replacements were read. Set
1400    /// this when the caller asked for the replacements through
1401    /// `ExtractOptions::actual_text` (#663).
1402    #[must_use]
1403    pub fn actual_text(mut self, apply: bool) -> Self {
1404        self.actual_text = apply;
1405        self
1406    }
1407
1408    /// Whether the device is inside an annotation appearance stream: a
1409    /// replacement there is the annotation's own statement about what it
1410    /// shows, so the marked-content stack is tracked even where replacements
1411    /// on the page stay off.
1412    fn in_annotation_appearance(&self) -> bool {
1413        !self.annotation_appearances.is_empty()
1414    }
1415
1416    /// Fold a painted box, in page space, into every open marked-content
1417    /// sequence: the marks a glyphless replacement reads at when it closes.
1418    fn note_drawn_box(&mut self, drawn: [f64; 4]) {
1419        for open in self.actual_text_stack.iter_mut() {
1420            open.extent = Some(match open.extent {
1421                Some(before) => union(&before, &drawn),
1422                None => drawn,
1423            });
1424        }
1425    }
1426
1427    /// Where a glyphless replacement reads: the marks its sequence drew, or
1428    /// the annotation showing the sequence when it drew nothing placeable.
1429    fn glyphless_placement(&self, extent: Option<[f64; 4]>) -> Option<kurbo::Rect> {
1430        if let Some([x0, y0, x1, y1]) = extent {
1431            if x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() {
1432                return Some(kurbo::Rect::new(x0, y0, x1, y1));
1433            }
1434        }
1435        self.annotation_appearances.last().copied()
1436    }
1437
1438    /// Read the replacement of a marked-content sequence that closes without
1439    /// a glyph having carried it: once, at the marks it drew. A sequence
1440    /// that already answered -- on a glyph, or through a nested sequence
1441    /// that did -- stays quiet, so a replacement inside a replacement reads
1442    /// once and the innermost answers.
1443    fn emit_glyphless_replacement(&mut self, entry: ActualTextEntry) {
1444        let answered = entry.consumed || entry.child_resolved;
1445        if answered || entry.saw_glyph || entry.actual_text.is_none() {
1446            // The sequence already stands in the text -- its replacement
1447            // read on a glyph, a nested sequence answered for it, or its
1448            // glyphs read as drawn -- or it carries no replacement. A nested
1449            // answer still quiets the sequence around it.
1450            if answered {
1451                if let Some(parent) = self.actual_text_stack.last_mut() {
1452                    parent.child_resolved = true;
1453                }
1454            }
1455            return;
1456        }
1457        let replacement = entry.actual_text.as_deref().unwrap_or("");
1458        if let Some(at) = self.glyphless_placement(entry.extent) {
1459            if !replacement.is_empty() {
1460                self.push_placed_span(replacement, at);
1461            }
1462        }
1463        if let Some(parent) = self.actual_text_stack.last_mut() {
1464            parent.child_resolved = true;
1465        }
1466    }
1467
1468    /// Drop the spans whose centre falls outside `rect` (`[x0, y0, x1, y1]`
1469    /// in user space, y up), before segmentation turns the rest into blocks.
1470    /// Called with the rectangle `ExtractOptions::clip_margins` leaves on the
1471    /// page: content the margins cover is never read.
1472    pub(crate) fn clip_spans(&mut self, rect: [f64; 4]) {
1473        self.spans.retain(|span| {
1474            let centre = (span.x + span.width * 0.5, span.y + span.height * 0.5);
1475            centre.0 >= rect[0] && centre.0 <= rect[2] && centre.1 >= rect[1] && centre.1 <= rect[3]
1476        });
1477    }
1478
1479    /// Refresh the cached median char width. Called lazily from
1480    /// `draw_glyph` to keep the hot path cheap.
1481    fn refresh_median_char_width(&mut self) {
1482        if self.glyph_widths.is_empty() {
1483            self.cached_median_char_width = 0.0;
1484            return;
1485        }
1486        let mut sorted = self.glyph_widths.clone();
1487        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1488        self.cached_median_char_width = sorted[sorted.len() / 2];
1489    }
1490
1491    /// Move the pen to the end of the glyph just drawn.
1492    ///
1493    /// ANN[r17/TEX5] A combining mark -- a Hebrew point, an Arabic harakat --
1494    /// is drawn on the glyph before it and leaves the pen where it was. Letting
1495    /// it move the pen back to its own origin makes the next letter look an
1496    /// advance away, and that gap then reads as a word break: `שָׁלוֹם` came
1497    /// apart into `שָׁל` and `וֹם` (#436). The declared advance cannot answer
1498    /// this, because `glyph_width_and_source` clamps a zero one up to a quarter
1499    /// of the font size before anything sees it.
1500    fn advance_pen(&mut self, text: &str, glyph_end_x: f64, widened: f64) {
1501        if !crate::bidi::is_only_marks(text) {
1502            self.last_end_x = glyph_end_x;
1503            self.last_widened = widened;
1504        }
1505    }
1506
1507    /// Move the raw pen past a glyph, for the turned-run judgment (#643).
1508    /// A no-op unless [`Self::raw_runs`] is on; marks never move it, exactly
1509    /// like [`Self::advance_pen`].
1510    fn advance_raw_pen(
1511        &mut self,
1512        text: &str,
1513        x: f64,
1514        y: f64,
1515        adv_x: f64,
1516        adv_y: f64,
1517        glyph_width: f64,
1518    ) {
1519        if !self.raw_runs || crate::bidi::is_only_marks(text) {
1520            return;
1521        }
1522        self.raw_pen = [x + adv_x * glyph_width, y + adv_y * glyph_width];
1523        self.raw_dir = [adv_x, adv_y];
1524        self.raw_pen_valid = true;
1525    }
1526
1527    /// Decide whether a space should be glued between two glyphs within
1528    /// the same span. Returns (insert_space, start_new_span).
1529    fn evaluate_space_consensus(
1530        &self,
1531        gap: f64,
1532        font_size: f64,
1533        prev_text: &str,
1534        next_text: &str,
1535    ) -> bool {
1536        let mut confidence = 0.0;
1537
1538        // Signal 1 — TJ offset (highest confidence). Raw units; a full
1539        // space is ~250. Anything over TJ_SPACE_THRESHOLD_UNITS counts.
1540        //
1541        // ONLY A NEGATIVE ONE, because only a negative TJ moves the next glyph
1542        // right, away from the last. A positive one is a pair kern pulling it
1543        // back: `[(FINANCIAL RESUL)134.6(TS)] TJ` in NYSE_MS_2001 overlaps
1544        // its `L` and `T` by a point, and counted by its size it wrote
1545        // `FINANCIAL RESUL TS` and `MANAGEMENT'S DISCUSSION AND ANAL YSIS`,
1546        // two headings the ground truth could not match (#638 rank 4).
1547        if self.pending_tj_offset <= -TJ_SPACE_THRESHOLD_UNITS {
1548            confidence += TJ_SIGNAL_WEIGHT;
1549        }
1550
1551        // Signal 2 — geometric gap. Prefer the adaptive median-char-width
1552        // reference; fall back to font-size when the median hasn't been
1553        // established yet (first few glyphs on a page).
1554        let gap_reference = if self.cached_median_char_width > 0.0 {
1555            self.cached_median_char_width * GAP_TO_MEDIAN_CHAR_FRACTION
1556        } else {
1557            font_size * GAP_TO_FONT_SIZE_FALLBACK_FRACTION
1558        };
1559        if gap > gap_reference {
1560            confidence += GAP_SIGNAL_WEIGHT;
1561        }
1562
1563        // Signal 3 — character-class transitions. Only checked when the
1564        // previous span ends with a character and the incoming text starts
1565        // with one; avoids double-counting with punctuation.
1566        if let (Some(prev_last), Some(next_first)) =
1567            (prev_text.chars().last(), next_text.chars().next())
1568        {
1569            let camel = prev_last.is_lowercase() && next_first.is_uppercase();
1570            let digit_to_letter = prev_last.is_ascii_digit() && next_first.is_alphabetic();
1571            let letter_to_digit = prev_last.is_alphabetic() && next_first.is_ascii_digit();
1572            if camel || digit_to_letter || letter_to_digit {
1573                confidence += HEURISTIC_SIGNAL_WEIGHT;
1574            }
1575        }
1576
1577        confidence >= SPACE_CONSENSUS_THRESHOLD
1578    }
1579
1580    /// Whether this gap is the typesetter's TRACKING rather than a word space
1581    /// (#582).
1582    ///
1583    /// A display line set in letter-spaced capitals puts the same distance
1584    /// between every pair of letters, and that distance is over every
1585    /// threshold a word space has: the TJ adjustment is a full one and the
1586    /// geometric gap is far past a third of a character. So the consensus
1587    /// fires on EVERY pair and `DIRECTORS' REPORT` comes out as
1588    /// `D I R E C TO R S ' R E P O R T` -- which is 47 of the 200 pages of
1589    /// run `structured-doclaynet-20260922T081244Z` writing text the ground
1590    /// truth does not have, and, on the heading axis, 65 of 154 missed
1591    /// headings (#583).
1592    ///
1593    /// WHAT SEPARATES THEM IS NOT THE LETTERS BUT THE REGULARITY. Tracking is
1594    /// a constant added to every advance, so a tracked run's gaps agree with
1595    /// one another; prose has word spaces at the ends of words and kerning
1596    /// everywhere else, and four consecutive gaps that agree within a quarter
1597    /// while every one of them is over the word-space threshold is not a
1598    /// sentence. Nothing here reads the text, so it holds for a language whose
1599    /// capitals this file has never seen.
1600    ///
1601    /// AND THE REAL WORD SPACE IS NOT LOST, because a tracked line still draws
1602    /// its word space as a space GLYPH: `DIRECTORS' REPORT` carries a 0x20 in
1603    /// the string the page shows, and that arrives as text rather than as a
1604    /// gap for this to judge.
1605    ///
1606    /// WHERE IT PAYS, AND WHY THAT IS A CHOICE AND NOT AN OVERSIGHT. A page
1607    /// that draws `A B C D E F` as separate glyphs with equal gaps and no
1608    /// space glyphs is, byte for byte, the same page as a letter-spaced
1609    /// display line: both are single characters at one constant advance, and
1610    /// nothing on the page says which the author meant. This takes the reading
1611    /// that is far more common and that the corpus measured -- letter-spacing,
1612    /// 47 of 200 pages -- and pays for it on a line of isolated single-letter
1613    /// words, which is rare. The price is pinned by
1614    /// `a_line_of_single_letter_words_reads_as_tracking_and_that_is_the_choice`
1615    /// so that a later reader argues with the note rather than with a silence.
1616    fn a_tracked_run(&self, gap: f64) -> bool {
1617        if gap <= 0.0 || self.recent_gaps.len() < TRACKING_MIN_PAIRS {
1618            return false;
1619        }
1620        self.recent_gaps
1621            .iter()
1622            .rev()
1623            .take(TRACKING_MIN_PAIRS)
1624            .all(|before| (before - gap).abs() <= TRACKING_TOLERANCE * gap)
1625    }
1626
1627    /// Take back the spaces already written into a run now recognised as
1628    /// tracked.
1629    ///
1630    /// The regularity needs [`TRACKING_MIN_PAIRS`] earlier gaps before it can be
1631    /// seen, so the first letters of the run have had their spaces inserted
1632    /// already: without this, `DIRECTORS` comes back as `D I R E CTORS`.
1633    ///
1634    /// ONLY THE SPACES THE CONSENSUS PUT IN, AT THE TRACKING (#623). The join
1635    /// used to take back every space between single characters at the end of
1636    /// the text, and a space the page DREW looks the same there: `IT ALL ADDS
1637    /// UP` (NYSE_WAL_2014) draws its word spaces as glyphs, and the run came out
1638    /// `ITALL`. It also stopped at the first piece longer than one letter, so a
1639    /// narrow `I` that took no space left `D IR ECTORS`. A space goes when the
1640    /// consensus inserted it at a gap the tracking explains, inside the run
1641    /// the tracking was seen in, between two letters; a word space set wider
1642    /// than the tracking, drawn or not, stays -- and so does the space of
1643    /// `suppose that`, which a formula's regular gaps further on matched
1644    /// (1201.4397).
1645    fn take_back_tracked_spaces(
1646        text: &mut String,
1647        inserted: &mut Vec<(usize, f64)>,
1648        run_start: usize,
1649        tracking: f64,
1650    ) {
1651        let between_letters = |at: usize| {
1652            text[..at]
1653                .chars()
1654                .next_back()
1655                .is_some_and(char::is_alphabetic)
1656                && text[at + 1..]
1657                    .chars()
1658                    .next()
1659                    .is_some_and(char::is_alphabetic)
1660        };
1661        let goes = |at: usize, gap: f64| {
1662            at >= run_start
1663                && at < text.len()
1664                && text.as_bytes()[at] == b' '
1665                && (gap - tracking).abs() <= TRACKING_TOLERANCE * tracking
1666                && between_letters(at)
1667        };
1668        if !inserted.iter().any(|&(at, gap)| goes(at, gap)) {
1669            return;
1670        }
1671        let goes: Vec<bool> = inserted.iter().map(|&(at, gap)| goes(at, gap)).collect();
1672        let bytes = text.as_bytes();
1673        let mut kept = String::with_capacity(text.len());
1674        let mut still: Vec<(usize, f64)> = Vec::with_capacity(inserted.len());
1675        let mut from = 0;
1676        for (&(at, gap), &goes) in inserted.iter().zip(&goes) {
1677            if at < from || at >= text.len() || bytes[at] != b' ' {
1678                continue;
1679            }
1680            kept.push_str(&text[from..at]);
1681            if goes {
1682                from = at + 1;
1683            } else {
1684                still.push((kept.len(), gap));
1685                kept.push(' ');
1686                from = at + 1;
1687            }
1688        }
1689        kept.push_str(&text[from..]);
1690        *text = kept;
1691        *inserted = still;
1692    }
1693
1694    /// Keep a drawn path when it is a horizontal rule (#545).
1695    ///
1696    /// The whole of the page's graphics arrives here and almost none of it is
1697    /// kept: what is, is ink that is a line -- flat enough not to be a box and
1698    /// long enough to separate two rows -- recorded in the page space the
1699    /// spans already live in, so the two can be compared without a second
1700    /// coordinate system to keep in step. The box is the transformed bounding
1701    /// box, which for a rotated path is the box around it and therefore too
1702    /// tall to be a rule: a rule on a turned page is not read as one, and that
1703    /// is the safe way round.
1704    ///
1705    /// A stroke is measured on its centreline, since that is the path the
1706    /// device is given and the weight is a property of the pen. A heavy rule is
1707    /// still a rule, and it still lies where its centreline lies.
1708    fn record_rule(&mut self, path: &BezPath, transform: Affine) {
1709        // The cap is asked at the door AND at every push. One path may hold
1710        // hundreds of subpaths, so a check that runs once per call is not a
1711        // bound on anything: it lets a single compound path put thousands of
1712        // entries in a vector `region_is_ruled` then walks per band.
1713        if self.rules.len() >= MAX_RECORDED_RULES {
1714            return;
1715        }
1716        // SUBPATH BY SUBPATH, because a producer is free to draw all of a
1717        // table's rules in one path, and a box around the lot of them is a box
1718        // and not a line. The bound is taken over the points a segment is made
1719        // of, curve controls included -- a curve lies inside its own hull, so
1720        // the box is never too small, and the arithmetic is a comparison per
1721        // point rather than solving each curve for its extremes. On the slowest
1722        // document of the gate set, which draws a figure of several thousand
1723        // segments, exact curve bounds cost 7% of its 3.3 s; this costs 1.6%.
1724        // WHAT IS DRAWN IS NOT WHAT IS SEEN. A Form XObject's `/BBox` reaches
1725        // this device as a clip and the geometry inside it unclipped, so a line
1726        // ruled across one cell of a form can arrive as a line across the page.
1727        // Two of those are enough for `region_is_ruled` to refuse a column cut
1728        // that should have been taken, so the extent recorded is the visible
1729        // one.
1730        let clip = self.clip_stack.last().copied();
1731        let mut lo = (f64::INFINITY, f64::INFINITY);
1732        let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
1733        let flush = |lo: &mut (f64, f64), hi: &mut (f64, f64), rules: &mut Vec<HorizontalRule>| {
1734            let (mut left, mut right) = (lo.0, hi.0);
1735            let (mut bottom, mut top) = (lo.1, hi.1);
1736            if let Some(clip) = clip {
1737                left = left.max(clip.x0);
1738                right = right.min(clip.x1);
1739                bottom = bottom.max(clip.y0);
1740                top = top.min(clip.y1);
1741            }
1742            if lo.0.is_finite()
1743                && rules.len() < MAX_RECORDED_RULES
1744                && bottom <= top
1745                && hi.1 - lo.1 <= RULE_MAX_THICKNESS
1746                && right - left >= RULE_MIN_LENGTH
1747            {
1748                rules.push(HorizontalRule {
1749                    left,
1750                    right,
1751                    y: (lo.1 + hi.1) * 0.5,
1752                });
1753            }
1754            *lo = (f64::INFINITY, f64::INFINITY);
1755            *hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
1756        };
1757
1758        for element in path.elements() {
1759            if matches!(element, PathEl::MoveTo(_) | PathEl::ClosePath) {
1760                flush(&mut lo, &mut hi, &mut self.rules);
1761            }
1762            for point in element_points(element) {
1763                let p = transform * point;
1764                lo = (lo.0.min(p.x), lo.1.min(p.y));
1765                hi = (hi.0.max(p.x), hi.1.max(p.y));
1766            }
1767        }
1768        flush(&mut lo, &mut hi, &mut self.rules);
1769    }
1770
1771    /// Keep a drawn path's straight axis-aligned strokes for the lattice
1772    /// (#574): each subpath that is a thin line, in either direction, and the
1773    /// four edges of each subpath that is an axis-aligned rectangle -- the way
1774    /// a timetable draws its cells. Anything else, a curve or a slanted line,
1775    /// is not a grid and is not kept.
1776    fn record_rulings(&mut self, path: &BezPath, transform: Affine) {
1777        if self.rulings.len() >= MAX_RECORDED_RULINGS {
1778            return;
1779        }
1780        let clip = self.clip_stack.last().copied();
1781        let mut points: Vec<Point> = Vec::new();
1782        let mut curved = false;
1783        let mut subpaths: Vec<(Vec<Point>, bool)> = Vec::new();
1784        for element in path.elements() {
1785            if matches!(element, PathEl::MoveTo(_)) && !points.is_empty() {
1786                subpaths.push((std::mem::take(&mut points), curved));
1787                curved = false;
1788            }
1789            if matches!(element, PathEl::QuadTo(..) | PathEl::CurveTo(..)) {
1790                curved = true;
1791            }
1792            for point in element_points(element) {
1793                points.push(transform * point);
1794            }
1795        }
1796        if !points.is_empty() {
1797            subpaths.push((points, curved));
1798        }
1799        for (points, curved) in subpaths {
1800            if curved || points.len() < 2 {
1801                continue;
1802            }
1803            let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);
1804            let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
1805            for p in &points {
1806                x0 = x0.min(p.x);
1807                y0 = y0.min(p.y);
1808                x1 = x1.max(p.x);
1809                y1 = y1.max(p.y);
1810            }
1811            let clipped = |r: Ruling| -> Option<Ruling> {
1812                let Some(c) = clip else {
1813                    return Some(r);
1814                };
1815                let r = Ruling {
1816                    x0: r.x0.max(c.x0),
1817                    y0: r.y0.max(c.y0),
1818                    x1: r.x1.min(c.x1),
1819                    y1: r.y1.min(c.y1),
1820                };
1821                (r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r)
1822            };
1823            let (w, h) = (x1 - x0, y1 - y0);
1824            let mut found: Vec<Ruling> = Vec::new();
1825            if h <= RULE_MAX_THICKNESS && w >= RULING_MIN_LENGTH {
1826                let y = (y0 + y1) * 0.5;
1827                found.push(Ruling {
1828                    x0,
1829                    y0: y,
1830                    x1,
1831                    y1: y,
1832                });
1833            } else if w <= RULE_MAX_THICKNESS && h >= RULING_MIN_LENGTH {
1834                let x = (x0 + x1) * 0.5;
1835                found.push(Ruling {
1836                    x0: x,
1837                    y0,
1838                    x1: x,
1839                    y1,
1840                });
1841            } else if w > RULE_MAX_THICKNESS && h > RULE_MAX_THICKNESS {
1842                // A rectangle, and only if every point is one of its corners.
1843                let corner = |p: &Point| {
1844                    ((p.x - x0).abs() < 0.5 || (p.x - x1).abs() < 0.5)
1845                        && ((p.y - y0).abs() < 0.5 || (p.y - y1).abs() < 0.5)
1846                };
1847                if points.iter().all(corner) && points.len() <= 6 {
1848                    found.push(Ruling { x0, y0, x1, y1: y0 });
1849                    found.push(Ruling { x0, y0: y1, x1, y1 });
1850                    found.push(Ruling { x0, y0, x1: x0, y1 });
1851                    found.push(Ruling { x0: x1, y0, x1, y1 });
1852                }
1853            }
1854            for r in found.into_iter().filter_map(clipped) {
1855                if self.rulings.len() >= MAX_RECORDED_RULINGS {
1856                    return;
1857                }
1858                self.rulings.push(r);
1859            }
1860        }
1861    }
1862
1863    /// The lattice rulings the page drew so far, taken out of the device
1864    /// (#574). Called before the device is consumed into its blocks.
1865    #[doc(hidden)]
1866    /// Keep a filled path that is a solid, non-white, axis-aligned rectangle
1867    /// big enough to stand behind a line of text (#574): shading, a
1868    /// highlighted row, a note's box. Anything curved or patterned, anything
1869    /// near white -- a page's own background -- and anything past the cap is
1870    /// left out.
1871    fn record_fill(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'_>) {
1872        if self.fills.len() >= MAX_RECORDED_RULINGS {
1873            return;
1874        }
1875        let Paint::Color(color) = paint else {
1876            return;
1877        };
1878        let rgba = color.to_rgba().to_rgba8();
1879        let [r, g, b, a] = rgba;
1880        let luminance = (299 * u32::from(r) + 587 * u32::from(g) + 114 * u32::from(b)) / 1000;
1881        if a < 26 || luminance >= FILL_WHITE_ABOVE {
1882            return;
1883        }
1884        let mut points: Vec<Point> = Vec::new();
1885        for element in path.elements() {
1886            match element {
1887                PathEl::QuadTo(..) | PathEl::CurveTo(..) => return,
1888                PathEl::MoveTo(_) if !points.is_empty() => return,
1889                _ => points.extend(element_points(element).map(|p| transform * p)),
1890            }
1891        }
1892        if points.len() < 4 {
1893            return;
1894        }
1895        let rect = transform.transform_rect_bbox(path.bounding_box());
1896        // Axis-aligned: every corner on the box's edges.
1897        let on_edge = |p: &Point| {
1898            ((p.x - rect.x0).abs() < 0.5 || (p.x - rect.x1).abs() < 0.5)
1899                && ((p.y - rect.y0).abs() < 0.5 || (p.y - rect.y1).abs() < 0.5)
1900        };
1901        if !points.iter().all(on_edge) {
1902            return;
1903        }
1904        let mut rect = rect;
1905        if let Some(clip) = self.clip_stack.last() {
1906            rect = rect.intersect(*clip);
1907        }
1908        if rect.width() >= FILL_MIN_WIDTH && rect.height() >= FILL_MIN_HEIGHT {
1909            self.fills.push([rect.x0, rect.y0, rect.x1, rect.y1]);
1910        }
1911    }
1912
1913    /// The fills [`Self::record_fill`] kept, in page space.
1914    pub fn take_fills(&mut self) -> Vec<[f64; 4]> {
1915        std::mem::take(&mut self.fills)
1916    }
1917
1918    pub fn take_rulings(&mut self) -> Vec<Ruling> {
1919        std::mem::take(&mut self.rulings)
1920    }
1921
1922    /// The boxes the page drew its images into, `[left, bottom, right, top]`
1923    /// in page space, in drawing order (#636).
1924    #[doc(hidden)]
1925    pub fn take_images(&mut self) -> Vec<[f64; 4]> {
1926        std::mem::take(&mut self.images)
1927    }
1928
1929    /// The figures the page DRAWS rather than places as images, as boxes in
1930    /// page space (#636): the path boxes that touch or overlap, merged; a
1931    /// cluster counts when it holds at least three paths and is at least 20
1932    /// points each way, and not when it covers more than half the page -- a
1933    /// page's background or frame is not a figure anyone captions.
1934    #[doc(hidden)]
1935    pub fn take_graphics(&mut self, width: f64, height: f64) -> Vec<[f64; 4]> {
1936        let boxes = std::mem::take(&mut self.path_boxes);
1937        let mut clusters: Vec<([f64; 4], usize)> = Vec::new();
1938        for b in boxes {
1939            let mut merged = (b, 1usize);
1940            let mut rest = Vec::with_capacity(clusters.len());
1941            for c in clusters {
1942                if touches(&merged.0, &c.0, GRAPHIC_GAP) {
1943                    merged = (union(&merged.0, &c.0), merged.1 + c.1);
1944                } else {
1945                    rest.push(c);
1946                }
1947            }
1948            rest.push(merged);
1949            clusters = rest;
1950        }
1951        let page = (width * height).max(1.0);
1952        clusters
1953            .into_iter()
1954            .filter(|(b, n)| {
1955                let (w, h) = (b[2] - b[0], b[3] - b[1]);
1956                *n >= 3 && w >= 20.0 && h >= 20.0 && w * h <= 0.5 * page
1957            })
1958            .map(|(b, _)| b)
1959            .collect()
1960    }
1961
1962    fn record_path_box(&mut self, path: &BezPath, transform: Affine) {
1963        if self.path_boxes.len() >= MAX_RECORDED_PATH_BOXES {
1964            return;
1965        }
1966        let mut rect = transform.transform_rect_bbox(path.bounding_box());
1967        if let Some(clip) = self.clip_stack.last() {
1968            rect = rect.intersect(*clip);
1969        }
1970        // A THIN LINE IS A RULE, a ruling or an underline -- the table and
1971        // paragraph passes read those -- and not a part of a figure.
1972        if rect.width().min(rect.height()) >= 1.5 {
1973            self.path_boxes.push([rect.x0, rect.y0, rect.x1, rect.y1]);
1974        }
1975    }
1976
1977    /// Record where an image lands: its PIXEL rectangle under the transform
1978    /// the interpreter hands over -- which maps `0..width, 0..height`, not the
1979    /// unit square of the content stream's `cm` -- clipped to the visible box. A sliver under a point wide is not a picture
1980    /// anyone would caption -- it is a rule or a dot drawn as an image -- and
1981    /// is left out, and so is anything past the cap.
1982    fn record_image(&mut self, width: u32, height: u32, transform: Affine) {
1983        if self.images.len() >= MAX_RECORDED_IMAGES {
1984            return;
1985        }
1986        let (w, h) = (f64::from(width.max(1)), f64::from(height.max(1)));
1987        let mut rect = transform.transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w, h));
1988        if let Some(clip) = self.clip_stack.last() {
1989            rect = rect.intersect(*clip);
1990        }
1991        if rect.width() >= 1.0 && rect.height() >= 1.0 {
1992            self.images.push([rect.x0, rect.y0, rect.x1, rect.y1]);
1993        }
1994    }
1995
1996    /// Consume the device and return extracted text as a single string.
1997    pub fn into_text(mut self) -> String {
1998        self.end_last_glyph();
1999        if self.geometry_mode == GeometryMode::RichGeometry {
2000            self.compute_tight_bounds();
2001        }
2002        self.spans.iter_mut().for_each(untrack_spaced_letters);
2003        let keep_ligatures = self.keep_ligatures;
2004        let rules = std::mem::take(&mut self.rules);
2005        let mut blocks = group_spans_into_blocks(self.spans, &rules);
2006        if !keep_ligatures {
2007            // Folded on the spans, which the lines assemble from: the one
2008            // fold every TEXT output shares (see `crate::ligatures`).
2009            for block in &mut blocks {
2010                crate::ligatures::fold_text_block(block);
2011            }
2012        }
2013        let lines: Vec<String> = blocks.iter().map(|b| b.text()).collect();
2014        let stitched = stitch_hyphenated_lines(&lines);
2015        normalize_text_output(&stitched)
2016    }
2017
2018    /// Consume the device and return text blocks.
2019    pub fn into_blocks(mut self) -> Vec<TextBlock> {
2020        self.end_last_glyph();
2021        if self.geometry_mode == GeometryMode::RichGeometry {
2022            self.compute_tight_bounds();
2023        }
2024        self.spans.iter_mut().for_each(untrack_spaced_letters);
2025        let keep_ligatures = self.keep_ligatures;
2026        let rules = std::mem::take(&mut self.rules);
2027        let mut blocks = group_spans_into_blocks(self.spans, &rules);
2028        if !keep_ligatures {
2029            for block in &mut blocks {
2030                crate::ligatures::fold_text_block(block);
2031            }
2032        }
2033        blocks
2034    }
2035
2036    /// Consume the device and return the page's paragraphs (#567).
2037    ///
2038    /// `page` is the 0-based page index the blocks record, and `first_index`
2039    /// where this page's blocks start in the document's reading order, so a
2040    /// caller walking a document hands the count of what it has emitted so far.
2041    pub fn into_paragraph_blocks(mut self, page: usize, first_index: usize) -> Vec<Block> {
2042        self.end_last_glyph();
2043        if self.geometry_mode == GeometryMode::RichGeometry {
2044            self.compute_tight_bounds();
2045        }
2046        self.spans.iter_mut().for_each(untrack_spaced_letters);
2047        let rules = std::mem::take(&mut self.rules);
2048        // Unfolded: these blocks feed the layout and model decisions and the
2049        // training dump, which all read the characters the glyphs declared.
2050        // The fold happens on the way out of `PdfDocument`, after the
2051        // decisions (see `crate::ligatures`). Every table stands: the
2052        // single-page path takes no options, so no strategy steers it.
2053        paragraph_blocks(self.spans, &rules, page, first_index, TableStrategy::Both)
2054    }
2055
2056    /// The paragraphs AND the rules the page drew, in one pass (#600).
2057    ///
2058    /// The blocks are what a reader wants and the rules are what a model
2059    /// wants: a cell boundary in a ruled table is a drawn line, and a learned
2060    /// table structure that cannot see the lines is guessing at the strongest
2061    /// evidence the page offers. They come back together because the
2062    /// alternative is interpreting the page twice, and two passes over one
2063    /// page is two chances for them to disagree.
2064    ///
2065    /// Hidden rather than published: this is what
2066    /// `crates/pdf-learned`'s feature dump reads, and the shape of it follows
2067    /// whatever that needs rather than a promise to anyone else.
2068    #[doc(hidden)]
2069    pub fn into_paragraph_blocks_and_rules(
2070        self,
2071        page: usize,
2072        first_index: usize,
2073    ) -> (Vec<Block>, Vec<HorizontalRule>) {
2074        self.into_paragraph_blocks_and_rules_with_strategy(
2075            page,
2076            first_index,
2077            TableStrategy::Both,
2078            false,
2079        )
2080    }
2081
2082    /// [`Self::into_paragraph_blocks_and_rules`], with the table-detection
2083    /// strategy given: under [`TableStrategy::Lines`] a grid no drawn rule
2084    /// crosses goes back to the paragraph merge (#659).
2085    ///
2086    /// `cell_path_words` is
2087    /// [`ExtractOptions::cell_path_words`](crate::ExtractOptions::cell_path_words):
2088    /// the leader fold and the line and cell texts read with the cell-path
2089    /// word rules where true.
2090    ///
2091    /// Hidden like the twin: this is what `extract_blocks` reads, and the
2092    /// shape of it follows whatever that needs rather than a promise to
2093    /// anyone else.
2094    #[doc(hidden)]
2095    pub fn into_paragraph_blocks_and_rules_with_strategy(
2096        mut self,
2097        page: usize,
2098        first_index: usize,
2099        table_strategy: TableStrategy,
2100        cell_path_words: bool,
2101    ) -> (Vec<Block>, Vec<HorizontalRule>) {
2102        self.end_last_glyph();
2103        if self.geometry_mode == GeometryMode::RichGeometry {
2104            self.compute_tight_bounds();
2105        }
2106        self.spans.iter_mut().for_each(untrack_spaced_letters);
2107        let rules = std::mem::take(&mut self.rules);
2108        let blocks = paragraph_blocks_with_cell_path_words(
2109            self.spans,
2110            &rules,
2111            page,
2112            first_index,
2113            table_strategy,
2114            cell_path_words,
2115        );
2116        (blocks, rules)
2117    }
2118
2119    /// Consume the device and return the page's blocks in raw content-stream
2120    /// order (#643).
2121    ///
2122    /// Requires [`Self::raw_runs`]: the spans must be the runs the page
2123    /// draws, in the order it draws them. Blocks follow draw order with no
2124    /// reading-order sort; the frames turn with the page exactly as the
2125    /// default path's do.
2126    pub fn into_raw_blocks(mut self, page: usize, first_index: usize) -> Vec<Block> {
2127        self.end_last_glyph();
2128        debug_assert!(self.raw_runs, "raw blocks need raw runs");
2129        if self.geometry_mode == GeometryMode::RichGeometry {
2130            self.compute_tight_bounds();
2131        }
2132        self.spans.iter_mut().for_each(untrack_spaced_letters);
2133        // The page ends every run it drew (#643).
2134        if let Some(last) = self.spans.last_mut() {
2135            last.complete_run = true;
2136        }
2137        let bdc = std::mem::take(&mut self.raw_bdc);
2138        debug_assert_eq!(bdc.len(), self.spans.len());
2139        raw_blocks(self.spans, &bdc, page, first_index)
2140    }
2141
2142    /// Consume the device and return raw spans.
2143    #[allow(dead_code)]
2144    pub(crate) fn into_spans(mut self) -> Vec<TextSpan> {
2145        self.end_last_glyph();
2146        if self.geometry_mode == GeometryMode::RichGeometry {
2147            self.compute_tight_bounds();
2148        }
2149        self.spans.iter_mut().for_each(untrack_spaced_letters);
2150        self.spans
2151    }
2152
2153    /// Forget the last glyph where the device ends: no glyph follows, so its
2154    /// place is never read again and [`LAST_GLYPH`] no longer names this
2155    /// device.
2156    fn end_last_glyph(&mut self) {
2157        self.last_glyph_text = None;
2158        self.holds_last_glyph = false;
2159        self.last_glyph_guard.clear();
2160    }
2161
2162    /// Remember `glyph` as the last glyph and `record` as where its text went.
2163    ///
2164    /// A glyph the page hides (`hidden`), or one whose text lands in a span
2165    /// the page hides, is not remembered: hidden text can be dropped from the
2166    /// output after the fact, and a mark that went into it would go with it.
2167    /// Not read again, its offer is refused and the mark reads as before its
2168    /// row (spec review 14-10 02:00 (ii)).
2169    fn remember_last_glyph(
2170        &mut self,
2171        glyph: &OutlineGlyph,
2172        mut record: LastGlyphText,
2173        hidden: bool,
2174    ) {
2175        let tail = self.spans.last();
2176        if hidden || tail.is_some_and(TextSpan::is_hidden) {
2177            self.last_glyph_text = None;
2178            return;
2179        }
2180        record.span_count = self.spans.len();
2181        record.tail_text = tail.map_or(0, |span| span.text.len());
2182        record.tail_boxes = tail.map_or(0, |span| span.char_bounds.len());
2183        record.place = glyph.place();
2184        if record.place.is_none() {
2185            self.last_glyph_text = None;
2186            return;
2187        }
2188        if !self.holds_last_glyph {
2189            let id = self.id;
2190            self.holds_last_glyph = LAST_GLYPH.try_with(|slot| slot.set(Some(id))).is_ok();
2191        }
2192        self.last_glyph_text = self.holds_last_glyph.then_some(record);
2193    }
2194
2195    /// Whether the last glyph's text still stands last in the last span.
2196    fn last_glyph_still_last(&self, last: &LastGlyphText) -> bool {
2197        let tail = self.spans.last();
2198        self.spans.len() == last.span_count
2199            && tail.map_or(0, |span| span.text.len()) == last.tail_text
2200            && tail.map_or(0, |span| span.char_bounds.len()) == last.tail_boxes
2201    }
2202
2203    /// Read the last glyph's text again and put what it reads now in place of
2204    /// what it read then (see [`LastGlyphText`]). The glyph is read only while
2205    /// its text still stands last, so that the read is never one this device
2206    /// cannot act on; nothing changes when the text is unchanged.
2207    fn reread_last_glyph(&mut self, glyph: &Glyph<'_>) {
2208        let Some(last) = self.last_glyph_text.as_ref() else {
2209            return;
2210        };
2211        if !self.last_glyph_still_last(last) {
2212            self.last_glyph_text = None;
2213            return;
2214        }
2215        // Only the glyph arriving now can have changed the last one, and it
2216        // says so; on a page without a row mark it never has.
2217        let read = match (glyph, last.place) {
2218            (Glyph::Outline(outline), Some(place)) => outline.reread_previous(place),
2219            _ => None,
2220        };
2221        let Some(read) = read else {
2222            return;
2223        };
2224        let last = self.last_glyph_text.as_mut().expect("checked above");
2225        if last.boxless {
2226            // It wrote nothing, and a glyph that read as nothing is a mark
2227            // whose read gave its offer up; there is nothing to put back.
2228            return;
2229        }
2230        let span = &mut self.spans[last.span];
2231        // Compared in place: on a page without mark rows the text never
2232        // changes, and the comparison then costs no allocation.
2233        let written = &span.text[last.offset..];
2234        let unchanged = match &read {
2235            Some(BfString::Char(c)) => written == c.encode_utf8(&mut [0; 4]),
2236            Some(BfString::String(s)) => written == s,
2237            None => written.is_empty(),
2238        };
2239        if unchanged {
2240            return;
2241        }
2242        let now = match read {
2243            Some(BfString::Char(c)) => c.to_string(),
2244            Some(BfString::String(s)) => s,
2245            None => String::new(),
2246        };
2247        span.text.truncate(last.offset);
2248        span.text.push_str(&now);
2249        last.len = now.len();
2250        last.tail_text = span.text.len();
2251        if !now.is_empty() {
2252            return;
2253        }
2254        // Read as nothing now: the glyph gives its box back, and a span it
2255        // opened goes with it.
2256        let last = self.last_glyph_text.take().expect("checked above");
2257        self.deferred_rich_glyphs.truncate(last.deferred);
2258        if last.opened {
2259            self.spans.pop();
2260            if self.raw_runs {
2261                self.raw_bdc.pop();
2262                if let (Some(prev), Some(was)) = (self.spans.last_mut(), last.previous_complete_run)
2263                {
2264                    prev.complete_run = was;
2265                }
2266            }
2267        } else {
2268            let span = &mut self.spans[last.span];
2269            span.char_bounds.truncate(last.char_bounds);
2270            span.width = last.width;
2271        }
2272    }
2273
2274    /// Record glyph-level data for deferred tight-bounds computation.
2275    /// Called from draw_glyph in RichGeometry mode only.
2276    fn record_deferred_glyph(
2277        &mut self,
2278        glyph: &Glyph<'_>,
2279        composed: &Affine,
2280        font_size: f64,
2281        glyph_width: f64,
2282    ) {
2283        let coeffs = composed.as_coeffs();
2284        let is_rotated = coeffs[1].abs() > ROTATION_EPSILON || coeffs[2].abs() > ROTATION_EPSILON;
2285        let is_sheared = (coeffs[0] - coeffs[3]).abs() > SHEAR_EPSILON;
2286        let needs_exact = is_rotated || is_sheared;
2287
2288        // Capture the glyph's ink bbox in raw glyph space. The full outline path
2289        // is retained only when `needs_exact` (rotated/sheared), so a tight bound
2290        // can bound the transformed path; axis-aligned glyphs keep only the bbox
2291        // (enveloping its transformed corners is exact) to bound memory on large
2292        // pages. Ink-less glyphs (spaces, Type3) carry no bbox → Estimate later.
2293        let (outline, font_bbox) = match glyph {
2294            Glyph::Outline(o) => {
2295                let path = o.outline();
2296                let bb = path.bounding_box();
2297                if bb.width() <= 0.0 && bb.height() <= 0.0 {
2298                    (None, None)
2299                } else if needs_exact {
2300                    (Some(path), Some(bb))
2301                } else {
2302                    (None, Some(bb))
2303                }
2304            }
2305            Glyph::Type3(_) => (None, None),
2306        };
2307
2308        self.deferred_rich_glyphs.push(DeferredGlyph {
2309            coeffs,
2310            font_size,
2311            glyph_width,
2312            needs_exact,
2313            outline,
2314            font_bbox,
2315        });
2316    }
2317
2318    /// Compute tight bounds for all recorded glyphs and populate the spans.
2319    /// Called once after all glyphs have been extracted.
2320    fn compute_tight_bounds(&mut self) {
2321        let deferred = std::mem::take(&mut self.deferred_rich_glyphs);
2322        if deferred.is_empty() {
2323            return;
2324        }
2325
2326        let mut idx = 0usize;
2327
2328        for span in &mut self.spans {
2329            let n = span.char_bounds.len();
2330            if n == 0 {
2331                continue;
2332            }
2333
2334            for _gi in 0..n {
2335                if idx >= deferred.len() {
2336                    break;
2337                }
2338                let dg = &deferred[idx];
2339                idx += 1;
2340
2341                let composed = Affine::new(dg.coeffs);
2342                let (tight_bound, source) = if let Some(font_bbox) = dg.font_bbox {
2343                    // Glyph has ink. The outline/bbox is in raw glyph space and
2344                    // `composed` (CTM * glyph_transform) maps it straight to page
2345                    // space — exactly as the renderer fills the raw outline, with
2346                    // NO upem pre-scaling. Applying any extra font_size/1000 here
2347                    // would double-scale the bound.
2348                    if dg.needs_exact {
2349                        // Rotated/sheared: bound the transformed outline path so
2350                        // the box stays tight (the transformed bbox would be
2351                        // looser). Falls back to enveloping the bbox corners if
2352                        // the path was not retained.
2353                        let page_bbox = match dg.outline {
2354                            Some(ref path) => (composed * path.clone()).bounding_box(),
2355                            None => {
2356                                let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
2357                                let b = transform_bbox_corners(&raw, &composed);
2358                                kurbo::Rect::new(b[0], b[1], b[2], b[3])
2359                            }
2360                        };
2361                        (
2362                            [page_bbox.x0, page_bbox.y0, page_bbox.x1, page_bbox.y1],
2363                            BoundsSource::Tight,
2364                        )
2365                    } else {
2366                        // Axis-aligned scale + translate: enveloping the
2367                        // transformed bbox corners is exact.
2368                        let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
2369                        (transform_bbox_corners(&raw, &composed), BoundsSource::Tight)
2370                    }
2371                } else {
2372                    // No ink (space, Type3, missing outline): fall back to the
2373                    // advance box in page space, in the same frame as the span's
2374                    // char_bounds ([x, y, x + advance, y + font_size]).
2375                    let x = dg.coeffs[4];
2376                    let y = dg.coeffs[5];
2377                    (
2378                        [x, y, x + dg.glyph_width, y + dg.font_size],
2379                        BoundsSource::Estimate,
2380                    )
2381                };
2382
2383                span.tight_char_bounds.push(tight_bound);
2384                span.glyph_advances.push(dg.glyph_width);
2385                span.glyph_bounds_sources.push(source);
2386            }
2387
2388            span.bounds_source = aggregate_bounds_source(&span.glyph_bounds_sources);
2389        }
2390    }
2391}
2392
2393impl Device<'_> for TextExtractionDevice {
2394    fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
2395    fn set_blend_mode(&mut self, _: BlendMode) {}
2396    fn begin_marked_content(&mut self, _tag: &[u8], _mcid: Option<i32>, actual_text: Option<&str>) {
2397        if self.actual_text || self.in_annotation_appearance() {
2398            self.actual_text_stack.push(ActualTextEntry {
2399                actual_text: actual_text.map(str::to_string),
2400                consumed: false,
2401                saw_glyph: false,
2402                child_resolved: false,
2403                extent: None,
2404            });
2405        }
2406        if !self.raw_runs {
2407            return;
2408        }
2409        let id = self.raw_bdc_next;
2410        self.raw_bdc_next += 1;
2411        self.raw_bdc_stack.push(id);
2412        self.raw_bdc_changed = true;
2413    }
2414    fn end_marked_content(&mut self) {
2415        if let Some(entry) = self.actual_text_stack.pop() {
2416            self.emit_glyphless_replacement(entry);
2417        }
2418        if !self.raw_runs {
2419            return;
2420        }
2421        self.raw_bdc_stack.pop();
2422        self.raw_bdc_changed = true;
2423    }
2424    fn draw_path(
2425        &mut self,
2426        path: &BezPath,
2427        transform: Affine,
2428        paint: &Paint<'_>,
2429        mode: &PathDrawMode,
2430    ) {
2431        self.record_rule(path, transform);
2432        self.record_rulings(path, transform);
2433        self.record_path_box(path, transform);
2434        if matches!(mode, PathDrawMode::Fill(_)) {
2435            self.record_fill(path, transform, paint);
2436        }
2437        if !self.actual_text_stack.is_empty() {
2438            let mut drawn = transform.transform_rect_bbox(path.bounding_box());
2439            if let Some(clip) = self.clip_stack.last() {
2440                drawn = drawn.intersect(*clip);
2441            }
2442            self.note_drawn_box([drawn.x0, drawn.y0, drawn.x1, drawn.y1]);
2443        }
2444    }
2445    fn begin_annotation_appearance(&mut self, rect: kurbo::Rect) {
2446        self.annotation_appearances.push(rect);
2447    }
2448    fn end_annotation_appearance(&mut self) {
2449        self.annotation_appearances.pop();
2450    }
2451    fn push_clip_path(&mut self, clip_path: &ClipPath) {
2452        // The clip arrives in page space already -- `render.rs` applies it
2453        // under the identity transform -- so its box needs no transform here.
2454        // The box and not the region: a clip shaped like a star still reaches
2455        // only its own bounding box, so this over-states what is visible and
2456        // never under-states it, which is the direction that cannot invent a
2457        // rule.
2458        let box_of_clip = clip_path.path.bounding_box();
2459        let effective = match self.clip_stack.last() {
2460            Some(outer) => outer.intersect(box_of_clip),
2461            None => box_of_clip,
2462        };
2463        self.clip_stack.push(effective);
2464    }
2465    fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
2466    fn draw_image(&mut self, image: Image<'_, '_>, transform: Affine) {
2467        self.record_image(image.width(), image.height(), transform);
2468        if !self.actual_text_stack.is_empty() {
2469            let (w, h) = (
2470                f64::from(image.width().max(1)),
2471                f64::from(image.height().max(1)),
2472            );
2473            let mut drawn = transform.transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w, h));
2474            if let Some(clip) = self.clip_stack.last() {
2475                drawn = drawn.intersect(*clip);
2476            }
2477            self.note_drawn_box([drawn.x0, drawn.y0, drawn.x1, drawn.y1]);
2478        }
2479    }
2480    fn pop_clip_path(&mut self) {
2481        self.clip_stack.pop();
2482    }
2483    fn pop_transparency_group(&mut self) {}
2484    fn draw_placed_text(&mut self, text: &str, rect: kurbo::Rect) {
2485        self.push_placed_span(text, rect);
2486    }
2487
2488    fn draw_glyph(
2489        &mut self,
2490        glyph: &Glyph<'_>,
2491        transform: Affine,
2492        glyph_transform: Affine,
2493        paint: &Paint<'_>,
2494        draw_mode: &GlyphDrawMode,
2495    ) {
2496        // A glyph whose text an `/ActualText` replacement stands in for, or
2497        // that the page hides and the output may drop, cannot carry a mark
2498        // offered across it, so the glyph before it is not read again: the
2499        // offer is then refused and both read as before (spec review (b),
2500        // review 14-10 02:00 (ii)).
2501        let color = paint_to_rgba(paint);
2502        let glyph_hidden = matches!(draw_mode, GlyphDrawMode::Invisible)
2503            || color.is_some_and(|color| color[3] == 0);
2504        let replaced = self.actual_text
2505            && self
2506                .actual_text_stack
2507                .iter()
2508                .any(|entry| entry.actual_text.is_some());
2509        if replaced || glyph_hidden {
2510            self.last_glyph_text = None;
2511        } else {
2512            self.reread_last_glyph(glyph);
2513        }
2514        // Whatever this glyph does, the glyph before it is no longer the last
2515        // one, unless this is the stroke that repeats its fill.
2516        let before = self.last_glyph_text.take();
2517        // The glyph is read once: a read decides a placement offered to it
2518        // and every later read answers the same, so reading it again here
2519        // would only repeat the font's lookup.
2520        let unicode = glyph.as_unicode();
2521
2522        // FILL AND STROKE IS ONE GLYPH (#623). Render mode 2 draws each glyph
2523        // filled and then stroked -- how a heading is made heavier without a
2524        // bold face -- and the interpreter hands both to the device, since a
2525        // renderer needs both. Read twice, `1.0 BACKGROUND` came out as
2526        // `BBAACCKKGGRROOUUNNDD` (GAMOBUDU LANDFILL, DocLayNet validation) and
2527        // the heading matched nothing. The stroke that repeats the fill just
2528        // before it, at the same transform, is the same character.
2529        let placed = (transform * glyph_transform).as_coeffs();
2530        let repeats_fill = matches!(draw_mode, GlyphDrawMode::Stroke(_))
2531            && self
2532                .last_fill
2533                .as_ref()
2534                .is_some_and(|(at, filled)| *at == placed && *filled == unicode);
2535        self.last_fill = match draw_mode {
2536            GlyphDrawMode::Fill => Some((placed, unicode.clone())),
2537            _ => None,
2538        };
2539        if repeats_fill {
2540            self.last_glyph_text = before;
2541            return;
2542        }
2543
2544        let reads_as_nothing = unicode.is_none();
2545        let glyph_text = match unicode {
2546            Some(BfString::Char(c)) => c.to_string(),
2547            Some(BfString::String(s)) => s,
2548            None => String::new(),
2549        };
2550
2551        let composed = transform * glyph_transform;
2552        let coeffs = composed.as_coeffs();
2553        let x = coeffs[4];
2554        let y = coeffs[5];
2555        let glyph_scale = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt().abs();
2556        let draw_size = glyph_scale * 1000.0;
2557        // Under raw runs the size is the area scale of the text matrix, so a
2558        // squashed line reports what it is set in (#643): `Deceased` reads
2559        // 6.9296, the geometric mean of its 6.86 by 7 matrix, where the first
2560        // column alone gives 6.86. Widths keep the drawn scale, so boxes and
2561        // gaps never move; the default path reports the drawn scale, as
2562        // before.
2563        let font_size = if self.raw_runs {
2564            (coeffs[0] * coeffs[3] - coeffs[1] * coeffs[2]).abs().sqrt() * 1000.0
2565        } else {
2566            draw_size
2567        };
2568
2569        // G2: distinguish real advance (Metric) from estimate (Estimate).
2570        let (glyph_width, glyph_ws) = glyph_width_and_source(glyph, draw_size);
2571        let widened = match glyph {
2572            Glyph::Outline(outline) => outline.advance_width().map_or(0.0, |w| {
2573                (glyph_width - f64::from(w) / 1000.0 * font_size).max(0.0)
2574            }),
2575            Glyph::Type3(_) => 0.0,
2576        };
2577        let glyph_end_x = x + glyph_width;
2578        let glyph_bound = [x, y, glyph_end_x, y + font_size];
2579
2580        let style = derive_glyph_style(glyph, self.document_font_names);
2581
2582        // An `/ActualText` replacement reads instead of the glyphs of its
2583        // marked run, once, on the run's first glyph; the box of that glyph
2584        // stays whole for however many letters the replacement holds. The
2585        // innermost open sequence carrying one wins, and a run replaced by
2586        // nothing reads as nothing. Later glyphs of the same run add no
2587        // text and no box, but the pen still moves past what was drawn, so
2588        // the gap after the run is measured from where the run ended.
2589        let actual_idx = if self.actual_text {
2590            self.actual_text_stack
2591                .iter()
2592                .rposition(|e| e.actual_text.is_some())
2593        } else {
2594            None
2595        };
2596        let text = if let Some(idx) = actual_idx {
2597            let replacement = if self.actual_text_stack[idx].consumed {
2598                String::new()
2599            } else {
2600                let found = self.actual_text_stack[idx]
2601                    .actual_text
2602                    .clone()
2603                    .unwrap_or_default();
2604                self.actual_text_stack[idx].consumed = true;
2605                found
2606            };
2607            if replacement.is_empty() {
2608                self.last_y = y;
2609                self.advance_pen(&glyph_text, glyph_end_x, widened);
2610                let adv_len = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
2611                let (adv_x, adv_y) = if adv_len > 0.0 {
2612                    (coeffs[0] / adv_len, coeffs[1] / adv_len)
2613                } else {
2614                    (1.0, 0.0)
2615                };
2616                self.advance_raw_pen(&glyph_text, x, y, adv_x, adv_y, glyph_width);
2617                self.pending_tj_offset = 0.0;
2618                return;
2619            }
2620            replacement
2621        } else {
2622            if reads_as_nothing {
2623                // Read again at the next glyph all the same: a mark drawn
2624                // before its base gives its offer up by being read.
2625                if let Glyph::Outline(outline) = glyph {
2626                    let record = LastGlyphText {
2627                        place: None,
2628                        span: self.spans.len().saturating_sub(1),
2629                        offset: 0,
2630                        len: 0,
2631                        boxless: true,
2632                        opened: false,
2633                        char_bounds: 0,
2634                        width: 0.0,
2635                        previous_complete_run: None,
2636                        deferred: self.deferred_rich_glyphs.len(),
2637                        span_count: 0,
2638                        tail_text: 0,
2639                        tail_boxes: 0,
2640                    };
2641                    self.remember_last_glyph(outline, record, glyph_hidden);
2642                }
2643                return;
2644            }
2645            // A glyph that reads as drawn: the open sequences already stand
2646            // in the text, so their close must not read a replacement too.
2647            // Its box is still a mark a glyphless close could read at.
2648            if !self.actual_text_stack.is_empty() {
2649                for open in self.actual_text_stack.iter_mut() {
2650                    open.saw_glyph = true;
2651                }
2652                self.note_drawn_box(glyph_bound);
2653            }
2654            glyph_text
2655        };
2656        // The glyph's own text, which the next glyph may make it read
2657        // differently (see `LastGlyphText`); a replacement is not.
2658        let own_glyph = match glyph {
2659            Glyph::Outline(outline) if actual_idx.is_none() => Some(outline),
2660            _ => None,
2661        };
2662
2663        // M3: record glyph data for deferred tight-bounds computation.
2664        let deferred_before = self.deferred_rich_glyphs.len();
2665        if self.geometry_mode == GeometryMode::RichGeometry {
2666            self.record_deferred_glyph(glyph, &composed, font_size, glyph_width);
2667        }
2668
2669        // ANN[r17/TEX4] Feed the running sample used to derive the adaptive
2670        // median character width. Capped to protect against pathological
2671        // pages with hundreds of thousands of glyphs.
2672        if self.glyph_widths.len() < 4096 {
2673            self.glyph_widths.push(glyph_width);
2674            if self.glyph_widths.len().is_multiple_of(MEDIAN_REFRESH) {
2675                self.refresh_median_char_width();
2676            }
2677        }
2678
2679        // The gap is what the page drew between this glyph and the last one,
2680        // judged on the pen the last glyph left. The pen moves past this glyph
2681        // only after the merge decision -- `advance_pen` on every path below --
2682        // so the gap reads the previous end, and `last_widened` still carries
2683        // the previous glyph's floor for the tracking read. Advancing here left
2684        // every glyph a span of its own and the tracking dead (#623 family).
2685        let gap = x - self.last_end_x;
2686        let same_line = (y - self.last_y).abs() <= font_size.max(BAND_Y_TOLERANCE) * 0.35;
2687        // Under raw runs a turned glyph joins its run along the way it reads
2688        // (#643): `Form` is one show of four glyphs reading down, and judged
2689        // on x and y each glyph stands alone. The gap runs along the way this
2690        // glyph reads and the same-line band across it, off the pen the last
2691        // glyph left. Two horizontal glyphs keep the judgment above, exactly
2692        // as before.
2693        let adv_len = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
2694        let (adv_x, adv_y) = if adv_len > 0.0 {
2695            (coeffs[0] / adv_len, coeffs[1] / adv_len)
2696        } else {
2697            (1.0, 0.0)
2698        };
2699        let vector = self.raw_runs
2700            && self.raw_pen_valid
2701            && (adv_y.abs() > adv_x.abs() || self.raw_dir[1].abs() > self.raw_dir[0].abs());
2702        let (same_line, gap, dir_changed) = if vector {
2703            let along = (x - self.raw_pen[0]) * adv_x + (y - self.raw_pen[1]) * adv_y;
2704            let across_x = (x - self.raw_pen[0]) - along * adv_x;
2705            let across_y = (y - self.raw_pen[1]) - along * adv_y;
2706            let across = (across_x.powi(2) + across_y.powi(2)).sqrt();
2707            let aligned = adv_x * self.raw_dir[0] + adv_y * self.raw_dir[1] > 0.999;
2708            (aligned && across <= font_size * 0.35, along, !aligned)
2709        } else {
2710            (same_line, gap, false)
2711        };
2712        let adjacent = same_line && gap >= -font_size * 0.25 && gap < font_size * 0.5;
2713
2714        // G1: only merge into the previous span when font + style + color
2715        // match. Otherwise the editor's style toolbar would render the wrong
2716        // state for the cursor position.
2717        //
2718        // A span is one font at one size and style: where the caller asks
2719        // for the document's names, the reported name parts a span even
2720        // where the merge face does not. Two document fonts can stand on one
2721        // face, and merged the second font's name and descriptor metrics are
2722        // lost with it (#636). Words still join across the split at word
2723        // level. The default path keeps merging on the face alone, as it
2724        // did: a program that names no face still reads under the page's
2725        // name (#574).
2726        let same_font = !self.document_font_names
2727            || self
2728                .spans
2729                .last()
2730                .is_some_and(|last| last.font_name == style.font_name);
2731        let style_matches = self
2732            .spans
2733            .last()
2734            .map(|last| {
2735                self.last_merge_face == style.merge_face
2736                    && same_font
2737                    && last.is_bold == style.is_bold
2738                    && last.is_italic == style.is_italic
2739                    && last.color == color
2740            })
2741            .unwrap_or(false);
2742        // Under raw runs a run ends where the type size changes: `MM` at 7pt
2743        // and the space at 4.95pt the page sets between it and `DD` are one
2744        // line but three runs (#643). The default path merges across sizes, as
2745        // before.
2746        let same_size = !self.raw_runs
2747            || self.spans.last().is_some_and(|last| {
2748                let big = last.font_size.max(font_size).max(1.0);
2749                (last.font_size - font_size).abs() <= RAW_SIZE_TOLERANCE * big
2750            });
2751        // A segment that opened or closed since the last glyph ends the run,
2752        // so a run never crosses the segment it was drawn in (#643). Only
2753        // ever set under raw runs; read and cleared here, on every glyph.
2754        let bdc_changed = std::mem::replace(&mut self.raw_bdc_changed, false);
2755        // Under raw runs a run ends where the page's declared face changes,
2756        // for the reason `last_raw_declared` gives (#643). A glyph the page
2757        // named nothing for never parts a run on its own.
2758        let declared = if self.raw_runs {
2759            match glyph {
2760                Glyph::Outline(outline) => outline
2761                    .declared_name()
2762                    .map(|d| strip_subset_prefix(&d).to_string()),
2763                Glyph::Type3(_) => None,
2764            }
2765        } else {
2766            None
2767        };
2768        let same_declared = !self.raw_runs
2769            || match (&self.last_raw_declared, &declared) {
2770                (Some(before), Some(now)) => before == now,
2771                _ => true,
2772            };
2773
2774        if adjacent
2775            && !self.spans.is_empty()
2776            && style_matches
2777            && same_size
2778            && same_declared
2779            && !bdc_changed
2780            && !dir_changed
2781        {
2782            // ANN[r17/TEX1] Multi-signal consensus replaces the prior
2783            // single-threshold rule (`gap > 0.15 * font_size`). The
2784            // consensus evaluates TJ offset, geometric gap, and
2785            // character-class transitions; a space is inserted only
2786            // when the weighted sum meets SPACE_CONSENSUS_THRESHOLD.
2787            // Decision is computed before the mutable borrow of `last`
2788            // to keep the borrow checker happy.
2789            // AND THE TYPESETTER'S TRACKING IS NOT A WORD SPACE (#582). The
2790            // consensus fires on every pair of a letter-spaced display line,
2791            // so the regularity of the run is asked about before its answer is
2792            // believed.
2793            //
2794            // BETWEEN TWO LETTERS THE RUN IS JUDGED ON THE GAP THE PAGE DREW
2795            // (#623). Two things make a tracked run's gaps disagree that are not
2796            // the tracking: the quarter-em floor of `glyph_width_and_source`
2797            // widens a narrow glyph's box, so the gap after every `I` of a
2798            // tracked `DIRECTORS’ DECLARATION` (OTC_STKAF_2002) read half the
2799            // tracking; and a pair kern in the TJ -- `[(ORPORA) 74 (TION)] TJ`
2800            // in NYSE_PVA_2004 -- takes 40 per cent off one gap. Either broke
2801            // the regularity for the next four pairs, and the run ended
2802            // `CORPORAT I O N`. Both are put back. A kern is a POSITIVE TJ, of
2803            // any size, since it moves the next glyph left: `Twenty-five`
2804            // tightens its `Tw` by 1.3 points. A small negative one is the
2805            // tracking itself -- NASDAQ_UCBI_2014 letter-spaces `CONSOLIDATED`
2806            // with `-80` between every pair -- and stays in the gap. Only
2807            // between letters and drawn spaces: a formula's commas and dots are
2808            // floored too, and put back there they make `{1, ..., n}` as
2809            // regular as tracking (1201.4397).
2810            let prev_char = self.spans.last().and_then(|last| last.text.chars().last());
2811            let next_char = text.chars().next();
2812            let letters = prev_char.is_some_and(char::is_alphabetic)
2813                && next_char.is_some_and(char::is_alphabetic);
2814            // A drawn space in a tracked line is floored and kerned like its
2815            // letters: `BACK TO` (NYSE_WAL_2014) kerns the `T` after its space
2816            // by 25, and left raw that one gap kept the run from being seen.
2817            let letter_or_space =
2818                |c: Option<char>| c.is_some_and(|c| c.is_alphabetic() || c == ' ');
2819            let drawn_gap = if letter_or_space(prev_char) && letter_or_space(next_char) {
2820                let kern = f64::from(self.pending_tj_offset.max(0.0)) / 1000.0 * font_size;
2821                gap + self.last_widened + kern
2822            } else {
2823                gap
2824            };
2825            let tracked = self.a_tracked_run(drawn_gap);
2826            let want_space = {
2827                let last = self.spans.last().expect("checked non-empty");
2828                !last.text.ends_with(' ')
2829                    && !text.starts_with(' ')
2830                    && !tracked
2831                    && self.evaluate_space_consensus(gap, font_size, &last.text, &text)
2832            };
2833            // EVERY GAP, and not only the ones wide enough to be a space.
2834            // Filtering to the wide ones leaves a run of word spaces, which
2835            // agree with one another as neatly as tracking does -- that is how
2836            // `in a healthy` came back as `in ahealthy`. What tells the two
2837            // apart is the gaps INSIDE the words: tracking adds its constant
2838            // to those as well, and prose does not.
2839            //
2840            // A gap that breaks the regularity ends one run and the next
2841            // starts after it, and a take-back never reaches back past that
2842            // start: the gap itself is the boundary, so the word space before
2843            // a letter-spaced `C O M B I N E` stays (NYSE_GLW_2004). A span's
2844            // first gap has nothing to break, and it is the run's own. Only a
2845            // gap between two letters breaks a run, measured against the last
2846            // gap between two letters: a drawn space glyph sits a little off
2847            // the tracking on both sides, and a break there left `I T ALL`
2848            // (NYSE_WAL_2014) and `D ECLARATION` (OTC_STKAF_2002).
2849            if letters {
2850                if self.last_letter_gap.is_some_and(|before| {
2851                    (before - drawn_gap).abs() > TRACKING_TOLERANCE * drawn_gap
2852                }) {
2853                    self.run_start = self.spans.last().map_or(0, |last| last.text.len() + 1);
2854                }
2855                self.last_letter_gap = Some(drawn_gap);
2856            }
2857            self.recent_gaps.push(drawn_gap);
2858            if self.recent_gaps.len() > TRACKING_MIN_PAIRS * 2 {
2859                self.recent_gaps.remove(0);
2860            }
2861            let span_index = self.spans.len() - 1;
2862            let last = self.spans.last_mut().expect("checked non-empty");
2863            if tracked {
2864                Self::take_back_tracked_spaces(
2865                    &mut last.text,
2866                    &mut self.inserted_spaces,
2867                    self.run_start,
2868                    drawn_gap,
2869                );
2870            }
2871            if want_space {
2872                self.inserted_spaces.push((last.text.len(), drawn_gap));
2873                last.text.push(' ');
2874            }
2875            let offset = last.text.len();
2876            let char_bounds = last.char_bounds.len();
2877            let width = last.width;
2878            last.text.push_str(&text);
2879            last.width = last.width.max(glyph_end_x - last.x);
2880            last.height = last.height.max(font_size);
2881            // G2: append char_bound; downgrade width_source if this glyph is Estimate.
2882            last.char_bounds.push(glyph_bound);
2883            if glyph_ws == WidthSource::Estimate {
2884                last.width_source = WidthSource::Estimate;
2885            }
2886            if let Some(outline) = own_glyph {
2887                let record = LastGlyphText {
2888                    place: None,
2889                    span: span_index,
2890                    offset,
2891                    len: text.len(),
2892                    boxless: false,
2893                    opened: false,
2894                    char_bounds,
2895                    width,
2896                    previous_complete_run: None,
2897                    deferred: deferred_before,
2898                    span_count: 0,
2899                    tail_text: 0,
2900                    tail_boxes: 0,
2901                };
2902                self.remember_last_glyph(outline, record, glyph_hidden);
2903            }
2904            self.last_y = y;
2905            self.advance_pen(&text, glyph_end_x, widened);
2906            self.advance_raw_pen(&text, x, y, adv_x, adv_y, glyph_width);
2907            // ANN[r17/TEX1] Consume the TJ signal: it only counts for
2908            // the one merge it preceded.
2909            self.pending_tj_offset = 0.0;
2910            return;
2911        }
2912
2913        self.last_y = y;
2914        self.advance_pen(&text, glyph_end_x, widened);
2915        self.advance_raw_pen(&text, x, y, adv_x, adv_y, glyph_width);
2916        // ANN[r17/TEX1] Non-adjacent glyph starts a fresh span, so any
2917        // pending TJ offset is about within-span word breaks and no longer
2918        // meaningful here.
2919        self.pending_tj_offset = 0.0;
2920        // AND A NEW SPAN IS A NEW RUN (#582). The gaps belong to the run they
2921        // were measured in; carried across, the first gap of a new line is
2922        // judged against the last line's tracking, and a line that opens with
2923        // a one-letter word loses its space -- `A report` as `Areport`. The
2924        // placed-text path clears this for the same reason.
2925        self.recent_gaps.clear();
2926        self.inserted_spaces.clear();
2927        self.run_start = 0;
2928        self.last_letter_gap = None;
2929
2930        let span = TextSpan {
2931            text,
2932            x,
2933            y,
2934            width: glyph_width,
2935            height: font_size,
2936            font_size,
2937            font_name: style.font_name.clone(),
2938            is_bold: style.is_bold,
2939            is_italic: style.is_italic,
2940            color,
2941            width_source: glyph_ws,
2942            char_bounds: vec![glyph_bound],
2943            transform: Some(coeffs),
2944            font_weight: style.font_weight,
2945            is_serif: style.is_serif,
2946            is_monospace: style.is_monospace,
2947            render_mode: Some(render_mode_from_draw_mode(draw_mode)),
2948            is_ocr_layer: style.is_ocr_layer,
2949            font_metrics: style.font_metrics,
2950            geometry_mode: self.geometry_mode,
2951            bounds_source: BoundsSource::Advance,
2952            tight_char_bounds: Vec::new(),
2953            glyph_advances: Vec::new(),
2954            glyph_bounds_sources: Vec::new(),
2955            complete_run: false,
2956        };
2957
2958        self.last_merge_face = style.merge_face;
2959        let previous_complete_run = self.spans.last().map(|prev| prev.complete_run);
2960        if self.raw_runs {
2961            // A run ends where the style, the size, the declared face or the
2962            // reading direction does; a segment or a text object alone never
2963            // ends one (`4a` reads as one span across two segments, and the
2964            // foreign-address fields merge across text objects), and neither
2965            // does a purely geometric part -- a gap or a backward step inside
2966            // one show -- so a consumer joining same-style spans may still
2967            // merge there (#643).
2968            let run_end = !style_matches || !same_size || !same_declared || dir_changed;
2969            if let Some(prev) = self.spans.last_mut() {
2970                prev.complete_run = run_end;
2971            }
2972            self.raw_bdc.push(self.raw_bdc_stack.last().copied());
2973            self.last_raw_declared = declared;
2974        }
2975        let record = own_glyph.map(|outline| {
2976            let record = LastGlyphText {
2977                place: None,
2978                span: self.spans.len(),
2979                offset: 0,
2980                len: span.text.len(),
2981                boxless: false,
2982                opened: true,
2983                char_bounds: 0,
2984                width: 0.0,
2985                previous_complete_run,
2986                deferred: deferred_before,
2987                span_count: 0,
2988                tail_text: 0,
2989                tail_boxes: 0,
2990            };
2991            (outline, record)
2992        });
2993        self.spans.push(span);
2994        if let Some((outline, record)) = record {
2995            self.remember_last_glyph(outline, record, glyph_hidden);
2996        }
2997    }
2998
2999    // ANN[r17/TEX1] Record TJ offsets. Accumulate because a single
3000    // inter-substring gap may be expressed as multiple numeric entries
3001    // (rare, but legal per PDF §9.4.3). The next draw_glyph consumes
3002    // the sum.
3003    fn text_adjustment(&mut self, amount: f32) {
3004        self.pending_tj_offset += amount;
3005    }
3006}
3007
3008/// Style metadata derived from a `Glyph` for the G1 text-run extension.
3009#[derive(Debug, Default, Clone)]
3010struct GlyphStyle {
3011    font_name: Option<String>,
3012    /// The face spans merge on: `font_name` as the program names it, without
3013    /// the /BaseFont fallback (see `last_merge_face`).
3014    merge_face: Option<String>,
3015    is_bold: bool,
3016    is_italic: bool,
3017    /// Numeric weight from embedded font data, if available.
3018    font_weight: Option<u16>,
3019    /// Serif flag from embedded font data, if available.
3020    is_serif: Option<bool>,
3021    /// Monospace flag from embedded font data, if available.
3022    is_monospace: Option<bool>,
3023    /// Whether the glyph was set in the engine's own OCR layer resource.
3024    is_ocr_layer: bool,
3025    /// Vertical font metrics from embedded font data, if available.
3026    font_metrics: Option<FontMetrics>,
3027}
3028
3029/// Strip a 6-character subset prefix (e.g. `AAAAAA+Helvetica` → `Helvetica`).
3030fn strip_subset_prefix(name: &str) -> &str {
3031    match name.split_once('+') {
3032        Some((prefix, rest)) if prefix.len() == 6 => rest,
3033        _ => name,
3034    }
3035}
3036
3037/// Heuristic style inference from a PostScript name when no descriptor
3038/// flags are reachable. Matches the same rules `pdf-interpret` uses in
3039/// `FallbackFontQuery::new`.
3040fn name_style_hints(name: &str) -> (bool, bool) {
3041    let lower = name.to_ascii_lowercase();
3042    let italic = lower.contains("italic") || lower.contains("oblique") || lower.contains("slant");
3043    let bold = lower.contains("bold")
3044        || lower.contains("demi")
3045        || lower.contains("semibold")
3046        || lower.contains("heavy")
3047        || lower.contains("black");
3048    (bold, italic)
3049}
3050
3051/// Whether a font name says monospace, for faces whose program is out of
3052/// reach (standard-14 and other non-embedded fonts).
3053///
3054/// Lowercased, spaceless substring match over known monospace families, the
3055/// way [`name_style_hints`] reads bold and italic. `Some(true)` on a hit,
3056/// `None` otherwise: a name that says nothing keeps the unknown it had. A
3057/// bare "mono" is never matched: decorative faces carry it too.
3058fn name_monospace_hint(name: &str) -> Option<bool> {
3059    let squashed: String = name
3060        .to_ascii_lowercase()
3061        .chars()
3062        .filter(|c| c.is_ascii_alphanumeric())
3063        .collect();
3064    const MONOSPACE_FAMILIES: &[&str] = &[
3065        "courier",
3066        "monaco",
3067        "menlo",
3068        "consolas",
3069        "liberationmono",
3070        "dejavusansmono",
3071        "notosansmono",
3072        "sfmono",
3073        "ubuntumono",
3074        "robotomono",
3075        "ptmono",
3076        "droidsansmono",
3077        "cascadiacode",
3078        "cascadiamono",
3079        "firacode",
3080        "firamono",
3081        "inconsolata",
3082        "jetbrainsmono",
3083        "sourcecodepro",
3084        "lucidaconsole",
3085        "nimbusmono",
3086        "texgyrecursor",
3087        "andalemono",
3088        "lettergothic",
3089        "prestigeelite",
3090        "anonymouspro",
3091        "oxygenmono",
3092        "overpassmono",
3093        "plexmono",
3094        "spacemono",
3095        "sharetechmono",
3096        "cutivemono",
3097        "novamono",
3098        "fixedsys",
3099        "typewriter",
3100        "monospace",
3101    ];
3102    if MONOSPACE_FAMILIES
3103        .iter()
3104        .any(|family| squashed.contains(family))
3105    {
3106        Some(true)
3107    } else {
3108        None
3109    }
3110}
3111
3112fn derive_glyph_style(glyph: &Glyph<'_>, document_font_names: bool) -> GlyphStyle {
3113    let mut style = glyph_style_from_font(glyph, document_font_names);
3114    // THE ENGINE'S OWN VOICE. Text set in the OCR layer resource is the
3115    // layer the engine itself wrote, not ink the author hid: the hidden-text
3116    // drop keeps it. The resource name is the only mark that survives, since
3117    // the layer stands on Helvetica like any author text.
3118    style.is_ocr_layer = glyph
3119        .font_resource_name()
3120        .is_some_and(|name| name.as_bytes() == pdf_ocr::pipeline::OCR_FONT_NAME);
3121    // THE PAGE'S NAME, BESIDE THE FACE'S (#623). A font that stands on a
3122    // standard face -- an embedded CFF or Type1 whose program the renderer
3123    // replaced, a named face that is not embedded -- reaches here as that
3124    // face: `RotisSansSerif-Light`, its regular and its `-Bold` were all
3125    // `Helvetica`, and a `-Bold` or `-Italic` the page asked for was lost.
3126    // `font_name` stays the face's, because span merging compares it and a
3127    // formula's faces must keep merging as they did; bold and italic read
3128    // the page's name as well.
3129    if let Glyph::Outline(outline) = glyph {
3130        if let Some(raw) = outline.declared_name() {
3131            let declared = strip_subset_prefix(&raw).to_string();
3132            if !declared.is_empty() && style.font_name.as_deref() != Some(declared.as_str()) {
3133                let (bold, italic) = name_style_hints(&declared);
3134                style.is_bold |= bold;
3135                style.is_italic |= italic;
3136                style.is_monospace = style.is_monospace.or(name_monospace_hint(&declared));
3137            }
3138        }
3139    }
3140    style
3141}
3142
3143fn glyph_style_from_font(glyph: &Glyph<'_>, document_font_names: bool) -> GlyphStyle {
3144    match glyph {
3145        Glyph::Outline(outline) => {
3146            if let Some(data) = outline.font_data() {
3147                let raw = data.postscript_name.as_deref().unwrap_or("");
3148                let name = strip_subset_prefix(raw).to_string();
3149                let weight_bold = data.weight.is_some_and(|w| w >= 700);
3150                let (name_bold, name_italic) = name_style_hints(&name);
3151                let font_name = if name.is_empty() { None } else { Some(name) };
3152                GlyphStyle {
3153                    merge_face: font_name.clone(),
3154                    font_name,
3155                    is_bold: weight_bold || name_bold,
3156                    is_italic: data.is_italic || name_italic,
3157                    font_weight: data.weight.map(|w| w.clamp(1, 1000) as u16),
3158                    is_serif: Some(data.is_serif),
3159                    is_monospace: Some(data.is_monospace),
3160                    // Read back in `derive_glyph_style`, the only caller.
3161                    is_ocr_layer: false,
3162                    font_metrics: match (data.ascent, data.descent) {
3163                        (Some(ascent), Some(descent)) => Some(FontMetrics {
3164                            ascent,
3165                            descent,
3166                            cap_height: data.cap_height,
3167                            x_height: data.x_height,
3168                        }),
3169                        _ => None,
3170                    },
3171                }
3172            } else if document_font_names {
3173                // Type1 / non-embedded font — descriptor not surfaced
3174                // via font_data(). Fall back to the name-only
3175                // accessor which works for standard-14 fallbacks,
3176                // and use the canonical Standard-14 AFM metrics
3177                // when the outline font resolves to a known face.
3178                //
3179                // THE MERGE KEY STAYS THE FACE'S, THE REPORTED NAME IS THE
3180                // PAGE'S (#643 E3). Spans merge on the face the program
3181                // names (or the substitute it stands on), exactly as they
3182                // did (#574); `font_name` is the name the page set its
3183                // text in. Style reads both, as it did (#623): a `Bd` the
3184                // page names is bold because the face says `Bold`, and a
3185                // `Bliss-LightItalic` no face names is italic because the
3186                // page says so.
3187                let face =
3188                    strip_subset_prefix(&outline.postscript_name().unwrap_or_default()).to_string();
3189                let merge_face = if face.is_empty() {
3190                    None
3191                } else {
3192                    Some(face.clone())
3193                };
3194                let raw = outline.document_name().unwrap_or_default();
3195                let name = strip_subset_prefix(&raw).to_string();
3196                // AND WHERE THE PROGRAM NAMES NOTHING, THE PAGE'S NAME (#574).
3197                // An embedded Type1 or CFF reaches here with no descriptor and,
3198                // unless it stands on a standard face, no PostScript name: every
3199                // span of `DIN-Medium`, `CMR12`, `MetalworkBold` had no face at
3200                // all -- five of the rig-like proxy's 36 documents, 105 of its
3201                // 389 pages, on which `font_is_the_body_face` could say nothing.
3202                // A Type1 always declares a /BaseFont. Spans still merge on the
3203                // program's name (`merge_face`).
3204                let name = if name.is_empty() {
3205                    outline
3206                        .declared_name()
3207                        .map(|d| strip_subset_prefix(&d).to_string())
3208                        .unwrap_or_default()
3209                } else {
3210                    name
3211                };
3212                let (name_bold, name_italic) = name_style_hints(&name);
3213                let (face_bold, face_italic) = name_style_hints(&face);
3214                let metrics = outline.font_metrics().map(|(a, d, c, x)| FontMetrics {
3215                    ascent: a,
3216                    descent: d,
3217                    cap_height: c,
3218                    x_height: x,
3219                });
3220                GlyphStyle {
3221                    font_name: if name.is_empty() { None } else { Some(name) },
3222                    merge_face,
3223                    is_bold: name_bold || face_bold,
3224                    is_italic: name_italic || face_italic,
3225                    // Non-embedded font: descriptor metrics are not reachable.
3226                    font_weight: None,
3227                    is_serif: None,
3228                    is_monospace: None,
3229                    // Read back in `derive_glyph_style`, the only caller.
3230                    is_ocr_layer: false,
3231                    font_metrics: metrics,
3232                }
3233            } else {
3234                // THE DEFAULT ANSWERS AS cc2f37b DID (#643 follow-up). The
3235                // option is off: the substitute face's name and AFM pair,
3236                // style hints from that name alone -- the merge key and
3237                // `font_is_the_body_face` see exactly what they saw before.
3238                // `substitute_metrics` is the pre-E4 metrics source under
3239                // its new name; the rest is verbatim.
3240                let raw = outline.postscript_name().unwrap_or_default();
3241                let name = strip_subset_prefix(&raw).to_string();
3242                let merge_face = if name.is_empty() {
3243                    None
3244                } else {
3245                    Some(name.clone())
3246                };
3247                // AND WHERE THE PROGRAM NAMES NOTHING, THE PAGE'S NAME (#574).
3248                // An embedded Type1 or CFF reaches here with no descriptor and,
3249                // unless it stands on a standard face, no PostScript name: every
3250                // span of `DIN-Medium`, `CMR12`, `MetalworkBold` had no face at
3251                // all -- five of the rig-like proxy's 36 documents, 105 of its
3252                // 389 pages, on which `font_is_the_body_face` could say nothing.
3253                // A Type1 always declares a /BaseFont. Spans still merge on the
3254                // program's name (`merge_face`).
3255                let name = if name.is_empty() {
3256                    outline
3257                        .declared_name()
3258                        .map(|d| strip_subset_prefix(&d).to_string())
3259                        .unwrap_or_default()
3260                } else {
3261                    name
3262                };
3263                let (name_bold, name_italic) = name_style_hints(&name);
3264                let metrics = outline
3265                    .substitute_metrics()
3266                    .map(|(a, d, c, x)| FontMetrics {
3267                        ascent: a,
3268                        descent: d,
3269                        cap_height: c,
3270                        x_height: x,
3271                    });
3272                let monospace = name_monospace_hint(&name);
3273                GlyphStyle {
3274                    font_name: if name.is_empty() { None } else { Some(name) },
3275                    merge_face,
3276                    is_bold: name_bold,
3277                    is_italic: name_italic,
3278                    // Non-embedded font: descriptor metrics are not reachable.
3279                    font_weight: None,
3280                    is_serif: None,
3281                    is_monospace: monospace,
3282                    // Read back in `derive_glyph_style`, the only caller.
3283                    is_ocr_layer: false,
3284                    font_metrics: metrics,
3285                }
3286            }
3287        }
3288        Glyph::Type3(_) => GlyphStyle::default(),
3289    }
3290}
3291
3292fn paint_to_rgba(paint: &Paint<'_>) -> Option<[u8; 4]> {
3293    match paint {
3294        Paint::Color(c) => Some(c.to_rgba().to_rgba8()),
3295        Paint::Pattern(_) => None,
3296    }
3297}
3298
3299/// Map the renderer's coarse glyph draw mode to a PDF text render-mode code.
3300///
3301/// NOTE: the interpreter collapses the eight PDF text render modes (Tr 0–7)
3302/// into three visibility classes, so only `{0, 1, 3}` are representable here —
3303/// fill (0), stroke (1), and invisible (3). The fill+stroke and clip modes
3304/// (2, 4–7) are not distinguished. This is sufficient to identify and filter
3305/// invisible (mode-3) OCR/overlay text; recovering the full `Tr` range would
3306/// require threading it through the `Device` trait (out of scope).
3307fn render_mode_from_draw_mode(mode: &GlyphDrawMode) -> u8 {
3308    match mode {
3309        GlyphDrawMode::Fill => 0,
3310        GlyphDrawMode::Stroke(_) => 1,
3311        GlyphDrawMode::Invisible => 3,
3312    }
3313}
3314
3315#[cfg(test)]
3316mod render_mode_tests {
3317    use super::render_mode_from_draw_mode;
3318    use pdf_render::pdf_interpret::{GlyphDrawMode, StrokeProps};
3319
3320    #[test]
3321    fn render_mode_is_only_zero_one_three() {
3322        assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Fill), 0);
3323        assert_eq!(
3324            render_mode_from_draw_mode(&GlyphDrawMode::Stroke(StrokeProps::default())),
3325            1
3326        );
3327        assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Invisible), 3);
3328        // Exhaustive: the mapping only ever yields 0, 1, or 3 — never 2 or 4–7.
3329        for m in [
3330            GlyphDrawMode::Fill,
3331            GlyphDrawMode::Stroke(StrokeProps::default()),
3332            GlyphDrawMode::Invisible,
3333        ] {
3334            assert!(matches!(render_mode_from_draw_mode(&m), 0 | 1 | 3));
3335        }
3336    }
3337}
3338
3339/// Returns `(advance_in_user_space, WidthSource)` for a glyph.
3340///
3341/// Uses the real advance from `OutlineGlyph::advance_width()` when available
3342/// (returns `WidthSource::Metric`); falls back to 50% em otherwise
3343/// (`WidthSource::Estimate`). The result is clamped to at least 25% em so
3344/// invisible-glyph outliers do not collapse spans.
3345fn glyph_width_and_source(glyph: &Glyph<'_>, font_size: f64) -> (f64, WidthSource) {
3346    match glyph {
3347        Glyph::Outline(outline) => {
3348            if let Some(w) = outline.advance_width() {
3349                let advance = (w as f64 / 1000.0 * font_size).max(font_size * 0.25);
3350                (advance, WidthSource::Metric)
3351            } else {
3352                (font_size * 0.5, WidthSource::Estimate)
3353            }
3354        }
3355        Glyph::Type3(_) => (font_size * 0.5, WidthSource::Estimate),
3356    }
3357}
3358
3359/// Two baselines run in the same direction when they are within half a degree
3360/// of each other. A page draws a run of text with one text matrix, so the
3361/// glyphs of one run agree to the last bit; the tolerance is there for the
3362/// rounding a document writes its matrix with (`0.7071 -0.7071`), not for
3363/// telling two angles apart.
3364const DIRECTION_TOLERANCE_RAD: f64 = 0.008_727; // 0.5 degrees
3365
3366/// How far a baseline may stand from a quarter turn and still read as one:
3367/// ten degrees (#574). The defect's runs stand at a quarter or half turn; a
3368/// typeset line that merely drifts -- the 3-degree skew the guard test pins --
3369/// stays well inside this, and so does the rounding a document writes its
3370/// matrix with.
3371const QUARTER_TURN_TOLERANCE_RAD: f64 = 0.174_533; // 10 degrees
3372
3373/// Whether the span's baseline stands at a quarter turn to the page: within
3374/// ten degrees of 90, 180 or 270 degrees (#574). The defect is a
3375/// quarter-turned run read in page space -- six reversed runs on the tuning
3376/// pages, all at 90 degrees -- and only such a run is ever re-grouped. A
3377/// free-angle region already reads forwards and must not be: merging a
3378/// 60-degree region re-groups text that was never broken (OTC_STKAF p-c3c8:
3379/// spans 197 -> 31, text -0.107). Upright text and small skews are not
3380/// quarter-turned, and neither is anything on a majority-turned page, which
3381/// [`PageTurn::into_frame`] lays upright before this is ever asked.
3382fn span_is_quarter_turned(span: &TextSpan) -> bool {
3383    let direction = span_direction(span);
3384    direction_gap(direction, (0.0, 1.0)) <= QUARTER_TURN_TOLERANCE_RAD
3385        || direction_gap(direction, (-1.0, 0.0)) <= QUARTER_TURN_TOLERANCE_RAD
3386        || direction_gap(direction, (0.0, -1.0)) <= QUARTER_TURN_TOLERANCE_RAD
3387}
3388
3389/// A baseline direction: a unit vector in page space.
3390type Direction = (f64, f64);
3391
3392/// A span taken out of the page's blocks, carrying the position in the page's
3393/// order that the block it came from held.
3394type PlacedSpan = (usize, TextSpan);
3395
3396/// The direction the baseline of a glyph runs in, as a unit vector in page
3397/// space: the first column of its composed transform, normalised.
3398///
3399/// This is what the text matrix rotates. `(1, 0)` for upright text, `(0, 1)`
3400/// for a page's 90-degree marginalia, `(0.7071, -0.7071)` for the diagonal
3401/// watermark of pdfjs issue17069 -- and `(1, 0)` again for a degenerate
3402/// transform, which has no direction to speak of and may as well read as the
3403/// page's own (#435).
3404fn baseline_direction(coeffs: &[f64; 6]) -> Direction {
3405    let (a, b) = (coeffs[0], coeffs[1]);
3406    let length = (a * a + b * b).sqrt();
3407    if length <= f64::EPSILON {
3408        (1.0, 0.0)
3409    } else {
3410        (a / length, b / length)
3411    }
3412}
3413
3414/// The direction a span's baseline runs in. A span not built from a glyph
3415/// carries no transform and reads as upright.
3416fn span_direction(span: &TextSpan) -> Direction {
3417    span.transform
3418        .as_ref()
3419        .map(baseline_direction)
3420        .unwrap_or((1.0, 0.0))
3421}
3422
3423/// The angle between two unit vectors, in radians, without a sign.
3424fn direction_gap(lhs: Direction, rhs: Direction) -> f64 {
3425    let cross = lhs.0 * rhs.1 - lhs.1 * rhs.0;
3426    let dot = lhs.0 * rhs.0 + lhs.1 * rhs.1;
3427    cross.atan2(dot).abs()
3428}
3429
3430fn directions_agree(lhs: Direction, rhs: Direction) -> bool {
3431    direction_gap(lhs, rhs) <= DIRECTION_TOLERANCE_RAD
3432}
3433
3434/// How far along its own baseline, and how far across it, a span's origin sits.
3435///
3436/// The pair is the page's `(x, y)` seen from the baseline: `along` grows in
3437/// reading direction, `across` grows to the left of it, so a line of rotated
3438/// text has one `across` and a range of `along` exactly as an upright line has
3439/// one `y` and a range of `x`.
3440fn baseline_coordinates(span: &TextSpan, direction: Direction) -> (f64, f64) {
3441    (
3442        span.x * direction.0 + span.y * direction.1,
3443        -span.x * direction.1 + span.y * direction.0,
3444    )
3445}
3446
3447/// Where a span stops along its own baseline.
3448///
3449/// `width` is the widest a glyph of the span reached in PAGE x, which is the
3450/// along-baseline extent only while the text is upright: a run drawn at thirty
3451/// degrees advances `cos 30` of its length in x, and reading that as the length
3452/// leaves an eighth of the run behind it as phantom gap -- enough to put a
3453/// space in the middle of a word where a style change ends one span and starts
3454/// the next. So the last glyph's own bound says where the run stopped, and
3455/// `width` answers only when there are no bounds to ask (every hand-built span)
3456/// or when it reaches further, which is what an overprinted or backwards-drawn
3457/// run does and what the seam allowance of #432 is measured against (#435).
3458fn baseline_end(span: &TextSpan, direction: Direction) -> f64 {
3459    let start = baseline_coordinates(span, direction).0;
3460    let from_width = start + span.width;
3461    match span.char_bounds.last() {
3462        Some(bound) => {
3463            let last = bound[0] * direction.0 + bound[1] * direction.1;
3464            let advance = bound[2] - bound[0];
3465            (last + advance).max(from_width)
3466        }
3467        None => from_width,
3468    }
3469}
3470
3471/// Threshold for detecting rotation or shear in the composed transform.
3472/// Any |b| or |c| above this is treated as rotation; any difference between
3473/// a and d above this is treated as non-uniform scale (= shear potential).
3474const ROTATION_EPSILON: f64 = 1e-6;
3475const SHEAR_EPSILON: f64 = 1e-3;
3476
3477/// Transform a bounding-box `[x0,y0,x1,y1]` (in local space) to page space
3478/// by applying the affine to its four corners and taking the envelope.
3479fn transform_bbox_corners(local_bbox: &[f64; 4], affine: &Affine) -> [f64; 4] {
3480    use kurbo::Point;
3481    let corners = [
3482        *affine * Point::new(local_bbox[0], local_bbox[1]),
3483        *affine * Point::new(local_bbox[2], local_bbox[1]),
3484        *affine * Point::new(local_bbox[2], local_bbox[3]),
3485        *affine * Point::new(local_bbox[0], local_bbox[3]),
3486    ];
3487    let x0 = corners.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
3488    let y0 = corners.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
3489    let x1 = corners
3490        .iter()
3491        .map(|p| p.x)
3492        .fold(f64::NEG_INFINITY, f64::max);
3493    let y1 = corners
3494        .iter()
3495        .map(|p| p.y)
3496        .fold(f64::NEG_INFINITY, f64::max);
3497    [x0, y0, x1, y1]
3498}
3499
3500/// Collapse fake-bold / overprint duplicates inside one band.
3501///
3502/// Real-word corpus failures such as 0105.pdf draw the same text several times
3503/// with sub-point x drift to simulate heavier weight. Text extraction should
3504/// keep the most informative span once rather than concatenate every overprint.
3505/// A span the page drew over split around the narrower span it covers.
3506///
3507/// Merged spans join a glyph run with the spaces between its glyphs, so one
3508/// span can cover the x of a span the page drew between them: the form's
3509/// `MM      DD      YYYY` field span covers the `/` separators set between
3510/// its groups, and left whole no x-order of spans interleaves them again --
3511/// the line reads `MM DD YYYY/ /` (#636). Where a narrow sibling sits fully
3512/// inside the span on the same baseline and the span maps its characters to
3513/// its glyphs one to one, the span is cut at the whitespace gap holding the
3514/// sibling, so the reading order puts the separator back between its groups.
3515/// A sibling over ink rather than white never cuts: the split point must sit
3516/// under the sibling at a whitespace character.
3517fn split_spans_covering_siblings(spans: &mut Vec<TextSpan>) {
3518    let mut changed = true;
3519    while changed {
3520        changed = false;
3521        let mut at = 0usize;
3522        while at < spans.len() {
3523            if let Some((left, right)) = split_cover_at_sibling(spans, at) {
3524                spans[at] = left;
3525                spans.insert(at + 1, right);
3526                changed = true;
3527                at += 2;
3528            } else {
3529                at += 1;
3530            }
3531            if spans.len() > 4096 {
3532                return;
3533            }
3534        }
3535    }
3536}
3537
3538/// The two halves of `spans[cover]` cut around one span it covers, or `None`.
3539///
3540/// `SIBLING_WIDTH_EM` and `COVER_MARGIN` are above; the y check keeps the cut
3541/// to the band the sort already grouped, since a tall span can reach into the
3542/// band over or under its own.
3543fn split_cover_at_sibling(spans: &[TextSpan], cover: usize) -> Option<(TextSpan, TextSpan)> {
3544    const SIBLING_WIDTH_EM: f64 = 0.6;
3545    const COVER_MARGIN: f64 = 1.0;
3546    let (cover_left, cover_right) = {
3547        let span = &spans[cover];
3548        (span.x, span.measured_right())
3549    };
3550    if cover_right <= cover_left {
3551        return None;
3552    }
3553    let chars: Vec<char> = spans[cover].text.chars().collect();
3554    if chars.len() < 3 || chars.len() != spans[cover].char_bounds.len() {
3555        return None;
3556    }
3557    for (sib, sibling) in spans.iter().enumerate() {
3558        if sib == cover {
3559            continue;
3560        }
3561        let em = sibling.font_size.max(1.0);
3562        if sibling.width > SIBLING_WIDTH_EM * em {
3563            continue;
3564        }
3565        if (sibling.y - spans[cover].y).abs() > 0.35 * spans[cover].font_size.max(em) {
3566            continue;
3567        }
3568        let (sib_left, sib_right) = (sibling.x, sibling.measured_right());
3569        if !(sib_left > cover_left + COVER_MARGIN && sib_right < cover_right - COVER_MARGIN) {
3570            continue;
3571        }
3572        // The whitespace gap under the sibling: the char boundary closest to
3573        // the sibling's centre whose glyphs stand on either side of it.
3574        let centre = (sib_left + sib_right) * 0.5;
3575        let boxes = &spans[cover].char_bounds;
3576        let mut cut: Option<usize> = None;
3577        let mut best = f64::INFINITY;
3578        for i in 1..chars.len() {
3579            if !(chars[i - 1].is_whitespace() || chars[i].is_whitespace()) {
3580                continue;
3581            }
3582            let edge = (boxes[i - 1][2] + boxes[i][0]) * 0.5;
3583            if edge < sib_left || edge > sib_right {
3584                continue;
3585            }
3586            let distance = (edge - centre).abs();
3587            if distance < best {
3588                best = distance;
3589                cut = Some(i);
3590            }
3591        }
3592        let Some(i) = cut else { continue };
3593        let mut left = spans[cover].clone();
3594        let mut right = spans[cover].clone();
3595        left.text = chars[..i].iter().collect();
3596        right.text = chars[i..].iter().collect();
3597        left.char_bounds = boxes[..i].to_vec();
3598        right.char_bounds = boxes[i..].to_vec();
3599        split_parallel_glyph_vecs(&mut left, &mut right, i, chars.len());
3600        left.width = (boxes[i - 1][2] - left.x).max(0.0);
3601        right.x = boxes[i][0];
3602        right.width = (cover_right - right.x).max(0.0);
3603        return Some((left, right));
3604    }
3605    None
3606}
3607
3608/// Split the per-glyph vectors alongside [`split_cover_at_sibling`]'s cut.
3609///
3610/// A vector that does not run with `char_bounds` (nothing names such a case
3611/// today) is dropped on both halves rather than cut at the wrong place.
3612fn split_parallel_glyph_vecs(left: &mut TextSpan, right: &mut TextSpan, at: usize, len: usize) {
3613    if left.tight_char_bounds.len() == len {
3614        right.tight_char_bounds = left.tight_char_bounds[at..].to_vec();
3615        left.tight_char_bounds.truncate(at);
3616    } else {
3617        left.tight_char_bounds.clear();
3618        right.tight_char_bounds.clear();
3619    }
3620    if left.glyph_advances.len() == len {
3621        right.glyph_advances = left.glyph_advances[at..].to_vec();
3622        left.glyph_advances.truncate(at);
3623    } else {
3624        left.glyph_advances.clear();
3625        right.glyph_advances.clear();
3626    }
3627    if left.glyph_bounds_sources.len() == len {
3628        right.glyph_bounds_sources = left.glyph_bounds_sources[at..].to_vec();
3629        left.glyph_bounds_sources.truncate(at);
3630    } else {
3631        left.glyph_bounds_sources.clear();
3632        right.glyph_bounds_sources.clear();
3633    }
3634}
3635
3636fn collapse_overprinted_spans(spans: &mut Vec<TextSpan>) {
3637    if spans.len() < 2 {
3638        return;
3639    }
3640
3641    let mut deduped: Vec<TextSpan> = Vec::with_capacity(spans.len());
3642    for span in spans.drain(..) {
3643        if let Some(last) = deduped.last_mut() {
3644            if spans_are_overprint_duplicates(last, &span) {
3645                let choose_incoming = span.text.chars().count() > last.text.chars().count()
3646                    || (span.text.chars().count() == last.text.chars().count()
3647                        && span.width > last.width);
3648                let preferred_text = if choose_incoming {
3649                    span.text.clone()
3650                } else {
3651                    last.text.clone()
3652                };
3653                let left = last.x.min(span.x);
3654                let right = last.right().max(span.right());
3655                last.x = left;
3656                last.y = (last.y + span.y) * 0.5;
3657                last.width = (right - left).max(last.width).max(span.width);
3658                last.height = last.height.max(span.height);
3659                last.font_size = last.font_size.max(span.font_size);
3660                last.text = preferred_text;
3661                continue;
3662            }
3663        }
3664
3665        deduped.push(span);
3666    }
3667
3668    *spans = deduped;
3669}
3670
3671fn spans_are_overprint_duplicates(lhs: &TextSpan, rhs: &TextSpan) -> bool {
3672    let lhs_text = lhs.text.trim();
3673    let rhs_text = rhs.text.trim();
3674    if lhs_text.is_empty() || rhs_text.is_empty() {
3675        return false;
3676    }
3677
3678    let size = lhs.font_size.max(rhs.font_size);
3679    let drift = (lhs.y - rhs.y).abs();
3680    // Ink over ink: `right()` widens a short span to half an em per character
3681    // for layout, and through that estimate two spans standing side by side
3682    // count as mostly overlapping -- `t ` beside `t`, `oll` beside `o` -- so
3683    // the second reads as a fake-bold overprint and a mid-word letter is lost
3684    // (`follws`, `heir`). A strike over the first sits on the same ink, which
3685    // the measured edges still overlap; neighbours only touch there.
3686    // Quarter-turned spans already measure this way: their `right()` is the
3687    // measured edge.
3688    let lhs_left = lhs.x;
3689    let lhs_right = lhs.measured_right();
3690    let rhs_left = rhs.x;
3691    let rhs_right = rhs.measured_right();
3692    let overlap = (lhs_right.min(rhs_right) - lhs_left.max(rhs_left)).max(0.0);
3693    let min_width = (lhs_right - lhs_left).min(rhs_right - rhs_left).max(1.0);
3694    let share = overlap / min_width;
3695
3696    // A SHADOW IS THE SAME TEXT A LITTLE OFF (#1973). A label drawn twice --
3697    // a halo or drop shadow under it, sometimes a size larger -- sits up to a
3698    // quarter of an em off the first copy's baseline and overlaps it by less
3699    // than a fake-bold overprint does: `KFTC` 0.85 point lower at 4.9 points,
3700    // `NRST` 5.52 over 4.90. Concatenated, it read `KFTCKFTC`. Only for the SAME
3701    // text, and still only where the two lie mostly over one another, so two
3702    // cells or two words that happen to agree never meet here. And of two
3703    // characters or more: one character's shadow and its neighbour are the
3704    // same picture -- the two `1`s of a matrix, the `ll` of `Ersatzteilliste`
3705    // -- and a letter lost there is worse than a letter doubled.
3706    //
3707    // HALF THE WIDTH IS ENOUGH FROM THREE CHARACTERS ON (#623). NASDAQ_SHEN_2003
3708    // (DocLayNet) sets its current-year column head twice, the second copy 8.0
3709    // points right and 0.86 higher at 10.08 points: the two `2003`s overlap by
3710    // 60 per cent, and the page read `20032003` in ten of its tables where the
3711    // annotation reads `2003`. Two different cells cannot lie half over one
3712    // another, and a word of three characters or more drawn twice at that
3713    // distance is a copy; two characters keep the stricter share, for the
3714    // reason one character is left alone.
3715    let marks = lhs_text.chars().filter(|c| !c.is_whitespace()).count();
3716    let needed = if marks >= 3 { 0.5 } else { 0.7 };
3717    if lhs_text == rhs_text && marks >= 2 && drift <= size * 0.25 && share >= needed {
3718        return true;
3719    }
3720
3721    // Fake bold: the same line struck again with sub-point drift.
3722    if drift > size * 0.12 || share < 0.85 {
3723        return false;
3724    }
3725    lhs_text == rhs_text || lhs_text.starts_with(rhs_text) || rhs_text.starts_with(lhs_text)
3726}
3727
3728/// Does `curr` carry on from `prev`, or does it lie under it?
3729///
3730/// The join in `TextBlock::text` drops a word `curr` repeats from the end of
3731/// `prev`, because a document that draws that word twice at the seam between
3732/// two runs holds it once (0105.pdf, #1014). That reading needs `curr` to
3733/// begin where `prev` stopped. A run that begins a whole character *inside*
3734/// its predecessor is not the continuation of it: a text field draws its value
3735/// with the advance the font asks for, which is regularly wider than the field
3736/// box, so each of three fields on one row of a form reaches tens of points
3737/// into the next field's column. With the same value in all three -- a
3738/// repeated date, a column of `N/A`, or Acroform-PDFBOX-2333's own value -- the
3739/// second and the third read as the first one's tail said again, and dropping
3740/// them loses two filled fields. On a form that is the loss of entered data
3741/// (#432, class R-E of #389).
3742///
3743/// One glyph of `curr`'s own mean advance is the allowance, and it is there
3744/// for the seam itself: a measured width carries the last glyph's side bearing
3745/// and a rounding, never a column of another field.
3746///
3747/// The mean is per GLYPH, not per character, and `char_bounds` holds one entry
3748/// per glyph drawn. A glyph that decodes to several characters -- a `fi`
3749/// ligature, any `bfrange` that names a string -- would otherwise divide one
3750/// advance by two or three characters and shrink the allowance below the seam
3751/// it exists for. The character count answers only for a span that carries no
3752/// bounds, which is every hand-built one.
3753fn second_run_carries_on_from_the_first(prev: &TextSpan, curr: &TextSpan) -> bool {
3754    let glyphs = if curr.char_bounds.is_empty() {
3755        curr.text.chars().count()
3756    } else {
3757        curr.char_bounds.len()
3758    };
3759    if glyphs == 0 {
3760        return false;
3761    }
3762
3763    let mean_advance = curr.width / glyphs as f64;
3764    // Along the previous run's baseline, so that a rotated seam is measured in
3765    // the direction the two runs actually overlap in (#435).
3766    let direction = span_direction(prev);
3767    let (curr_along, _) = baseline_coordinates(curr, direction);
3768    baseline_end(prev, direction) - curr_along <= mean_advance
3769}
3770
3771/// A space drawn over the letter before it is no word space (#574).
3772///
3773/// The page draws the word as two runs, and the second run starts with a
3774/// SPACE character placed LEFT of where the first run ends: after a ligature
3775/// glyph the next run starts ` cally` 0.3 em back, after an initial capital
3776/// ` ransition` 0.6 em back. A real word space never starts left of the
3777/// letter before it, so where the next run overlaps the previous by more
3778/// than a tenth of an em and opens with a space and a lower-case letter,
3779/// the join carries no space and the leading space is dropped.
3780///
3781/// Lower-case only, and that is the whole of the guard: leading space plus
3782/// overlap alone fires a thousand times across formulas and tables (`v 2`,
3783/// `0 N`), and some of those are real gaps. A capital or a digit after the
3784/// space keeps its space.
3785///
3786/// `gap` is the join's own gap, measured along the baseline the same way the
3787/// 0.25 em rule measures it. Where this can fire the previous run ends in a
3788/// letter, so it is never a marks-only span and the anchor the gap was
3789/// measured from is the previous run itself.
3790///
3791/// This runs before the ligature fold, which reads the assembled text: the
3792/// previous run still ends in the ligature code point here.
3793fn space_drawn_over_previous_run(prev: &TextSpan, curr: &TextSpan, gap: f64) -> bool {
3794    if gap >= -0.1 * prev.font_size {
3795        return false;
3796    }
3797    let mut next = curr.text.chars();
3798    if next.next() != Some(' ') || !next.next().is_some_and(|c| c.is_lowercase()) {
3799        return false;
3800    }
3801    prev.text
3802        .chars()
3803        .last()
3804        .is_some_and(|c| c.is_alphabetic() || ('\u{FB00}'..='\u{FB06}').contains(&c))
3805}
3806
3807fn trim_overlapping_word_prefix(prev: &str, curr: &str) -> Option<String> {
3808    let prev_chars: Vec<char> = prev.trim_end().chars().collect();
3809    let curr_chars: Vec<char> = curr.trim_start().chars().collect();
3810    let max = prev_chars.len().min(curr_chars.len());
3811
3812    for len in (4..=max).rev() {
3813        let prev_start = prev_chars.len() - len;
3814        if prev_chars[prev_start..] != curr_chars[..len] {
3815            continue;
3816        }
3817
3818        if !curr_chars[..len].iter().all(|ch| ch.is_alphanumeric()) {
3819            continue;
3820        }
3821
3822        let prev_boundary = prev_start == 0 || !prev_chars[prev_start - 1].is_alphanumeric();
3823        let curr_boundary = len == curr_chars.len() || !curr_chars[len].is_alphanumeric();
3824        if !prev_boundary || !curr_boundary {
3825            continue;
3826        }
3827
3828        return Some(curr_chars[len..].iter().collect());
3829    }
3830
3831    None
3832}
3833
3834/// Compute an adaptive column gap threshold from a set of bands.
3835///
3836/// Collects all positive inter-span gaps within each band, computes the
3837/// median, and returns `COLUMN_GAP_MEDIAN_MULTIPLIER × median`, clamped to
3838/// `[COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX]`.  Falls back to
3839/// `COLUMN_GAP_THRESHOLD_FALLBACK` when there are no measurable gaps.
3840fn compute_adaptive_column_gap(bands: &[TextBand]) -> f64 {
3841    let mut all_gaps: Vec<f64> = Vec::new();
3842
3843    for band in bands {
3844        if band.spans.len() < 2 {
3845            continue;
3846        }
3847        let mut sorted = band.spans.clone();
3848        sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
3849        let mut prev_right = sorted[0].right();
3850        for span in sorted.iter().skip(1) {
3851            let gap = span.x - prev_right;
3852            if gap > 0.0 {
3853                all_gaps.push(gap);
3854            }
3855            prev_right = prev_right.max(span.right());
3856        }
3857    }
3858
3859    if all_gaps.is_empty() {
3860        return COLUMN_GAP_THRESHOLD_FALLBACK;
3861    }
3862
3863    all_gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3864
3865    let min_gap = all_gaps[0];
3866
3867    // When all inter-span gaps are already large (> MIN threshold), they are
3868    // likely all column gaps — the draw_glyph merger absorbed word-level
3869    // spaces into span text.  Use a fraction of the smallest gap so that
3870    // ALL column gaps exceed the threshold.
3871    if min_gap > COLUMN_GAP_THRESHOLD_MIN {
3872        return (min_gap * 0.75).clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
3873    }
3874
3875    // Look for a natural break: the largest relative jump between consecutive
3876    // sorted gaps separates word-level gaps from column gaps.
3877    let mut best_break_threshold = 0.0f64;
3878    let mut best_ratio = 1.5f64; // require at least 1.5× jump
3879    for pair in all_gaps.windows(2) {
3880        if pair[0] > 0.5 {
3881            let ratio = pair[1] / pair[0];
3882            if ratio > best_ratio {
3883                best_ratio = ratio;
3884                best_break_threshold = (pair[0] + pair[1]) * 0.5;
3885            }
3886        }
3887    }
3888
3889    if best_break_threshold > 0.0 {
3890        return best_break_threshold.clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
3891    }
3892
3893    // Fallback: median × multiplier.
3894    let mid = all_gaps.len() / 2;
3895    let median = if all_gaps.len().is_multiple_of(2) {
3896        (all_gaps[mid - 1] + all_gaps[mid]) * 0.5
3897    } else {
3898        all_gaps[mid]
3899    };
3900
3901    (median * COLUMN_GAP_MEDIAN_MULTIPLIER)
3902        .clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX)
3903}
3904
3905/// Group spans into reading-order blocks, using column-aware reordering when
3906/// a contiguous region repeatedly exposes the same gutters.
3907/// Per-page adaptive parameters derived from the span set before any
3908/// grouping happens. Centralising these here (TEX4) means the rest of
3909/// the pipeline — band grouping, XY-Cut cuts, in-block space insertion
3910/// — all speak the same typographic baseline for this specific page,
3911/// rather than each helper reaching for an independent fixed constant.
3912#[derive(Debug, Clone, Copy)]
3913struct PageStats {
3914    /// Median font size across all spans (pt).
3915    median_font_size: f64,
3916    /// Median measured character width (pt). Zero-guarded fallback is
3917    /// 0.5 × median_font_size when there aren't enough samples.
3918    /// Currently populated for diagnostics / future tuning; allow dead_code
3919    /// under `-D warnings` until a reader is added.
3920    #[allow(dead_code)]
3921    median_char_width: f64,
3922    /// Tight line-to-line spacing (25th percentile of pairwise band
3923    /// gaps), representing the body-text leading on this page. The
3924    /// quartile is used instead of the median so large paragraph /
3925    /// zone gaps don't inflate the baseline. Zero if the page has
3926    /// only one band.
3927    median_line_spacing: f64,
3928}
3929
3930impl PageStats {
3931    fn from_spans(spans: &[TextSpan]) -> Self {
3932        if spans.is_empty() {
3933            return Self {
3934                median_font_size: 12.0,
3935                median_char_width: 6.0,
3936                median_line_spacing: 0.0,
3937            };
3938        }
3939
3940        // Median font size.
3941        let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
3942        sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3943        let median_font_size = sizes[sizes.len() / 2];
3944
3945        // Median char width — measured width / char count, per span.
3946        let mut char_widths: Vec<f64> = spans
3947            .iter()
3948            .filter_map(|s| {
3949                let chars = s.text.chars().count();
3950                if chars > 0 && s.width > 0.0 {
3951                    Some(s.width / chars as f64)
3952                } else {
3953                    None
3954                }
3955            })
3956            .collect();
3957        let median_char_width = if char_widths.is_empty() {
3958            median_font_size * 0.5
3959        } else {
3960            char_widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3961            char_widths[char_widths.len() / 2]
3962        };
3963
3964        // Median line spacing — pairwise gaps between consecutive band
3965        // y-values.
3966        let band_tolerance = (median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
3967        let mut ys: Vec<f64> = spans.iter().map(|s| s.y).collect();
3968        ys.sort_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
3969        let mut band_ys: Vec<f64> = Vec::new();
3970        for y in ys {
3971            if band_ys
3972                .last()
3973                .map(|prev: &f64| (prev - y).abs() > band_tolerance)
3974                .unwrap_or(true)
3975            {
3976                band_ys.push(y);
3977            }
3978        }
3979        // ANN[r17/TEX4] "Line spacing" here means the TIGHT line-to-line
3980        // gap inside a text block — not the median of all gaps. Using
3981        // the median drags the estimate up when the page has
3982        // paragraph / zone breaks (which are the very gaps the
3983        // paragraph-break threshold is supposed to EXCEED). The 25th
3984        // percentile is the smallest gap that still shows up in more
3985        // than one place on the page; it captures body-text leading
3986        // robustly even when large zone gaps dominate.
3987        let median_line_spacing = if band_ys.len() < 2 {
3988            0.0
3989        } else {
3990            let mut spacings: Vec<f64> = band_ys
3991                .windows(2)
3992                .map(|pair| (pair[0] - pair[1]).abs())
3993                .collect();
3994            spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3995            let q1_index = spacings.len() / 4;
3996            spacings[q1_index]
3997        };
3998
3999        Self {
4000            median_font_size,
4001            median_char_width,
4002            median_line_spacing,
4003        }
4004    }
4005}
4006
4007// ANN[r17/TEX2] Maximum recursion depth for XY-Cut. Any real page layout
4008// is decomposable in well under 10 alternating cuts; the cap guards
4009// against pathological inputs where the cut predicate keeps triggering
4010// due to floating-point drift.
4011const XY_CUT_MAX_DEPTH: usize = 12;
4012// THE GUTTER IS MEASURED AGAINST THE TYPE, NOT AGAINST THE PAGE. There was a
4013// third rail here -- a vertical gap had to reach 4% of the region's width --
4014// and on the page it was written for it is the only rail that ever binds. A
4015// two-column paper is set in 9pt type inside a 500pt text block, so 4% asks for
4016// 20pt of white where the typographer left 13pt: wider than the gutter of every
4017// journal that ever printed two columns, which is what "narrow-gutter academic
4018// layouts are still detected" in the comment below was meant to allow and did
4019// not. It refused the cut on tracemonkey's pages 2 and 13 and on the same pages
4020// of highlights and tracemonkey_with_editable_annotations -- six of the eleven
4021// pages that were left under 95% on the gate set (#389).
4022//
4023// What separates a gutter from a word space is the type, and that is what the
4024// two remaining rails say: the median font size (a word space is a quarter of
4025// an em, a gutter is more than a whole one) and a 10pt absolute floor. A page
4026// four times as wide does not get four times the word spaces, so scaling the
4027// question with the page was never measuring the thing it asked about.
4028/// Floor (in pt) for vertical gap regardless of region width. Matches
4029/// the previous `COLUMN_GAP_THRESHOLD_MIN` and keeps XY-Cut conservative
4030/// on narrow regions (sidebars, tall columns).
4031const XY_CUT_VERTICAL_GAP_FLOOR: f64 = 10.0;
4032/// Multiplier applied to median font size to produce the horizontal-gap
4033/// threshold. 1.8 × line-height matches typical paragraph spacing.
4034const XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER: f64 = 1.8;
4035
4036/// Whether the horizontal cut at `cut_y` falls INSIDE one grid (#596).
4037///
4038/// The defect this refuses is the largest single item on the tables axis, and it
4039/// is not in `tables.rs` at all. A table whose rows are grouped under labels --
4040/// `Study Design`, `Gender studied`, `Region` -- leaves a little more white
4041/// between the groups than between the rows of a group, and
4042/// `try_horizontal_cut`'s threshold is a multiple of the ROW pitch. On
4043/// `PMC3037919-p0007` the rows sit 12.0 points apart and the groups 23.8, against
4044/// a threshold of 12 * 1.8 = 21.6: so the cut fires between every group, each
4045/// group becomes its own region, and `blocks::paragraphs` -- whose stretches are
4046/// bounded per region -- hands `tables::find` one group at a time. The page comes
4047/// back as twelve tables where the document holds one, and `find` is blameless:
4048/// it is called twelve times with between one and six rows and does the right
4049/// thing with each of them. Measured over both sets, that shape is 34 pages of
4050/// DocLayNet and 29 of PubTables-1M, and 0.3378 of the tables axis's 0.5442.
4051///
4052/// WHAT SAYS "ONE GRID" IS THE COLUMN EDGES, and the measurement already exists:
4053/// `TextBand::gap_midpoints` gives the x where a band's runs are separated, and
4054/// `boundaries_match` asks whether two bands are separated at the same places.
4055/// `region_is_a_grid` has asked exactly this question since #429 -- and is
4056/// reachable only from `group_spans_into_blocks_legacy_from_bands`, the path whose
4057/// own comment says XY-Cut supersedes it. The vertical cut carries two guards of
4058/// this kind (`region_is_ruled` and `the_cut_is_a_column_of_a_table`); the
4059/// horizontal cut carried none, so a guard that would have refused the dominant
4060/// table defect was left on the dead side of a replacement.
4061///
4062/// BOTH SIDES, and at least two bands each. One band above a cut agreeing with
4063/// one below is two lines of prose that happen to break at the same place; four
4064/// bands agreeing across the cut is a grid. The bands are taken adjacent to the
4065/// cut rather than over the whole region, because a table's own caption and the
4066/// prose above it break nowhere and would refuse every table.
4067fn the_cut_is_inside_a_grid(bands: &[TextBand], cut_y: f64, gap: f64) -> bool {
4068    let threshold = compute_adaptive_column_gap(bands);
4069    // `TextBand` has no y accessor of its own; a band's y is its spans' and they
4070    // are within the band tolerance of each other, so the maximum will do.
4071    let band_y = |band: &TextBand| {
4072        band.spans
4073            .iter()
4074            .map(|span| span.y)
4075            .fold(f64::NEG_INFINITY, f64::max)
4076    };
4077    let mut above: Vec<&TextBand> = bands.iter().filter(|band| band_y(band) > cut_y).collect();
4078    let mut below: Vec<&TextBand> = bands.iter().filter(|band| band_y(band) <= cut_y).collect();
4079    // Nearest the cut first on each side: ascending y above it, descending below.
4080    above.sort_by(|a, b| band_y(a).partial_cmp(&band_y(b)).unwrap_or(Ordering::Equal));
4081    below.sort_by(|a, b| band_y(b).partial_cmp(&band_y(a)).unwrap_or(Ordering::Equal));
4082    if above.len() < GRID_SIDE_ROWS || below.len() < GRID_SIDE_ROWS {
4083        return false;
4084    }
4085    // The bands touching the cut, nearest first on each side.
4086    let touching: Vec<&TextBand> = above
4087        .iter()
4088        .take(GRID_SIDE_ROWS)
4089        .chain(below.iter().take(GRID_SIDE_ROWS))
4090        .copied()
4091        .collect();
4092    // AND THE GAP HAS TO BE A GROUP'S GAP AND NOT A ZONE'S. Refusing every cut
4093    // whose two sides share column edges refuses the cut that takes the prose off
4094    // the top of the table as well -- and then the region handed to
4095    // `tables::find` opens with more prose than its `MAX_TRIMMED_ROWS` will trim,
4096    // so the grid is refused outright and the page comes back with NO table.
4097    // Measured: `PMC1277013-p0003` went from six tables to none that way, which is
4098    // worse than the six. The white between the label groups of one table is a
4099    // small multiple of its row pitch; the white above a table is not.
4100    let mut steps: Vec<f64> = touching
4101        .windows(2)
4102        .map(|pair| (band_y(pair[0]) - band_y(pair[1])).abs())
4103        .filter(|step| *step > 0.0)
4104        .collect();
4105    steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4106    let pitch = match steps.first() {
4107        Some(pitch) => *pitch,
4108        None => return false,
4109    };
4110    if gap > GRID_GROUP_GAP_PITCHES * pitch {
4111        return false;
4112    }
4113
4114    // A ROW WITH AN EMPTY CELL IS STILL A ROW OF THE GRID, and this is the whole
4115    // difficulty. `boundaries_match` asks for the same NUMBER of separations on
4116    // both bands, which is right for two lines of a column cut and wrong here: the
4117    // rows that make a table fragment are exactly the ones whose label cell is
4118    // empty, so they hold one gap fewer than the row above them. A guard built on
4119    // equal counts would refuse the grid precisely where the grid has the empty
4120    // cells that caused the split -- the same way round as a detector I wrote for
4121    // the interleave class, which could not fire on the pages it was for.
4122    //
4123    // So the widest band sets the edges and every other band has to agree with a
4124    // SUBSET of them: each of its separations falls on one of the edges, and no
4125    // separation of its own sits where the widest band has ink.
4126    let reference = touching
4127        .iter()
4128        .map(|band| band.gap_midpoints(threshold))
4129        .max_by_key(Vec::len)
4130        .unwrap_or_default();
4131    if reference.len() < GRID_MIN_BOUNDARIES {
4132        return false;
4133    }
4134    let tolerance = (threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
4135    // A BAND WITH ONE RUN OF INK IS A ROW OF THE TABLE TOO, and refusing on it
4136    // would refuse exactly the tables this guard is for: the section heads set
4137    // across a grid -- `Region`, `Health status` -- are what the label groups are
4138    // divided by, so they touch almost every cut worth refusing. `tables::find`
4139    // already reads such a band as a row of the table when the grid goes on under
4140    // it (#576); this is the same judgement one layer up. One is allowed and not
4141    // two, because two in a row is prose between two tables rather than a head
4142    // inside one.
4143    let mut unseparated = 0usize;
4144    for band in &touching {
4145        let midpoints = band.gap_midpoints(threshold);
4146        if midpoints.is_empty() {
4147            unseparated += 1;
4148            if unseparated > GRID_MAX_UNSEPARATED {
4149                return false;
4150            }
4151            continue;
4152        }
4153        if !midpoints
4154            .iter()
4155            .all(|mid| reference.iter().any(|edge| (edge - mid).abs() <= tolerance))
4156        {
4157            return false;
4158        }
4159    }
4160    true
4161}
4162
4163/// How many bands on EACH side of a horizontal cut must agree on the column
4164/// edges before the cut is inside one grid (#596). Two: one band against one is
4165/// a coincidence two lines of prose can produce.
4166const GRID_SIDE_ROWS: usize = 2;
4167/// How many row pitches of white a gap may hold and still be a gap BETWEEN THE
4168/// GROUPS of one table rather than the white above the table (#596).
4169const GRID_GROUP_GAP_PITCHES: f64 = 5.0;
4170/// How many column separations the widest band across a cut must show before the
4171/// bands can be one grid (#596). Two, so three columns: a single separation is
4172/// what every line of prose with one wide word space has.
4173const GRID_MIN_BOUNDARIES: usize = 2;
4174/// How many of the bands touching a cut may hold a single run of ink and still
4175/// leave the rest a grid (#596): a section head set across the table.
4176const GRID_MAX_UNSEPARATED: usize = 1;
4177
4178/// A table's extent on the page, found before the XY-cut runs (#574).
4179#[derive(Debug, Clone, Copy, PartialEq)]
4180struct TableRegion {
4181    left: f64,
4182    right: f64,
4183    /// The highest baseline of the region's first row.
4184    top: f64,
4185    /// The lowest baseline of its last row.
4186    bottom: f64,
4187    /// Whether the region was found by something stronger than aligned text --
4188    /// rules above and below, two columns of keys, a leader -- so that a column
4189    /// cut through it is the cut dividing a table and not a page.
4190    ruled: bool,
4191    /// How many rows the detector counted in it.
4192    rows: usize,
4193}
4194
4195impl TableRegion {
4196    fn holds(&self, span: &TextSpan) -> bool {
4197        let centre = span.x + (span.right() - span.x) * 0.5;
4198        span.y <= self.top && span.y >= self.bottom && centre >= self.left && centre <= self.right
4199    }
4200}
4201
4202/// One run of ink inside a band: its left and right edge and how many
4203/// characters it holds.
4204#[derive(Debug, Clone)]
4205struct InkRun {
4206    left: f64,
4207    right: f64,
4208    chars: usize,
4209    /// Whether the run opens with a bullet glyph and goes on in text.
4210    bulleted: bool,
4211    /// The run's text, its spans joined by a space.
4212    text: String,
4213}
4214
4215/// Whether a span is nothing but a dot leader's dots: the filler a table of
4216/// contents or a statement runs from a label out to its figure. It is white to
4217/// the eye that reads the row, and to the table found in it (#574).
4218fn is_leader(span: &TextSpan) -> bool {
4219    let text = span.text.trim();
4220    !text.is_empty()
4221        && text
4222            .chars()
4223            .all(|c| matches!(c, '.' | '\u{00B7}' | '\u{2026}' | ' '))
4224}
4225
4226/// Whether a run's first span opens with a bullet -- one that is not also a
4227/// dash, since a dash alone is the nil of a financial table and a minus opens a
4228/// figure. The bullet is often a span of its own, so whether text follows it is
4229/// asked of the whole run (`chars`).
4230fn opens_with_bullet(text: &str) -> bool {
4231    matches!(text.chars().find(|c| !c.is_whitespace()),
4232        Some(c) if crate::lists::BULLET_CHARS.contains(&c)
4233            && !matches!(c, '-' | '\u{2013}' | '\u{2014}'))
4234}
4235
4236fn ink_runs(band: &TextBand, gap: f64) -> Vec<InkRun> {
4237    let mut runs: Vec<InkRun> = Vec::new();
4238    for span in &band.spans {
4239        let chars = span.text.chars().filter(|c| !c.is_whitespace()).count();
4240        if chars == 0 || is_leader(span) {
4241            continue;
4242        }
4243        // The glyphs' own advance where the page gave one: the estimate is
4244        // deliberately wide, and a long label measured by it runs into the
4245        // value column it stops well short of.
4246        let right = if span.width > 0.0 {
4247            span.measured_right()
4248        } else {
4249            span.right()
4250        };
4251        match runs.last_mut() {
4252            Some(run) if span.x - run.right < gap => {
4253                run.right = run.right.max(right);
4254                run.chars += chars;
4255                run.text.push(' ');
4256                run.text.push_str(span.text.trim());
4257            }
4258            _ => runs.push(InkRun {
4259                left: span.x,
4260                right,
4261                chars,
4262                bulleted: opens_with_bullet(&span.text),
4263                text: span.text.trim().to_string(),
4264            }),
4265        }
4266    }
4267    runs
4268}
4269
4270/// The white strips between a band's runs, left edge and right edge.
4271fn run_gaps(runs: &[InkRun]) -> Vec<(f64, f64)> {
4272    runs.windows(2)
4273        .map(|pair| (pair[0].right, pair[1].left))
4274        .collect()
4275}
4276
4277/// Whether a row repeats the column heads of the row that opened the table:
4278/// the same text in every value column, three columns or more.
4279fn heads_again(first: &[InkRun], here: &[InkRun]) -> bool {
4280    first.len() >= 3
4281        && here.len() == first.len()
4282        && first
4283            .iter()
4284            .zip(here)
4285            .skip(1)
4286            .all(|(a, b)| a.text == b.text && a.text.chars().any(char::is_alphabetic))
4287}
4288
4289/// Which of the table's strips this gap falls in, if any. A strip is the white
4290/// every row so far leaves between two columns, so a gap belongs to it when the
4291/// two overlap: right-aligned figures move their left edges row by row and a
4292/// long label moves its right edge, and neither moves the strip.
4293fn strip_of(strips: &[(f64, f64)], gap: (f64, f64), slack: f64) -> Option<usize> {
4294    strips
4295        .iter()
4296        .position(|strip| gap.1.min(strip.1) - gap.0.max(strip.0) > -slack)
4297}
4298
4299/// The tables of a page, found on the WHOLE page before the XY-cut (#574).
4300///
4301/// The XY-cut decides the regions and `tables::find` only ever sees one region
4302/// at a time, so a table the cut runs through comes back as several -- or as a
4303/// table, a few loose paragraphs and another table. That is the largest single
4304/// item on the DocLayNet tables axis: the annotated table drawn as several
4305/// (33 of 64 pages of the round of 24-09, 58 % of the gap), and of the text the
4306/// annotation puts in a table a third goes to prose (T4, 27-09). Grouping the
4307/// cells we already produce by the annotation's own region takes detection from
4308/// 0.300 to 0.849 on DocLayNet's train split (T2-577), so the cells are right
4309/// and the region is the defect.
4310///
4311/// So the region is decided first, from the page's own bands. A ROW is a band
4312/// that breaks into three runs or more at a gap of two ems, at least two of them
4313/// short (a value, a year, a `$`). A TABLE is a stack of at least
4314/// `TABLE_REGION_MIN_ROWS` such rows that break at the SAME places, with room
4315/// between them for what a financial statement puts there: a group label in the
4316/// stub column (`Leasehold buildings and improvements`), a row with one value,
4317/// and the white between groups -- up to `TABLE_REGION_MAX_STEP_PITCHES` of the
4318/// page's line pitch. The cut then runs round each region and never through it.
4319fn table_regions(
4320    spans: &[TextSpan],
4321    stats: &PageStats,
4322    rules: &[HorizontalRule],
4323) -> Vec<TableRegion> {
4324    table_regions_seen(spans, stats, rules, None)
4325}
4326
4327/// [`table_regions`], and when `seen` is given, every region each detector
4328/// accepted before one gave way to another -- the proposals a judge chooses
4329/// between (#574, T4's judge).
4330fn table_regions_seen(
4331    spans: &[TextSpan],
4332    stats: &PageStats,
4333    rules: &[HorizontalRule],
4334    seen: Option<&mut Vec<(TableRegion, &'static str)>>,
4335) -> Vec<TableRegion> {
4336    if spans.len() < TABLE_REGION_MIN_ROWS * 2 {
4337        return Vec::new();
4338    }
4339    let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
4340    let em = stats.median_font_size.max(1.0);
4341    let gap = TABLE_REGION_GAP_EM * em;
4342    let tolerance = TABLE_REGION_EDGE_EM * em;
4343    // Never under a line of the body type: a page with superscripts or a
4344    // footnote block measures a tighter spacing than any of its rows keep,
4345    // and every step between two rows would then read as a break.
4346    let pitch = stats.median_line_spacing.max(em * 1.2);
4347    let runs: Vec<Vec<InkRun>> = bands.iter().map(|band| ink_runs(band, gap)).collect();
4348    // A key and its description may stand closer than two cells of a grid:
4349    // the second column of a two-column table starts where the longest key
4350    // ends, and the white between them can be a single em.
4351    let pair_runs: Vec<Vec<InkRun>> = bands
4352        .iter()
4353        .map(|band| ink_runs(band, TABLE_REGION_PAIR_GAP_EM * em))
4354        .collect();
4355    // Columns of bulleted items side by side line up like a table and are
4356    // lists (a brochure's `• Power Plants  • Marine Propulsion  • Oil and Gas`).
4357    let is_row = |runs: &[InkRun]| {
4358        runs.len() >= 3
4359            && runs
4360                .iter()
4361                .filter(|run| run.bulleted && run.chars > 1)
4362                .count()
4363                < 2
4364            && runs
4365                .iter()
4366                .filter(|run| run.chars <= TABLE_REGION_SHORT_CELL)
4367                .count()
4368                >= 2
4369            && runs
4370                .iter()
4371                .skip(1)
4372                .all(|run| run.chars <= TABLE_REGION_LONG_CELL)
4373    };
4374
4375    // Asked for what the detectors considered, the loop keeps the stacks of
4376    // two rows as well; they are proposals the judge may rescue, and they are
4377    // left out of what the page itself uses below.
4378    let min_rows = if seen.is_some() {
4379        2
4380    } else {
4381        TABLE_REGION_MIN_ROWS
4382    };
4383    let mut regions = Vec::new();
4384    let mut i = 0;
4385    while i < bands.len() {
4386        if !is_row(&runs[i]) {
4387            i += 1;
4388            continue;
4389        }
4390        let mut strips = run_gaps(&runs[i]);
4391        // Where the first value column starts: a label may run up to it.
4392        let mut values_left = runs[i]
4393            .iter()
4394            .skip(1)
4395            .map(|run| run.left)
4396            .fold(f64::INFINITY, f64::min);
4397        let mut last_row = i;
4398        let mut rows = 1;
4399        let mut loose = 0;
4400        let mut above = bands[i].y;
4401        let mut j = i + 1;
4402        while j < bands.len() {
4403            let here = &runs[j];
4404            // A band of nothing but blank glyphs is white, not a row.
4405            if here.is_empty() {
4406                j += 1;
4407                continue;
4408            }
4409            if above - bands[j].y > TABLE_REGION_MAX_STEP_PITCHES * pitch {
4410                break;
4411            }
4412            above = bands[j].y;
4413            let gaps = run_gaps(here);
4414            let found: Vec<Option<usize>> = gaps
4415                .iter()
4416                .map(|gap| strip_of(&strips, *gap, tolerance))
4417                .collect();
4418            // THE HEAD AGAIN IS A SECOND TABLE: a page that sets two years'
4419            // statements one under the other repeats the column heads over
4420            // the second, and the annotation draws two tables there.
4421            if heads_again(&runs[i], here) {
4422                break;
4423            }
4424            // A CAPTION IS WHERE THE NEXT TABLE STARTS, however short and
4425            // wherever it stands: `Table 2 The same by subtype` between two
4426            // banks is two tables.
4427            let line: Vec<&str> = here.iter().map(|run| run.text.as_str()).collect();
4428            if crate::tables::announces_a_table(&line.join(" ")) {
4429                break;
4430            }
4431            if is_row(here) {
4432                // Most of this row's gaps must fall in strips the table already
4433                // has; a row with one gap more (a column the header did not
4434                // fill) adds it.
4435                let known = found.iter().flatten().count();
4436                if known < 2.min(strips.len()) || known * 2 < gaps.len() {
4437                    break;
4438                }
4439                for (gap, at) in gaps.iter().zip(&found) {
4440                    match at {
4441                        Some(k) => {
4442                            let (lo, hi) = (strips[*k].0.max(gap.0), strips[*k].1.min(gap.1));
4443                            if hi - lo >= TABLE_REGION_MIN_STRIP {
4444                                strips[*k] = (lo, hi);
4445                            }
4446                        }
4447                        None => strips.push(*gap),
4448                    }
4449                }
4450                values_left = here
4451                    .iter()
4452                    .skip(1)
4453                    .map(|run| run.left)
4454                    .fold(values_left, f64::min);
4455                last_row = j;
4456                rows += 1;
4457                loose = 0;
4458            } else {
4459                // A label in the stub column, or a row with fewer cells filled
4460                // whose breaks fall on the table's own edges.
4461                let first_edge = strips.iter().map(|s| s.1).fold(f64::INFINITY, f64::min);
4462                // A label stops short of the figures; a section head set
4463                // across the table may run over them, and is short.
4464                let label = here.len() == 1
4465                    && here[0].left < first_edge
4466                    && (here[0].right < values_left - tolerance
4467                        || here[0].chars <= TABLE_REGION_LONG_CELL);
4468                let sparse = here.len() == 2 && found.iter().all(Option::is_some);
4469                // A cell's second line: one run that closes none of the strips
4470                // -- it may be longer than the line above it, but it does not
4471                // reach across into the next column.
4472                let wrapped = here.len() == 1
4473                    && strips
4474                        .iter()
4475                        .all(|strip| here[0].left > strip.0 || here[0].right < strip.1);
4476                if !(label || sparse || wrapped) {
4477                    break;
4478                }
4479                // A row with a cell left empty is a row of the table even as
4480                // its last line; a label is only a row of it with a row under it.
4481                if sparse {
4482                    last_row = j;
4483                }
4484                loose += 1;
4485                if loose > TABLE_REGION_MAX_LOOSE {
4486                    break;
4487                }
4488            }
4489            j += 1;
4490        }
4491        // THE HEAD OF THE TABLE, above its first row: a line of years or units
4492        // set over the value columns with nothing in the stub, or a head that
4493        // spans two of them, is not a row by the test above, and it is the
4494        // first thing the annotation's box holds.
4495        let mut first_row = i;
4496        let mut k = i;
4497        let mut heads = 0;
4498        while k > 0 && heads < TABLE_REGION_MAX_HEADS {
4499            k -= 1;
4500            let here = &runs[k];
4501            if here.is_empty() {
4502                continue;
4503            }
4504            if bands[k].y - bands[first_row].y > TABLE_REGION_MAX_STEP_PITCHES * pitch {
4505                break;
4506            }
4507            // Over the value columns only, or a row of heads whose breaks fall
4508            // in the table's strips -- a head spanning two columns among them.
4509            let over_values = here.iter().all(|run| run.left >= values_left - tolerance);
4510            let gaps = run_gaps(here);
4511            if (!over_values && gaps.is_empty())
4512                || !gaps
4513                    .iter()
4514                    .all(|gap| strip_of(&strips, *gap, tolerance).is_some())
4515            {
4516                break;
4517            }
4518            first_row = k;
4519            heads += 1;
4520        }
4521        if rows >= min_rows && strips.len() >= 2 {
4522            let chosen = &bands[first_row..=last_row];
4523            let mut left = chosen
4524                .iter()
4525                .map(TextBand::left)
4526                .fold(f64::INFINITY, f64::min);
4527            // A STUB THAT IS A COLUMN OF PROSE is the page's other column: on a
4528            // paper set in two columns a table's rows share their bands with
4529            // the lines of the paragraph beside it, and each such line reads as
4530            // the row's first cell. Labels are ragged on the right; justified
4531            // prose is not. Where most rows open with a long run and those runs
4532            // end flush, the region starts where the rows' second run does.
4533            if let Some(cut) = prose_stub_edge(&runs[first_row..=last_row], em) {
4534                left = cut;
4535            }
4536            let right = chosen
4537                .iter()
4538                .map(TextBand::right)
4539                .fold(f64::NEG_INFINITY, f64::max);
4540            let top = bands[first_row]
4541                .spans
4542                .iter()
4543                .map(|span| span.y)
4544                .fold(f64::NEG_INFINITY, f64::max);
4545            let bottom = bands[last_row]
4546                .spans
4547                .iter()
4548                .map(|span| span.y)
4549                .fold(f64::INFINITY, f64::min);
4550            regions.push(TableRegion {
4551                left,
4552                right,
4553                top,
4554                bottom,
4555                ruled: false,
4556                rows,
4557            });
4558        }
4559        i = last_row + 1;
4560    }
4561    if let Some(seen) = seen {
4562        seen.extend(regions.iter().map(|r| (*r, "aligned")));
4563        regions.retain(|r| r.rows >= TABLE_REGION_MIN_ROWS);
4564        seen.extend(
4565            booktabs_regions(&bands, &runs, rules, pitch, em)
4566                .into_iter()
4567                .map(|r| (r, "booktabs")),
4568        );
4569        seen.extend(
4570            pair_regions(&bands, &pair_runs, pitch, tolerance, &[], 2)
4571                .into_iter()
4572                .map(|r| (r, "pair")),
4573        );
4574        seen.extend(
4575            leader_regions(&bands, &runs, pitch, &[], 2)
4576                .into_iter()
4577                .map(|r| (r, "leader")),
4578        );
4579    }
4580    // A table the document ruled above and below is the table the rules
4581    // enclose, head and all: what the alignment found inside it gives way.
4582    for ruled in booktabs_regions(&bands, &runs, rules, pitch, em) {
4583        regions.retain(|r| !(r.top <= ruled.top + em && r.bottom >= ruled.bottom - em));
4584        if !regions
4585            .iter()
4586            .any(|r| r.bottom <= ruled.top && r.top >= ruled.bottom)
4587        {
4588            regions.push(ruled);
4589        }
4590    }
4591    let taken: Vec<(f64, f64)> = regions.iter().map(|r| (r.bottom, r.top)).collect();
4592    regions.extend(pair_regions(
4593        &bands,
4594        &pair_runs,
4595        pitch,
4596        tolerance,
4597        &taken,
4598        TABLE_REGION_MIN_ROWS,
4599    ));
4600    let taken: Vec<(f64, f64)> = regions.iter().map(|r| (r.bottom, r.top)).collect();
4601    regions.extend(leader_regions(
4602        &bands,
4603        &runs,
4604        pitch,
4605        &taken,
4606        TABLE_REGION_MIN_ROWS,
4607    ));
4608    regions
4609}
4610
4611/// Tables the document drew a rule above and below: the booktabs style of a
4612/// paper or a report, with no vertical lines and no rules between the rows
4613/// (#574, the idea from LiteParse's detector, #632).
4614///
4615/// A top rule and a rule below it of the same extent, at least
4616/// `TABLE_REGION_MIN_ROWS` bands between them all inside that extent, at least
4617/// two of them broken into cells, and no more white between two of them than a
4618/// table holds. The rule farthest down that still encloses such a stack is the
4619/// foot, so a mid-rule under the head is inside the table and not its end.
4620fn booktabs_regions(
4621    bands: &[TextBand],
4622    runs: &[Vec<InkRun>],
4623    rules: &[HorizontalRule],
4624    pitch: f64,
4625    em: f64,
4626) -> Vec<TableRegion> {
4627    // A GRID DRAWS ITS RULES CELL BY CELL: the rule under a row is often one
4628    // segment per cell, end to end, and no one of them spans the table. They are
4629    // one rule for this purpose, so segments at one height that meet are joined
4630    // first (EN tender annexes on DocLayNet: four segments to every rule).
4631    let mut joined: Vec<HorizontalRule> = rules.to_vec();
4632    joined.sort_by(|a, b| {
4633        b.y.partial_cmp(&a.y)
4634            .unwrap_or(Ordering::Equal)
4635            .then(a.left.partial_cmp(&b.left).unwrap_or(Ordering::Equal))
4636    });
4637    let mut merged: Vec<HorizontalRule> = Vec::with_capacity(joined.len());
4638    for rule in joined {
4639        match merged.last_mut() {
4640            Some(last)
4641                if (last.y - rule.y).abs() <= RULE_JOIN_TOLERANCE
4642                    && rule.left - last.right <= RULE_JOIN_TOLERANCE =>
4643            {
4644                last.right = last.right.max(rule.right);
4645            }
4646            _ => merged.push(rule),
4647        }
4648    }
4649    let mut rules: Vec<&HorizontalRule> = merged
4650        .iter()
4651        .filter(|rule| rule.right - rule.left >= TABLE_REGION_RULE_MIN_EM * em)
4652        .collect();
4653    rules.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
4654    let same_extent = |a: &HorizontalRule, b: &HorizontalRule| {
4655        let overlap = a.right.min(b.right) - a.left.max(b.left);
4656        let narrower = (a.right - a.left).min(b.right - b.left);
4657        overlap >= 0.9 * narrower && narrower >= 0.8 * (a.right - a.left).max(b.right - b.left)
4658    };
4659    let mut regions: Vec<TableRegion> = Vec::new();
4660    let mut t = 0;
4661    while t < rules.len() {
4662        let top = rules[t];
4663        let mut found: Option<(usize, TableRegion)> = None;
4664        for (f, foot) in rules.iter().enumerate().skip(t + 1) {
4665            if !same_extent(top, foot) {
4666                continue;
4667            }
4668            let inside: Vec<usize> = (0..bands.len())
4669                .filter(|&k| bands[k].y < top.y && bands[k].y > foot.y && !runs[k].is_empty())
4670                .collect();
4671            // The ink's own extent, from the runs: a band's `right()` is the
4672            // estimate's, deliberately wide, and a long line of a description
4673            // column measured by it runs out past the rule it stops short of.
4674            // A mark in the margin -- a manual's change bar `|`, a footnote
4675            // dagger -- stands outside the rules and is no ink of the table's:
4676            // runs of at most two characters are not asked.
4677            let within = |k: usize| {
4678                runs[k]
4679                    .iter()
4680                    .filter(|r| r.chars > TABLE_REGION_MARGIN_MARK)
4681                    .all(|r| r.left >= top.left - em && r.right <= top.right + em)
4682            };
4683            let contained = inside.iter().all(|&k| within(k));
4684            let celled = inside.iter().filter(|&&k| runs[k].len() >= 2).count();
4685            let tight = inside
4686                .windows(2)
4687                .all(|w| bands[w[0]].y - bands[w[1]].y <= TABLE_REGION_MAX_STEP_PITCHES * pitch);
4688            if !contained || !tight {
4689                break;
4690            }
4691            if inside.len() >= TABLE_REGION_MIN_ROWS && celled >= 2 {
4692                let top_y = inside
4693                    .first()
4694                    .map(|&k| {
4695                        bands[k]
4696                            .spans
4697                            .iter()
4698                            .map(|s| s.y)
4699                            .fold(f64::NEG_INFINITY, f64::max)
4700                    })
4701                    .unwrap_or(top.y);
4702                // A table ruled ABOVE each row has its last row under the last
4703                // rule: rows directly below it, in the extent and broken into
4704                // cells, are still the table's.
4705                let mut last = *inside.last().unwrap_or(&0);
4706                for k in last + 1..bands.len() {
4707                    if runs[k].is_empty() {
4708                        continue;
4709                    }
4710                    let in_extent = within(k);
4711                    if runs[k].len() < 2
4712                        || !in_extent
4713                        || bands[last].y - bands[k].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch
4714                        || foot.y - bands[k].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch
4715                    {
4716                        break;
4717                    }
4718                    last = k;
4719                }
4720                let bottom_y = bands[last]
4721                    .spans
4722                    .iter()
4723                    .map(|s| s.y)
4724                    .fold(f64::INFINITY, f64::min);
4725                found = Some((
4726                    f,
4727                    TableRegion {
4728                        left: top.left.min(foot.left),
4729                        right: top.right.max(foot.right),
4730                        top: top_y,
4731                        bottom: bottom_y,
4732                        ruled: true,
4733                        rows: inside.len(),
4734                    },
4735                ));
4736            }
4737        }
4738        match found {
4739            Some((f, region)) => {
4740                regions.push(region);
4741                t = f + 1;
4742            }
4743            None => t += 1,
4744        }
4745    }
4746    regions
4747}
4748
4749/// Where a region's rows start when their first runs are a column of justified
4750/// prose rather than labels: just left of the leftmost second run. `None` when
4751/// the first runs are ragged, short, or too few (#574).
4752fn prose_stub_edge(rows: &[Vec<InkRun>], em: f64) -> Option<f64> {
4753    let firsts: Vec<&InkRun> = rows
4754        .iter()
4755        .filter(|r| r.len() >= 2)
4756        .map(|r| &r[0])
4757        .collect();
4758    if firsts.len() < TABLE_REGION_MIN_ROWS {
4759        return None;
4760    }
4761    // The long runs, since a paragraph's last line and a short one of a new
4762    // paragraph are neither long nor flush; half the rows at least.
4763    let long: Vec<&&InkRun> = firsts
4764        .iter()
4765        .filter(|r| r.chars > TABLE_REGION_LONG_CELL)
4766        .collect();
4767    if long.len() * 2 < firsts.len() {
4768        return None;
4769    }
4770    let mut rights: Vec<f64> = long.iter().map(|r| r.right).collect();
4771    rights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4772    let median = rights[rights.len() / 2];
4773    let flush = rights
4774        .iter()
4775        .filter(|r| (*r - median).abs() <= TABLE_REGION_JUSTIFIED_EM * em)
4776        .count();
4777    if flush * 3 < rights.len() * 2 {
4778        return None;
4779    }
4780    rows.iter()
4781        .filter(|r| r.len() >= 2)
4782        .map(|r| r[1].left)
4783        .min_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
4784        .map(|x| x - 0.5 * em)
4785}
4786
4787/// How far apart, in ems, the right edges of a column's lines may be and still
4788/// be one justified edge.
4789const TABLE_REGION_JUSTIFIED_EM: f64 = 0.5;
4790
4791/// Tables of two columns: a short key and what it stands for (#574).
4792///
4793/// `Option | Function`, `F3 | Exit`, `11 | Work with PFD definitions` -- a
4794/// manual sets its menus and its function keys this way, and DocLayNet calls
4795/// every one a table. A row of two runs is also what a page set in two columns
4796/// is, which is why `table_regions` asks for three; here the two columns are
4797/// told from a page's by what they hold and how they stand: the first cell is
4798/// short and no list marker, the second is no bullet, and both start at the
4799/// same place on at least `TABLE_REGION_MIN_ROWS` rows running. The second
4800/// cell may wrap onto a line of its own under itself.
4801fn pair_regions(
4802    bands: &[TextBand],
4803    runs: &[Vec<InkRun>],
4804    pitch: f64,
4805    tolerance: f64,
4806    taken: &[(f64, f64)],
4807    min_rows: usize,
4808) -> Vec<TableRegion> {
4809    let free = |y: f64| !taken.iter().any(|(bottom, top)| y >= *bottom && y <= *top);
4810    let is_pair = |runs: &[InkRun]| {
4811        runs.len() == 2
4812            && runs[0].chars <= TABLE_REGION_SHORT_CELL
4813            && !runs[0].bulleted
4814            && !runs[1].bulleted
4815            && !is_list_marker(&runs[0].text)
4816    };
4817    let mut regions = Vec::new();
4818    let mut i = 0;
4819    while i < bands.len() {
4820        if !is_pair(&runs[i]) || !free(bands[i].y) {
4821            i += 1;
4822            continue;
4823        }
4824        let (key, value) = (runs[i][0].left, runs[i][1].left);
4825        let (mut last_row, mut rows, mut loose) = (i, 1, 0);
4826        let mut above = bands[i].y;
4827        let mut j = i + 1;
4828        while j < bands.len() {
4829            let here = &runs[j];
4830            if here.is_empty() {
4831                j += 1;
4832                continue;
4833            }
4834            if above - bands[j].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch || !free(bands[j].y) {
4835                break;
4836            }
4837            above = bands[j].y;
4838            if is_pair(here)
4839                && (here[0].left - key).abs() <= tolerance
4840                && (here[1].left - value).abs() <= tolerance
4841            {
4842                last_row = j;
4843                rows += 1;
4844                loose = 0;
4845            } else if here.len() == 1 && (here[0].left - value).abs() <= tolerance {
4846                loose += 1;
4847                if loose > TABLE_REGION_MAX_LOOSE {
4848                    break;
4849                }
4850            } else {
4851                break;
4852            }
4853            j += 1;
4854        }
4855        if rows >= min_rows {
4856            let chosen = &bands[i..=last_row];
4857            regions.push(TableRegion {
4858                left: chosen
4859                    .iter()
4860                    .map(TextBand::left)
4861                    .fold(f64::INFINITY, f64::min),
4862                right: chosen
4863                    .iter()
4864                    .map(TextBand::right)
4865                    .fold(f64::NEG_INFINITY, f64::max),
4866                top: bands[i]
4867                    .spans
4868                    .iter()
4869                    .map(|span| span.y)
4870                    .fold(f64::NEG_INFINITY, f64::max),
4871                bottom: bands[last_row]
4872                    .spans
4873                    .iter()
4874                    .map(|span| span.y)
4875                    .fold(f64::INFINITY, f64::min),
4876                ruled: true,
4877                rows,
4878            });
4879        }
4880        i = last_row + 1;
4881    }
4882    regions
4883}
4884
4885/// A span that holds its own leader -- `Characters per line ........ 10` drawn in
4886/// one string -- as the label with its dots and, apart, the figure after them
4887/// (#574). The label's box stops where its words do, so the white the dots
4888/// stand in is a gutter to `table_regions` and `tables::find` alike; the dots
4889/// stay in the label's text, since the reader keeps them. A span with no leader
4890/// of at least `TABLE_REGION_LEADER_DOTS` dots, or with more than a short
4891/// figure after it, comes back as it went in.
4892fn split_at_leader(span: TextSpan) -> Vec<TextSpan> {
4893    let chars: Vec<char> = span.text.chars().collect();
4894    // The last run of at least `TABLE_REGION_LEADER_DOTS` dots, single spaces
4895    // allowed between them: the figure after it may hold a point of its own.
4896    let (mut start, mut end, mut dots) = (0usize, 0usize, 0usize);
4897    let mut k = 0;
4898    while k < chars.len() {
4899        if chars[k] != '.' {
4900            k += 1;
4901            continue;
4902        }
4903        let (from, mut count, mut to) = (k, 0usize, k);
4904        while to < chars.len()
4905            && (chars[to] == '.'
4906                || (chars[to] == ' ' && to + 1 < chars.len() && chars[to + 1] == '.'))
4907        {
4908            if chars[to] == '.' {
4909                count += 1;
4910            }
4911            to += 1;
4912        }
4913        if count >= TABLE_REGION_LEADER_DOTS {
4914            (start, end, dots) = (from, to, count);
4915        }
4916        k = to.max(k + 1);
4917    }
4918    let label: String = chars[..start].iter().collect();
4919    let tail: String = chars[end..].iter().collect::<String>().trim().to_string();
4920    if dots < TABLE_REGION_LEADER_DOTS
4921        || label.trim().is_empty()
4922        || tail.chars().count() > TABLE_REGION_SHORT_CELL
4923    {
4924        return vec![span];
4925    }
4926    let n = chars.len().max(1) as f64;
4927    let exact = span.char_bounds.len() == chars.len();
4928    let at = |i: usize, right: bool| -> f64 {
4929        if exact {
4930            span.char_bounds[i][if right { 2 } else { 0 }]
4931        } else {
4932            span.x + span.width * ((i + usize::from(right)) as f64 / n)
4933        }
4934    };
4935    let label_end = label.trim_end().chars().count().max(1) - 1;
4936    let label_right = at(label_end, true);
4937    let mut out = Vec::with_capacity(2);
4938    let mut head = span.clone();
4939    head.text = chars[..end].iter().collect();
4940    head.width = (label_right - span.x).max(0.0);
4941    head.char_bounds.clear();
4942    head.tight_char_bounds.clear();
4943    head.glyph_advances.clear();
4944    head.glyph_bounds_sources.clear();
4945    out.push(head);
4946    if !tail.is_empty() {
4947        let first = chars[end..]
4948            .iter()
4949            .position(|c| !c.is_whitespace())
4950            .map_or(end, |k| end + k);
4951        let mut figure = span.clone();
4952        figure.text = tail;
4953        figure.x = at(first, false);
4954        figure.width = (span.x + span.width - figure.x).max(0.0);
4955        figure.char_bounds.clear();
4956        figure.tight_char_bounds.clear();
4957        figure.glyph_advances.clear();
4958        figure.glyph_bounds_sources.clear();
4959        out.push(figure);
4960    }
4961    out
4962}
4963
4964/// Each leader span's dots appended to the text of the span before it, and the
4965/// leader span removed; `spans` is in reading order along the band.
4966///
4967/// The dots' glyphs ride along: their boxes are appended to the label's, so a
4968/// word the fold glued keeps one box per glyph. Dropped, a folded dot left its
4969/// word boxless and the word fell back to the label's whole box -- a decimal
4970/// dot read on top of its digit, and no join rule could put `1.1.1` together
4971/// again.
4972///
4973/// Where the dots abut the label on its own line -- a decimal dot, not a
4974/// leader run across white -- they join it as text and box: `2` and `.` read
4975/// `2.`, and the label's box grows to cover them, because the box claims the
4976/// text and a gap measured past a stale edge reads white the ink fills. Dots
4977/// standing off across white keep their space and leave the box alone, so
4978/// the gutter past them still reads as one.
4979///
4980/// The abutting join is on both paths (#655): a decimal dot is a decimal
4981/// dot whatever the option says. What `cell_path_words` still gates is the
4982/// standoff fold: with it the dots' boxes ride along, without it the dots
4983/// join with a space and leave the glyph boxes behind, exactly as before.
4984fn fold_leaders(spans: &mut Vec<TextSpan>, cell_path_words: bool) {
4985    let mut kept: Vec<TextSpan> = Vec::with_capacity(spans.len());
4986    for span in spans.drain(..) {
4987        match kept.last_mut() {
4988            Some(label) if is_leader(&span) => {
4989                // Abutting on the label's own line: no daylight between the
4990                // label's right edge and the dots' left, and their heights
4991                // overlap. Anything past a hair is a leader across white.
4992                const ABUT_EPS: f64 = 1e-6;
4993                let dots_left = span
4994                    .char_bounds
4995                    .iter()
4996                    .map(|b| b[0])
4997                    .fold(f64::INFINITY, f64::min);
4998                let dots_right = span
4999                    .char_bounds
5000                    .iter()
5001                    .map(|b| b[2])
5002                    .fold(f64::NEG_INFINITY, f64::max);
5003                let dots_top = span
5004                    .char_bounds
5005                    .iter()
5006                    .map(|b| b[3])
5007                    .fold(f64::NEG_INFINITY, f64::max);
5008                let dots_bottom = span
5009                    .char_bounds
5010                    .iter()
5011                    .map(|b| b[1])
5012                    .fold(f64::INFINITY, f64::min);
5013                let abuts = dots_left <= label.x + label.width + ABUT_EPS
5014                    && dots_top > label.y
5015                    && dots_bottom < label.y + label.height;
5016                if abuts {
5017                    label.text.push_str(span.text.trim());
5018                    label.char_bounds.extend(span.char_bounds.iter().copied());
5019                    label
5020                        .tight_char_bounds
5021                        .extend(span.tight_char_bounds.iter().copied());
5022                    label
5023                        .glyph_advances
5024                        .extend(span.glyph_advances.iter().copied());
5025                    label
5026                        .glyph_bounds_sources
5027                        .extend(span.glyph_bounds_sources.iter().copied());
5028                    label.width = (dots_right - label.x).max(label.width).max(0.0);
5029                    continue;
5030                }
5031                if !cell_path_words {
5032                    label.text.push(' ');
5033                    label.text.push_str(span.text.trim());
5034                    continue;
5035                }
5036                label.text.push(' ');
5037                label.text.push_str(span.text.trim());
5038                label.char_bounds.extend(span.char_bounds.iter().copied());
5039                label
5040                    .tight_char_bounds
5041                    .extend(span.tight_char_bounds.iter().copied());
5042                label
5043                    .glyph_advances
5044                    .extend(span.glyph_advances.iter().copied());
5045                label
5046                    .glyph_bounds_sources
5047                    .extend(span.glyph_bounds_sources.iter().copied());
5048            }
5049            _ => kept.push(span),
5050        }
5051    }
5052    *spans = kept;
5053}
5054
5055/// Rows joined to their figures by a dot leader: a table of contents, a list of
5056/// tables, a statement set with leaders (#574). The leader is dropped by
5057/// `ink_runs`, so a row is a label and a short figure with white between them
5058/// where the dots were; `leader_rows` counts the dots, since a label and a page
5059/// number with white between them and no leader is a line of prose that ends
5060/// in a number. At least `TABLE_REGION_MIN_ROWS` such rows running, a label
5061/// that wraps onto a line of its own allowed between them.
5062fn leader_regions(
5063    bands: &[TextBand],
5064    runs: &[Vec<InkRun>],
5065    pitch: f64,
5066    taken: &[(f64, f64)],
5067    min_rows: usize,
5068) -> Vec<TableRegion> {
5069    let free = |y: f64| !taken.iter().any(|(bottom, top)| y >= *bottom && y <= *top);
5070    let is_leader_row = |k: usize| {
5071        // The dots, whether each is a span of its own or they run on inside
5072        // the label's string (`split_at_leader` has cut the figure off).
5073        let dots: usize = bands[k]
5074            .spans
5075            .iter()
5076            .map(|span| {
5077                if is_leader(span) {
5078                    span.text.chars().filter(|c| !c.is_whitespace()).count()
5079                } else {
5080                    span.text
5081                        .trim_end()
5082                        .chars()
5083                        .rev()
5084                        .take_while(|c| *c == '.' || *c == ' ')
5085                        .filter(|c| *c == '.')
5086                        .count()
5087                }
5088            })
5089            .sum();
5090        dots >= TABLE_REGION_LEADER_DOTS
5091            && runs[k].len() >= 2
5092            && runs[k]
5093                .last()
5094                .is_some_and(|run| run.chars <= TABLE_REGION_SHORT_CELL)
5095    };
5096    let mut regions = Vec::new();
5097    let mut i = 0;
5098    while i < bands.len() {
5099        if !is_leader_row(i) || !free(bands[i].y) {
5100            i += 1;
5101            continue;
5102        }
5103        let (mut last_row, mut rows, mut loose) = (i, 1, 0);
5104        let mut above = bands[i].y;
5105        let mut j = i + 1;
5106        while j < bands.len() {
5107            if runs[j].is_empty() {
5108                j += 1;
5109                continue;
5110            }
5111            if above - bands[j].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch || !free(bands[j].y) {
5112                break;
5113            }
5114            above = bands[j].y;
5115            if is_leader_row(j) {
5116                last_row = j;
5117                rows += 1;
5118                loose = 0;
5119            } else if runs[j].len() == 1 {
5120                loose += 1;
5121                if loose > 2 {
5122                    break;
5123                }
5124            } else {
5125                break;
5126            }
5127            j += 1;
5128        }
5129        if rows >= min_rows {
5130            let chosen = &bands[i..=last_row];
5131            regions.push(TableRegion {
5132                left: chosen
5133                    .iter()
5134                    .map(TextBand::left)
5135                    .fold(f64::INFINITY, f64::min),
5136                right: chosen
5137                    .iter()
5138                    .map(TextBand::right)
5139                    .fold(f64::NEG_INFINITY, f64::max),
5140                top: bands[i]
5141                    .spans
5142                    .iter()
5143                    .map(|span| span.y)
5144                    .fold(f64::NEG_INFINITY, f64::max),
5145                bottom: bands[last_row]
5146                    .spans
5147                    .iter()
5148                    .map(|span| span.y)
5149                    .fold(f64::INFINITY, f64::min),
5150                ruled: true,
5151                rows,
5152            });
5153        }
5154        i = last_row + 1;
5155    }
5156    regions
5157}
5158
5159/// Whether a short run is a list's marker rather than a key: `1.`, `a)`, `(iv)`.
5160fn is_list_marker(text: &str) -> bool {
5161    let text = text.trim();
5162    text.chars().count() <= 5
5163        && (text.ends_with('.') || text.ends_with(')') || text.starts_with('('))
5164}
5165
5166/// A table region one of the page's detectors proposed (#574): what a judge
5167/// chooses between. `hull` is `[left, bottom, right, top]` in page space, the
5168/// extent of the rows' ink, y up.
5169#[doc(hidden)]
5170#[derive(Debug, Clone, PartialEq)]
5171pub struct TableProposal {
5172    /// `aligned`, `booktabs`, `pair` or `leader`.
5173    pub detector: &'static str,
5174    /// Whether the detector would keep it: `false` for a stack of two rows,
5175    /// which is a proposal for the judge and not a table for the page.
5176    pub accepted: bool,
5177    /// The region's extent.
5178    pub hull: [f64; 4],
5179}
5180
5181/// Every table region the detectors accept on a page, before any gives way to
5182/// another, from the same spans and rules the page's blocks are built from.
5183///
5184/// A PAGE THAT IS ITSELF TURNED proposes nothing here: the regions are found in
5185/// the frame the page was written in, and until a proposal can be turned back
5186/// with it exactly, an empty list is the honest answer. Hidden: this is the
5187/// feature dump's and the table judge's, not a promise.
5188#[doc(hidden)]
5189pub fn table_proposals(spans: &[TextSpan], rules: &[HorizontalRule]) -> Vec<TableProposal> {
5190    if spans.is_empty() || PageTurn::of(spans) != PageTurn::None {
5191        return Vec::new();
5192    }
5193    let stats = PageStats::from_spans(spans);
5194    let spans: Vec<TextSpan> = spans.iter().cloned().flat_map(split_at_leader).collect();
5195    let mut seen = Vec::new();
5196    table_regions_seen(&spans, &stats, rules, Some(&mut seen));
5197    // And each column of a page set in columns, as a page of its own: a table
5198    // in one column shares its bands with the other column's prose, which no
5199    // detector reads as rows on the whole page (see `xy_cut_round_tables`).
5200    if let Some((columns, _)) = try_vertical_cut(&spans, &stats, rules) {
5201        for column in &columns {
5202            let mut more = Vec::new();
5203            table_regions_seen(column, &stats, rules, Some(&mut more));
5204            for found in more {
5205                if !seen.contains(&found) {
5206                    seen.push(found);
5207                }
5208            }
5209        }
5210    }
5211    seen.into_iter()
5212        .map(|(r, detector)| {
5213            let top = r.top
5214                + spans
5215                    .iter()
5216                    .filter(|s| r.holds(s))
5217                    .map(|s| s.height)
5218                    .fold(0.0, f64::max);
5219            TableProposal {
5220                detector,
5221                accepted: r.rows >= TABLE_REGION_MIN_ROWS,
5222                hull: [r.left, r.bottom, r.right, top],
5223            }
5224        })
5225        .collect()
5226}
5227
5228/// The page cut round its tables (#574): each table region is one leaf, the
5229/// rest goes through the XY-cut, and each table is put back in front of the
5230/// first region below it that it shares any width with. Each region comes back
5231/// with whether it is one of those tables, which `blocks::paragraphs` reads as
5232/// "one grid if it makes one" (`tables::find_whole`).
5233fn xy_cut_round_tables(
5234    spans: Vec<TextSpan>,
5235    stats: &PageStats,
5236    rules: &[HorizontalRule],
5237    merge_turned_runs: bool,
5238    cell_path_words: bool,
5239) -> Vec<(Vec<TextBlock>, bool)> {
5240    let tables = table_regions(&spans, stats, rules);
5241    // Each quarter-turned run the layout would break as one span for the
5242    // layout below -- the cut, the bands, the grid -- while the detectors
5243    // above read the spans as drawn (#574). A marginal run merged first
5244    // stops being the bands, steps and short marks the detectors count: on
5245    // one proxy page thirteen one-glyph bands veto an aligned region that
5246    // blocks a ruled one, and merged to one span the veto count drops under
5247    // the threshold, the page comes back one ruled region, and two columns
5248    // of prose are gridded as a table.
5249    //
5250    // On an upright page only: a turned page is already in the frame it was
5251    // written in here, where its own runs read upright and its MINORITY --
5252    // the upright furniture of a landscape table -- reads turned, and merging
5253    // that minority re-decides the table around it (#574: a landscape segment
5254    // report comes back more duplicated and fewer of its words match).
5255    let spans = if merge_turned_runs {
5256        merge_rotated_runs(spans)
5257    } else {
5258        spans
5259    };
5260    if tables.is_empty() {
5261        return xy_cut_recursive(spans, 0, stats, rules)
5262            .into_iter()
5263            .map(|region| (region, false))
5264            .collect();
5265    }
5266    // A PAGE SET IN COLUMNS IS LOOKED AT ONE COLUMN AT A TIME. Across the
5267    // gutter a line of prose and a row of a table share a band, and the prose
5268    // reads as the row's stub: the table's region took the column beside it
5269    // with it. Where the cut would divide the page into columns anyway, each
5270    // column is its own page for this.
5271    // Unless the cut would divide a table found by more than its alignment --
5272    // ruled above and below, keys and what they stand for, a leader: the cut
5273    // would take the labels from what they label.
5274    let ruled_across = |columns: &[Vec<TextSpan>]| {
5275        tables.iter().filter(|table| table.ruled).any(|table| {
5276            columns
5277                .iter()
5278                .filter(|column| column.iter().any(|span| table.holds(span)))
5279                .count()
5280                > 1
5281        })
5282    };
5283    if let Some((columns, _)) =
5284        try_vertical_cut(&spans, stats, rules).filter(|(columns, _)| !ruled_across(columns))
5285    {
5286        return columns
5287            .into_iter()
5288            .flat_map(|column| {
5289                xy_cut_round_tables(column, stats, rules, merge_turned_runs, cell_path_words)
5290            })
5291            .collect();
5292    }
5293    let mut held: Vec<Vec<TextSpan>> = vec![Vec::new(); tables.len()];
5294    let mut rest = Vec::new();
5295    for span in spans {
5296        match tables.iter().position(|table| table.holds(&span)) {
5297            Some(at) => held[at].push(span),
5298            None => rest.push(span),
5299        }
5300    }
5301    let mut regions: Vec<(Vec<TextBlock>, bool)> = xy_cut_recursive(rest, 0, stats, rules)
5302        .into_iter()
5303        .map(|region| (region, false))
5304        .collect();
5305    for (table, spans) in tables.iter().zip(held) {
5306        if spans.is_empty() {
5307            continue;
5308        }
5309        let at = regions
5310            .iter()
5311            .position(|(region, _)| {
5312                let spans = region.iter().flat_map(|line| line.spans.iter());
5313                let (mut top, mut left, mut right) =
5314                    (f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
5315                for span in spans {
5316                    top = top.max(span.y);
5317                    left = left.min(span.x);
5318                    right = right.max(span.right());
5319                }
5320                top < table.top && left < table.right && right > table.left
5321            })
5322            .unwrap_or(regions.len());
5323        // One line per row, every cell of the row in it and a wrapped cell's
5324        // lines kept with it (`push_row`): what `tables::find` reads a row as.
5325        // The leaf grouping the cut uses would look for columns of prose in it
5326        // and find the table's.
5327        // A leader's dots go into the text of the label they run out from and
5328        // not into its box: the reader keeps them, and `tables::find` sees the
5329        // gutter between the label and its figure that the dots filled.
5330        let mut bands = group_spans_into_bands_with_stats(spans, stats);
5331        for band in &mut bands {
5332            fold_leaders(&mut band.spans, cell_path_words);
5333        }
5334        let mut rows = Vec::with_capacity(bands.len());
5335        let mut k = 0;
5336        while k < bands.len() {
5337            k = push_row(&bands, k, &mut rows);
5338        }
5339        regions.insert(at, (rows, true));
5340    }
5341    regions
5342}
5343
5344/// The gap, in ems of the page's body size, that separates two cells of a
5345/// table row when its region is looked for on the whole page (#574).
5346const TABLE_REGION_GAP_EM: f64 = 2.0;
5347/// The narrowest a strip of white between two columns may become, in points,
5348/// before a row stops narrowing it.
5349const TABLE_REGION_MIN_STRIP: f64 = 2.0;
5350/// How far, in ems, a row's gap may miss a strip and still be in it.
5351const TABLE_REGION_EDGE_EM: f64 = 1.5;
5352/// A cell this short (non-blank characters) is a value, a year or a unit.
5353const TABLE_REGION_SHORT_CELL: usize = 16;
5354/// No cell but the stub may be longer than this: a run of prose beside a table
5355/// is a column of the page, not a cell of the table.
5356const TABLE_REGION_LONG_CELL: usize = 40;
5357/// The fewest rows that make a table region.
5358const TABLE_REGION_MIN_ROWS: usize = 3;
5359/// How many bands in a row may be labels or sparse rows inside a table.
5360const TABLE_REGION_MAX_LOOSE: usize = 5;
5361/// How many head lines over the value columns a table may carry above its
5362/// first row.
5363const TABLE_REGION_MAX_HEADS: usize = 2;
5364/// The most characters a run outside a ruled table's extent may hold and still
5365/// be a mark in the margin rather than text beside the table.
5366const TABLE_REGION_MARGIN_MARK: usize = 2;
5367/// How far apart, in points, two rule segments at one height may be and still
5368/// be one rule.
5369const RULE_JOIN_TOLERANCE: f64 = 2.0;
5370/// How long a rule must be, in ems, to open or close a table.
5371const TABLE_REGION_RULE_MIN_EM: f64 = 10.0;
5372/// The gap, in ems, that separates a key from its description.
5373const TABLE_REGION_PAIR_GAP_EM: f64 = 1.0;
5374/// How many leader dots make a row a leader row.
5375const TABLE_REGION_LEADER_DOTS: usize = 4;
5376/// How much white between two rows of a two-column table, in line pitches: less
5377/// than a statement's, since such a table has no groups to set apart.
5378const TABLE_REGION_PAIR_STEP_PITCHES: f64 = 2.5;
5379/// How much white between two bands, in line pitches, still lies inside one
5380/// table: the space between a statement's groups.
5381const TABLE_REGION_MAX_STEP_PITCHES: f64 = 4.0;
5382
5383/// The leading the body of THIS SET OF SPANS is set on: the commonest step
5384/// between its bands (#580).
5385///
5386/// PER REGION AND NOT PER PAGE, which is the whole of what makes this safe. A
5387/// page-wide leading is the leading of whatever the page has most of, and a
5388/// document that mixes dense form boxes with columns of prose has two: on
5389/// `pdfjs__test_pdfs_issue6127.pdf` the prose sets it to 12 points while the
5390/// form's own zone gaps are under that, so a page-wide floor suppressed a cut
5391/// the form needed and glued a heading to the marginal notes beside it
5392/// (`order_agreement` 97.8 -> 81.1 over the gate set). The region being cut
5393/// answers about itself.
5394///
5395/// Bounded on both sides, and both bounds earn their place:
5396///   * at least 0.8 of the line height, because two bands closer than that are
5397///     one line's sub- and superscripts, a split run or a rule -- the very
5398///     steps that drag `median_line_spacing` down. That bound is what tells
5399///     6.0pt of table pitch from 12.0pt of leading on the pages this fixes. It
5400///     is on the HEIGHT and not on the em, because a leading can sit below the
5401///     em -- a 10-on-12 setting does, and at 0.9 this refused
5402///     `xy_cut_leaf_falls_back_to_legacy_columns_for_header_plus_three_columns`.
5403///   * at most three ems, because a wider step is a paragraph break, a zone
5404///     break or the white above a heading. Without the ceiling a region that is
5405///     mostly table reports its one big gap as the leading, 57.6pt on an 8pt
5406///     page.
5407///
5408/// Zero when the region has no step that could be a leading at all, which says
5409/// "do not floor anything" rather than "the leading is nothing".
5410fn leading_of_a_region(spans: &[TextSpan], stats: &PageStats) -> f64 {
5411    let tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
5412    let mut ordered: Vec<&TextSpan> = spans.iter().collect();
5413    ordered.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
5414    // Each band keeps the leftmost ink on it, because whether two bands are
5415    // lines of one paragraph is a question about their left edge.
5416    let mut bands: Vec<(f64, f64)> = Vec::new();
5417    for span in ordered {
5418        match bands.last_mut() {
5419            Some(band) if (band.0 - span.y).abs() <= tolerance => {
5420                band.1 = band.1.min(span.x);
5421            }
5422            _ => bands.push((span.y, span.x)),
5423        }
5424    }
5425    let aligned = LEADING_ALIGNED_EM * stats.median_font_size;
5426    let mut steps: Vec<f64> = bands
5427        .windows(2)
5428        // CONTINUOUS TEXT, and not merely a step of the right size (codex, #580
5429        // round two). Two bands are lines of one paragraph when they start at the
5430        // same left edge; a masthead over a title over a byline is three bands
5431        // that may be equally spaced by coincidence but are not flush with each
5432        // other. Without this, three equidistant one-line zones read as a leading
5433        // and the floor suppresses both of the region's cuts -- and a rule
5434        // counting occurrences alone cannot tell the two apart, because two equal
5435        // gaps is every gap such a region has.
5436        .filter(|pair| (pair[0].1 - pair[1].1).abs() <= aligned)
5437        .map(|pair| (pair[0].0 - pair[1].0).abs())
5438        .filter(|step| {
5439            *step >= LEADING_MIN_EM * stats.median_font_size
5440                && *step <= LEADING_MAX_EM * stats.median_font_size
5441        })
5442        .map(|step| (step * 10.0).round() / 10.0)
5443        .collect();
5444    steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
5445    let mut best = (0usize, 0.0f64);
5446    let mut i = 0;
5447    while i < steps.len() {
5448        let mut j = i;
5449        while j < steps.len() && steps[j] == steps[i] {
5450            j += 1;
5451        }
5452        if j - i > best.0 {
5453            best = (j - i, steps[i]);
5454        }
5455        i = j;
5456    }
5457    // A LEADING IS A REPETITION THE REGION DOES NOT EXHAUST (codex, #580, both
5458    // rounds). One step is not a repetition: a region of exactly two bands has a
5459    // single gap, and that gap would win this count with a tally of one, so a
5460    // header over a body would be read as the leading of a body it is not part
5461    // of. The floor would then refuse the only horizontal cut the region has,
5462    // leave the full-width band in place, and through that block the column split
5463    // underneath it -- the interleave this change exists to avoid, reintroduced
5464    // by the change itself.
5465    //
5466    // AND A COUNT THAT EVERY GAP IN THE REGION SATISFIES IS NOT EVIDENCE EITHER,
5467    // which is the second round's point: three bands set equally apart have two
5468    // equal gaps whatever they are, so raising this count to three only moves the
5469    // same hole from two bands to three. What answers it is the filter above --
5470    // the gaps have to be between bands that are FLUSH with each other, which is
5471    // what tells three lines of a paragraph from a masthead over a centred title
5472    // over an indented byline. The count then only has to say "more than one".
5473    //
5474    // Raising the count to three was measured too and rejected: it changed ten of
5475    // the seventy-five pages this machine may hold and left four of the
5476    // twenty-one the floor had fixed unfixed, for a hole the alignment filter
5477    // closes without giving anything up.
5478    if best.0 < LEADING_MIN_OCCURRENCES {
5479        return 0.0;
5480    }
5481    best.1
5482}
5483
5484/// How many gaps of one size, between bands flush with each other, a region must
5485/// show before that size counts as its leading (#580). Two: three lines set
5486/// together on one leading is the least a body can be and still have one, and
5487/// what keeps a small region from inventing a leading out of its own two gaps is
5488/// that those gaps have to be between left-aligned bands, not that there are
5489/// more of them.
5490const LEADING_MIN_OCCURRENCES: usize = 2;
5491
5492/// The narrowest step that can be a line of text rather than one line's own
5493/// parts, as a share of the line height (#580).
5494const LEADING_MIN_EM: f64 = 0.8;
5495/// The widest step that can still be a leading rather than a break (#580).
5496const LEADING_MAX_EM: f64 = 3.0;
5497/// How far two bands' left edges may differ and still be lines of one paragraph
5498/// (#580). Half an em: flush enough to be set together, loose enough that the
5499/// measured left edge of a line opening with a narrow glyph still counts.
5500const LEADING_ALIGNED_EM: f64 = 0.5;
5501
5502/// How far above the body's leading the paragraph-break threshold has to stay
5503/// (#580). Just above, and deliberately so: the threshold also has to stay
5504/// BELOW the page's paragraph breaks or the zone cuts stop firing, and a page
5505/// whose paragraph break is only a fifth wider than its leading exists.
5506const HORIZONTAL_CUT_LEADING_FLOOR: f64 = 1.05;
5507/// Minimum number of spans a column must contain before it is eligible
5508/// for acceptance — one-span "columns" are almost always sidebar noise
5509/// or table-cell fragments.
5510/// The white, in ems of its own size, after a single glyph that sets it apart
5511/// as a marker hung into a gutter rather than a word of the line (#574).
5512const HUNG_MARKER_GAP_EM: f64 = 0.6;
5513/// The letters, not counting figures or signs, the median line of each side
5514/// of a gutter found past hung markers must hold to be a column of text.
5515const RUNNING_LETTERS: usize = 30;
5516const XY_CUT_MIN_SPANS_PER_COLUMN: usize = 2;
5517/// Average characters per band a column must have before it's accepted
5518/// as dense prose (vs. a short-cell table column).
5519const XY_CUT_MIN_CHARS_PER_BAND: f64 = 8.0;
5520
5521/// The page's spans as blocks, in reading order, given the horizontal rules the
5522/// page drew (#545): a region the document itself rules into rows is a table,
5523/// and a table is not read column by column.
5524///
5525/// ANN[r17/TEX2][r17/TEX4] Top-level grouping uses recursive XY-Cut
5526/// with a density guard. Per-page stats are computed once up front so
5527/// every decision downstream speaks the same typographic baseline.
5528fn group_spans_into_blocks(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<TextBlock> {
5529    if spans.is_empty() {
5530        return Vec::new();
5531    }
5532    // A page that is itself turned is read in the frame it was written in, and
5533    // an upright page is turned by nothing at all (#546).
5534    let turn = PageTurn::of(&spans);
5535    let spans = turn.into_frame(spans);
5536    let stats = PageStats::from_spans(&spans);
5537    let rules = turn.rules_in_frame(rules);
5538    // Each quarter-turned run as one span, so the cut cannot part it at a
5539    // word gap (#574). After the stats, which describe the spans as drawn. On
5540    // an upright page only: in the frame of a turned page the minority reads
5541    // turned, and it is not this merge's to touch.
5542    let spans = if turn == PageTurn::None {
5543        merge_rotated_runs(spans)
5544    } else {
5545        spans
5546    };
5547    let blocks = xy_cut_recursive(spans, 0, &stats, &rules)
5548        .into_iter()
5549        .flatten()
5550        .collect();
5551    let blocks = reassemble_cross_direction_runs(blocks);
5552    turn.back(blocks)
5553}
5554
5555/// The page's spans as paragraphs, in reading order (#567).
5556///
5557/// The same layout as [`group_spans_into_blocks`], up to the point where it
5558/// hands back lines: the page is turned into the frame it was written in, cut,
5559/// and its cross-direction runs reassembled. What is added is the merge, and it
5560/// runs in that same frame -- "the next line down" and "the left margin" are
5561/// statements about the frame the type was set in, not about page space. The
5562/// spans a caller receives are in page space, exactly as `into_blocks` leaves
5563/// them.
5564/// The reading direction of a run, quantized to quarter turns (#643): 0 is
5565/// upright or unknown, then 90, 180 and 270 degrees counter-clockwise. Only
5566/// equality matters: runs that read in different directions never share a
5567/// raw block.
5568fn raw_direction(span: &TextSpan) -> u8 {
5569    let Some(t) = span.transform else {
5570        return 0;
5571    };
5572    let (a, b) = (t[0], t[1]);
5573    if a.abs() >= b.abs() {
5574        if a >= 0.0 {
5575            0
5576        } else {
5577            2
5578        }
5579    } else if b >= 0.0 {
5580        1
5581    } else {
5582        3
5583    }
5584}
5585
5586/// Whether the face is one of the fourteen the viewer always has, by the
5587/// name the spans carry (#643).
5588fn raw_is_base14(font_name: Option<&str>) -> bool {
5589    font_name.is_some_and(|name| {
5590        name.starts_with("Helvetica")
5591            || name.starts_with("Times")
5592            || name.starts_with("Courier")
5593            || name.starts_with("Symbol")
5594            || name.starts_with("ZapfDingbats")
5595    })
5596}
5597
5598/// A run's vertical metrics in ems, for the tall boxes the raw grouping
5599/// overlaps (#643): the recorded base-14 pair when the face is one, else the
5600/// embedded font's ascent and descent, else Helvetica's recorded pair for a
5601/// base-14 face with no binary, else the engine's embedded fallback. A face
5602/// the metrics lookup missed is an embedded face, not base-14: the caption
5603/// over the form's title gaps it by 3pt in embedded metrics and would drown
5604/// in Helvetica's.
5605fn raw_metrics(span: &TextSpan) -> (f64, f64) {
5606    match span.font_name.as_deref() {
5607        Some("Helvetica") => (f64::from(1.075f32), f64::from(-0.299f32)),
5608        Some("Helvetica-Bold") => (1.07, -0.307),
5609        _ => span
5610            .font_metrics
5611            .map(|m| (m.ascent / 1000.0, m.descent / 1000.0))
5612            .unwrap_or_else(|| {
5613                if raw_is_base14(span.font_name.as_deref()) {
5614                    (1.075, -0.299)
5615                } else {
5616                    (0.718, -0.207)
5617                }
5618            }),
5619    }
5620}
5621
5622/// A run's tall box: the advance width with the ascender above the baseline
5623/// and the descender below (#643). Bullet rows set 12pt apart at 10pt type
5624/// overlap in these boxes and join one block; in advance boxes they would
5625/// stand 2pt apart and part.
5626fn raw_tall_box(span: &TextSpan) -> [f64; 4] {
5627    let (ascender, descender) = raw_metrics(span);
5628    [
5629        span.x,
5630        span.y + descender * span.font_size,
5631        span.x + span.width,
5632        span.y + ascender * span.font_size,
5633    ]
5634}
5635
5636/// How far apart two runs may stand along the line and still share it, as a
5637/// multiple of the larger type size (#643). The recordings join the 2.65pt
5638/// gap at 7pt type (0.38em, `Deceased` to `MM`) and part the 6.5pt gap at 8pt
5639/// type (0.81em, the dot leaders) and the 10.5pt gap at 10pt type (1.05em,
5640/// the bullets from their text).
5641const RAW_LINE_GAP_EM: f64 = 0.6;
5642
5643/// How far back past the line's end a run may start and still join it, as a
5644/// multiple of the larger type size (#643). The `1a` line number reads back
5645/// 2.2pt at 8pt type (0.28em) into its own line, while a new row reads back
5646/// whole ems (`/` 46pt at 7pt type past `YYYY`). Shares the forward gap's
5647/// threshold; the back side is untested between 0.28em and 6.6em.
5648const RAW_LINE_BACK_EM: f64 = 0.6;
5649
5650/// Cut a block's runs into lines: the x-runs (#643).
5651///
5652/// A run joins the open line while it reads in the line's direction, its
5653/// tall box overlaps the run drawn just before it, and it starts within
5654/// sixtenths of an em of the line's end on either side. Anything else --
5655/// the bullets past their 10.5pt gap, `25` back left of `U.S.`, the caption
5656/// under the title -- opens the next line. Returns the line each run lands
5657/// on, in run order.
5658fn raw_line_of(spans: &[TextSpan]) -> Vec<usize> {
5659    let mut lines = Vec::with_capacity(spans.len());
5660    let mut line = 0;
5661    let mut end = f64::NEG_INFINITY;
5662    let mut low = f64::NEG_INFINITY;
5663    let mut high = f64::NEG_INFINITY;
5664    let mut direction = 0;
5665    let mut size = 0.0f64;
5666    for span in spans {
5667        let dir = raw_direction(span);
5668        let tall = raw_tall_box(span);
5669        let overlap = tall[1] <= high && tall[3] >= low;
5670        let gap = span.x - end;
5671        let em = size.max(span.font_size);
5672        if !lines.is_empty()
5673            && (dir != direction
5674                || !overlap
5675                || span.x < end - RAW_LINE_BACK_EM * em
5676                || gap > RAW_LINE_GAP_EM * em)
5677        {
5678            line += 1;
5679        }
5680        direction = dir;
5681        end = span.x + span.width;
5682        low = tall[1];
5683        high = tall[3];
5684        size = span.font_size;
5685        lines.push(line);
5686    }
5687    lines
5688}
5689
5690/// Rebuild a raw block's text and box from its spans (#643).
5691///
5692/// Lines join with a newline, runs within a line concatenate as drawn -- the
5693/// spaces are inside the runs, and the fillers between them arrive with E2c.
5694/// Shared with the hidden-text drop, so a raw block that loses spans keeps
5695/// raw text.
5696pub(crate) fn raw_text_and_box(spans: &[TextSpan]) -> (String, [f64; 4]) {
5697    let mut text = String::new();
5698    let lines = raw_line_of(spans);
5699    for (i, span) in spans.iter().enumerate() {
5700        if i > 0 && lines[i] != lines[i - 1] {
5701            text.push('\n');
5702        }
5703        text.push_str(&span.text);
5704    }
5705    let mut bbox = [
5706        f64::INFINITY,
5707        f64::INFINITY,
5708        f64::NEG_INFINITY,
5709        f64::NEG_INFINITY,
5710    ];
5711    for span in spans {
5712        bbox[0] = bbox[0].min(span.x);
5713        bbox[1] = bbox[1].min(span.y);
5714        bbox[2] = bbox[2].max(span.x + span.width);
5715        bbox[3] = bbox[3].max(span.y + span.height);
5716    }
5717    (text, bbox)
5718}
5719
5720/// Seal the runs drawn so far into one raw paragraph block.
5721fn push_raw_block(blocks: &mut Vec<Block>, spans: Vec<TextSpan>, page: usize, index: usize) {
5722    if spans.is_empty() {
5723        return;
5724    }
5725    let (text, bbox) = raw_text_and_box(&spans);
5726    let mut block = Block::new(BlockKind::Paragraph, page, bbox, text, index);
5727    block.spans = spans;
5728    blocks.push(block);
5729}
5730
5731/// How much of the previous line's tall reach an overlapping row must cover
5732/// to join its block when it reads forward (#643). The recordings join from
5733/// 0.69 up (`Digital Assets` to its row) and part at 0.375 (the staggered
5734/// checkbox rows) and 0.10 (into `2a`).
5735const RAW_FWD_OVERLAP_FRAC: f64 = 0.5;
5736
5737/// How far a gapped row may stand past the previous line and still read on,
5738/// in ems of the larger type size (#643). Past a gap the recordings join
5739/// to 0.125 and part from 0.186; the size-up joins reach 0.15 through the
5740/// size rule below, not through this gap.
5741const RAW_BLOCK_GAP_EM: f64 = 0.15;
5742
5743/// How wide a gapped line may be and still read on, in points (#643). The
5744/// recordings join to 128pt at the same size and part from 178pt (the
5745/// `Married filing jointly` row) and 348pt; the two wider joins (199pt,
5746/// 224pt) read on as larger type through the size rule below.
5747const RAW_BLOCK_WIDTH_PT: f64 = 150.0;
5748
5749/// How much larger a gapped row may be and always read on, as a multiple of
5750/// the previous line's size (#643). The recordings join size-ups from 1.25
5751/// (section heads after wrapped lines; the two wider ones only through this
5752/// rule). Same-size rows part through the gap and width rules; nothing
5753/// between 1.0 and 1.25 tests the threshold from below.
5754const RAW_SIZE_UP_RATIO: f64 = 1.2;
5755
5756/// One cut line with the features the block rule reads (#643).
5757struct RawLine {
5758    spans: Vec<TextSpan>,
5759    bdc_first: Option<u32>,
5760    bdc_last: Option<u32>,
5761    x0: f64,
5762    x1: f64,
5763    low: f64,
5764    high: f64,
5765    direction: u8,
5766    size: f64,
5767}
5768
5769impl RawLine {
5770    fn width(&self) -> f64 {
5771        self.x1 - self.x0
5772    }
5773
5774    fn height(&self) -> f64 {
5775        self.high - self.low
5776    }
5777}
5778
5779/// Whether the next line opens a new block after the previous one (#643).
5780///
5781/// One marked-content segment never parts; a turn of the reading direction
5782/// always does. Between rows that overlap, a row that reads back joins, and
5783/// a row that reads forward joins while it covers half the previous reach.
5784/// Past a gap, a row that reads forward parts, and a row that reads back
5785/// joins when it opens larger type or reads on: same size or larger, a
5786/// short line, a small gap.
5787fn raw_block_split(before: &RawLine, after: &RawLine) -> bool {
5788    if after.direction != before.direction {
5789        return true;
5790    }
5791    if let (Some(a), Some(b)) = (before.bdc_last, after.bdc_first) {
5792        if a == b {
5793            return false;
5794        }
5795    }
5796    let overlap = before.high.min(after.high) - before.low.max(after.low);
5797    let motion = after.x0 - before.x1;
5798    if overlap > 0.0 {
5799        if motion <= 0.5 {
5800            return false;
5801        }
5802        return overlap / before.height() < RAW_FWD_OVERLAP_FRAC;
5803    }
5804    if motion > 0.5 {
5805        return true;
5806    }
5807    let ratio = after.size / before.size;
5808    if ratio >= RAW_SIZE_UP_RATIO {
5809        return false;
5810    }
5811    let gap_em = -overlap / before.size.max(after.size);
5812    !(ratio >= 1.0 && gap_em <= RAW_BLOCK_GAP_EM && before.width() <= RAW_BLOCK_WIDTH_PT)
5813}
5814
5815/// Group the page's runs into blocks in draw order (#643).
5816///
5817/// Lines cut first, over the whole page, and blocks group whole lines: a
5818/// line never spans a block boundary. The frames turn with the page exactly
5819/// as the default path's do; `bdc` carries each run's marked-content
5820/// segment, in run order.
5821fn raw_blocks(
5822    spans: Vec<TextSpan>,
5823    bdc: &[Option<u32>],
5824    page: usize,
5825    first_index: usize,
5826) -> Vec<Block> {
5827    if spans.is_empty() {
5828        return Vec::new();
5829    }
5830    debug_assert_eq!(bdc.len(), spans.len());
5831    let turn = PageTurn::of(&spans);
5832    let spans = turn.into_frame(spans);
5833    let line_of = raw_line_of(&spans);
5834    let mut lines: Vec<RawLine> = Vec::new();
5835    for (i, span) in spans.into_iter().enumerate() {
5836        let tall = raw_tall_box(&span);
5837        let dir = raw_direction(&span);
5838        let fresh = lines.is_empty() || line_of[i] != line_of[i - 1];
5839        if fresh {
5840            lines.push(RawLine {
5841                spans: Vec::new(),
5842                bdc_first: bdc[i],
5843                bdc_last: bdc[i],
5844                x0: span.x,
5845                x1: span.x + span.width,
5846                low: tall[1],
5847                high: tall[3],
5848                direction: dir,
5849                size: span.font_size,
5850            });
5851        }
5852        if let Some(line) = lines.last_mut() {
5853            line.bdc_last = bdc[i];
5854            line.x0 = line.x0.min(span.x);
5855            line.x1 = line.x1.max(span.x + span.width);
5856            line.low = line.low.min(tall[1]);
5857            line.high = line.high.max(tall[3]);
5858            line.spans.push(span);
5859        }
5860    }
5861    let mut blocks = Vec::new();
5862    let mut current: Vec<TextSpan> = Vec::new();
5863    for i in 0..lines.len() {
5864        let (head, tail) = lines.split_at_mut(i);
5865        let line = &mut tail[0];
5866        let cut = i > 0 && raw_block_split(&head[i - 1], line);
5867        if cut {
5868            let index = first_index + blocks.len();
5869            push_raw_block(&mut blocks, std::mem::take(&mut current), page, index);
5870        }
5871        current.append(&mut line.spans);
5872    }
5873    if !current.is_empty() {
5874        let index = first_index + blocks.len();
5875        push_raw_block(&mut blocks, current, page, index);
5876    }
5877    turn.back_blocks(blocks)
5878}
5879
5880fn paragraph_blocks(
5881    spans: Vec<TextSpan>,
5882    rules: &[HorizontalRule],
5883    page: usize,
5884    first_index: usize,
5885    table_strategy: TableStrategy,
5886) -> Vec<Block> {
5887    paragraph_blocks_with_cell_path_words(spans, rules, page, first_index, table_strategy, false)
5888}
5889
5890/// [`paragraph_blocks`] with the cell-path word rules where `cell_path_words`
5891/// is true: the leader fold joins abutting dots, and line and cell texts read
5892/// the quarter-em boundary as a space.
5893fn paragraph_blocks_with_cell_path_words(
5894    spans: Vec<TextSpan>,
5895    rules: &[HorizontalRule],
5896    page: usize,
5897    first_index: usize,
5898    table_strategy: TableStrategy,
5899    cell_path_words: bool,
5900) -> Vec<Block> {
5901    if spans.is_empty() {
5902        return Vec::new();
5903    }
5904    let turn = PageTurn::of(&spans);
5905    let spans = turn.into_frame(spans);
5906    let stats = PageStats::from_spans(&spans);
5907    let rules = turn.rules_in_frame(rules);
5908    // Once, before anything is cut: a leader drawn inside its label's string
5909    // is where the label ends and the figure begins.
5910    let spans: Vec<TextSpan> = spans.into_iter().flat_map(split_at_leader).collect();
5911    let (regions, whole): (Vec<Vec<TextBlock>>, Vec<bool>) = xy_cut_round_tables(
5912        spans,
5913        &stats,
5914        &rules,
5915        turn == PageTurn::None,
5916        cell_path_words,
5917    )
5918    .into_iter()
5919    .unzip();
5920    let direction = page_direction(&regions.iter().flatten().cloned().collect::<Vec<_>>());
5921    let regions: Vec<Vec<TextBlock>> = regions
5922        .into_iter()
5923        .map(|region| match direction {
5924            Some(direction) => reassemble_with_direction(direction, region),
5925            None => region,
5926        })
5927        .collect();
5928    let blocks = crate::blocks::paragraphs_with_cell_path_words(
5929        page,
5930        first_index,
5931        &regions,
5932        &whole,
5933        &rules,
5934        stats.median_line_spacing,
5935        table_strategy,
5936        cell_path_words,
5937    );
5938    // After the merge and before the heading classifier, so the joined block
5939    // is classified like any other (#574).
5940    let blocks = join_marker_continuations(blocks, first_index);
5941    turn.back_blocks(blocks)
5942}
5943
5944/// Join the lines of a double-spaced paragraph back into one block (#574).
5945///
5946/// A passage set on wider leading than the rest of the page clears the
5947/// paragraph-break threshold at every line, so each of its lines arrives here
5948/// as a block of its own: the double-spaced abstract of an arXiv preprint, a
5949/// double-spaced patent description. This pass joins runs of such one-line
5950/// paragraph blocks, guarded so lists, addresses, labels and contents lines
5951/// never join. The rule is a port of the simulated one (`dsim.py`); the guards
5952/// are drawn from the false merges the simulation found.
5953///
5954/// AFTER the heading classifier, and only over [`BlockKind::Paragraph`]:
5955/// stacked headings arrive on the same wide pitch as a double-spaced run, and
5956/// joining before they are marked turns two headings into one paragraph
5957/// (#636).
5958pub(crate) fn join_double_spaced_runs(blocks: Vec<Block>, first_index: usize) -> Vec<Block> {
5959    let sizes: Vec<f64> = blocks.iter().map(block_size).collect();
5960    let single = |i: usize| {
5961        matches!(blocks[i].kind, BlockKind::Paragraph) && block_line_count(&blocks[i]) == 1
5962    };
5963    // The white between a block and the one above it, in ems of the upper.
5964    let gap_above = |i: usize| {
5965        let em = sizes[i - 1];
5966        let em = if em > 0.0 { em } else { 10.0 };
5967        (blocks[i - 1].bbox[1] - blocks[i].bbox[3]) / em
5968    };
5969    let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
5970    let mut i = 0;
5971    while i < blocks.len() {
5972        if !single(i) {
5973            out.push(blocks[i].clone());
5974            i += 1;
5975            continue;
5976        }
5977        let mut run = vec![i];
5978        let mut j = i + 1;
5979        while j < blocks.len() {
5980            let a = run[run.len() - 1];
5981            if blocks[j].page != blocks[a].page || !single(j) {
5982                break;
5983            }
5984            let em = if sizes[a] > 0.0 { sizes[a] } else { 10.0 };
5985            if (sizes[j] - sizes[a]).abs() > DOUBLE_SPACED_SIZE_TOLERANCE_PT {
5986                break;
5987            }
5988            if (blocks[j].bbox[0] - blocks[run[0]].bbox[0]).abs() > DOUBLE_SPACED_LEFT_EM * em {
5989                break;
5990            }
5991            let gap = gap_above(j);
5992            if !(DOUBLE_SPACED_GAP_MIN_EM..=DOUBLE_SPACED_GAP_MAX_EM).contains(&gap) {
5993                break;
5994            }
5995            run.push(j);
5996            j += 1;
5997        }
5998        if run.len() < 2 {
5999            out.push(blocks[i].clone());
6000            i += 1;
6001            continue;
6002        }
6003        let gaps: Vec<f64> = run.iter().skip(1).map(|&k| gap_above(k)).collect();
6004        let median = median_gap(gaps);
6005        let right = run
6006            .iter()
6007            .map(|&k| blocks[k].bbox[2])
6008            .fold(f64::NEG_INFINITY, f64::max);
6009        let mut current = blocks[run[0]].clone();
6010        for pair in run.windows(2) {
6011            let (a, c) = (pair[0], pair[1]);
6012            let em = if sizes[a] > 0.0 { sizes[a] } else { 10.0 };
6013            let upper = blocks[a].text.trim();
6014            let lower = blocks[c].text.trim();
6015            let fills_the_measure = blocks[a].bbox[2] >= right - DOUBLE_SPACED_SHORT_EM * em;
6016            let join = fills_the_measure
6017                && gap_above(c) <= DOUBLE_SPACED_GAP_RATIO * median
6018                && !starts_with_marker(lower)
6019                && !double_spaced_guard(upper, lower);
6020            if join {
6021                if is_hyphen_wrap_candidate(&current.text, lower) {
6022                    current.text.pop();
6023                    current.text.push_str(lower);
6024                } else {
6025                    current.text.push(' ');
6026                    current.text.push_str(lower);
6027                }
6028                current.spans.extend(blocks[c].spans.iter().cloned());
6029                current.bbox = [
6030                    current.bbox[0].min(blocks[c].bbox[0]),
6031                    current.bbox[1].min(blocks[c].bbox[1]),
6032                    current.bbox[2].max(blocks[c].bbox[2]),
6033                    current.bbox[3].max(blocks[c].bbox[3]),
6034                ];
6035            } else {
6036                out.push(current);
6037                current = blocks[c].clone();
6038            }
6039        }
6040        out.push(current);
6041        i = j;
6042    }
6043    for (n, block) in out.iter_mut().enumerate() {
6044        block.index = first_index + n;
6045    }
6046    out
6047}
6048
6049/// Join a marker line back to its hanging continuation (#574).
6050///
6051/// The merge cuts a marker-led line off its continuation at a change of
6052/// weight -- `(74) Agent: ...` over `Road, Kennett Square ...`, bold over
6053/// regular -- and the heading classifier then reads the marker line as a
6054/// heading. Where the continuation hangs under the marker line's text at
6055/// normal leading, the cut is undone here, before classification, so the
6056/// joined block is classified like any other. Indices are renumbered from
6057/// `first_index`; nothing else moves.
6058fn join_marker_continuations(blocks: Vec<Block>, first_index: usize) -> Vec<Block> {
6059    let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
6060    for block in blocks {
6061        let join = match out.last() {
6062            Some(prev) if prev.page == block.page => {
6063                let prev_lines = lines_of_block(prev);
6064                let next_lines = lines_of_block(&block);
6065                match (prev_lines.first(), prev_lines.last(), next_lines.first()) {
6066                    (Some(first), Some(last), Some(c)) => {
6067                        marker_line_hangs_onto(prev, first, last, &block, c)
6068                    }
6069                    _ => false,
6070                }
6071            }
6072            _ => false,
6073        };
6074        if join {
6075            let prev = out.last_mut().expect("a previous block was checked");
6076            let text = crate::blocks::join_lines(&[prev.text.clone(), block.text.clone()]);
6077            prev.spans.extend(block.spans);
6078            prev.text = text;
6079            crate::blocks::recompute_bbox(prev);
6080        } else {
6081            out.push(block);
6082        }
6083    }
6084    for (n, block) in out.iter_mut().enumerate() {
6085        block.index = first_index + n;
6086    }
6087    out
6088}
6089
6090/// The white a line of a double-spaced run stands under, in ems of the line
6091/// above it (#574). Under six tenths the lines were never split; over three
6092/// the white is a zone break however regularly it recurs.
6093const DOUBLE_SPACED_GAP_MIN_EM: f64 = 0.6;
6094/// See [`DOUBLE_SPACED_GAP_MIN_EM`].
6095const DOUBLE_SPACED_GAP_MAX_EM: f64 = 3.0;
6096
6097/// How far the type size may differ between two lines of one run, in points.
6098const DOUBLE_SPACED_SIZE_TOLERANCE_PT: f64 = 0.5;
6099
6100/// How far a line's left edge may stand from the run's first, in ems.
6101const DOUBLE_SPACED_LEFT_EM: f64 = 0.5;
6102
6103/// How far short of the run's measure a line may fall and still be read as
6104/// filling it, in ems: a justified line does, an address line does not.
6105const DOUBLE_SPACED_SHORT_EM: f64 = 1.5;
6106
6107/// How far a line's gap may stand above the run's own median and still join.
6108const DOUBLE_SPACED_GAP_RATIO: f64 = 1.5;
6109
6110/// The type size most of the block's glyphs are set in, as the feature dump
6111/// measures it: one vote per glyph box, one per span without boxes.
6112fn block_size(block: &Block) -> f64 {
6113    let mut sizes: Vec<f64> = block
6114        .spans
6115        .iter()
6116        .flat_map(|span| {
6117            let votes = span.char_bounds.len().max(1);
6118            std::iter::repeat_n(span.font_size, votes)
6119        })
6120        .collect();
6121    if sizes.is_empty() {
6122        return 0.0;
6123    }
6124    sizes.sort_by(|a, b| a.total_cmp(b));
6125    sizes[sizes.len() / 2]
6126}
6127
6128/// How many lines the block holds, as the feature dump counts them: the
6129/// distinct baselines, rounded to half a point.
6130fn block_line_count(block: &Block) -> usize {
6131    let mut baselines: Vec<f64> = block
6132        .spans
6133        .iter()
6134        .map(|span| (span.y * 2.0).round() / 2.0)
6135        .collect();
6136    baselines.sort_by(|a, b| a.total_cmp(b));
6137    baselines.dedup();
6138    baselines.len()
6139}
6140
6141/// The middle of the run's own gaps, averaged where the run has an even
6142/// number of them, as `statistics.median` answers.
6143fn median_gap(mut gaps: Vec<f64>) -> f64 {
6144    gaps.sort_by(|a, b| a.total_cmp(b));
6145    match gaps.len() {
6146        0 => 0.0,
6147        len if len % 2 == 1 => gaps[len / 2],
6148        len => (gaps[len / 2 - 1] + gaps[len / 2]) / 2.0,
6149    }
6150}
6151
6152/// Whether a line opens with a list marker: `[0012]`, `1.` / `1)` / `(1)`,
6153/// `a)` / `(a)`, a Roman `IV. `, a bullet, or a section marker (`§`, `Art.`).
6154fn starts_with_marker(text: &str) -> bool {
6155    starts_with_bracket_number(text)
6156        || starts_with_numbered_marker(text)
6157        || starts_with_letter_marker(text)
6158        || starts_with_roman_marker(text)
6159        || starts_with_bullet(text)
6160        || starts_with_section_marker(text)
6161}
6162
6163/// `§ 2 ...`, `Art. 3 ...`: a section line never joins the one above it
6164/// (#636).
6165fn starts_with_section_marker(text: &str) -> bool {
6166    let trimmed = text.trim_start();
6167    trimmed.starts_with('§') || trimmed.starts_with("Art.")
6168}
6169
6170/// `[0012]`: an opening bracket, three to five digits, a closing bracket.
6171fn starts_with_bracket_number(text: &str) -> bool {
6172    let Some(rest) = text.trim_start().strip_prefix('[') else {
6173        return false;
6174    };
6175    let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
6176    (3..=5).contains(&digits) && rest.chars().nth(digits) == Some(']')
6177}
6178
6179/// `1.` / `1)` / `(1)`: an optional opening bracket, one to three digits, a
6180/// stop or a closing bracket.
6181fn starts_with_numbered_marker(text: &str) -> bool {
6182    let trimmed = text.trim_start();
6183    let rest = trimmed.strip_prefix('(').unwrap_or(trimmed);
6184    let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
6185    (1..=3).contains(&digits) && matches!(rest.chars().nth(digits), Some('.') | Some(')'))
6186}
6187
6188/// `a)` / `(a)`: an optional opening bracket, a lowercase letter, a stop or a
6189/// closing bracket, and the space after it.
6190fn starts_with_letter_marker(text: &str) -> bool {
6191    let trimmed = text.trim_start();
6192    let rest = trimmed.strip_prefix('(').unwrap_or(trimmed);
6193    let mut chars = rest.chars();
6194    matches!(
6195        (chars.next(), chars.next(), chars.next()),
6196        (Some(letter), Some(stop), Some(space))
6197            if letter.is_ascii_lowercase()
6198                && matches!(stop, '.' | ')')
6199                && space.is_whitespace()
6200    )
6201}
6202
6203/// `IV. `: one to four Roman capitals, a stop, and the space after it.
6204fn starts_with_roman_marker(text: &str) -> bool {
6205    let trimmed = text.trim_start();
6206    let numerals = trimmed
6207        .chars()
6208        .take_while(|c| matches!(c, 'I' | 'V' | 'X'))
6209        .count();
6210    if !(1..=4).contains(&numerals) {
6211        return false;
6212    }
6213    let mut chars = trimmed.chars().skip(numerals);
6214    matches!((chars.next(), chars.next()), (Some('.'), Some(space)) if space.is_whitespace())
6215}
6216
6217/// `• item`: a bullet and the space after it.
6218fn starts_with_bullet(text: &str) -> bool {
6219    let mut chars = text.trim_start().chars();
6220    matches!(
6221        (chars.next(), chars.next()),
6222        (Some(bullet), Some(space))
6223            if matches!(bullet, '•' | '●' | '▪' | '■' | '–' | '—' | '-' | '*')
6224                && space.is_whitespace()
6225    )
6226}
6227
6228/// Whether the pair must stay two blocks: every guard is drawn from a false
6229/// merge the simulation found (#574).
6230fn double_spaced_guard(upper: &str, lower: &str) -> bool {
6231    starts_with_number_then_capital(lower)
6232        || ends_in_digit(upper)
6233        || holds_key_value(upper)
6234        || lower.ends_with(':')
6235        || reads_as_heading(lower)
6236        || ends_sentence_then_capital(upper, lower)
6237}
6238
6239/// `26 Consolidated Statements`: a contents line, a number and a capital word.
6240fn starts_with_number_then_capital(text: &str) -> bool {
6241    let digits = text.chars().take_while(|c| c.is_ascii_digit()).count();
6242    if !(1..=4).contains(&digits) {
6243        return false;
6244    }
6245    let mut chars = text.chars().skip(digits);
6246    let mut spaces = 0;
6247    for c in chars.by_ref() {
6248        if c.is_whitespace() {
6249            spaces += 1;
6250        } else {
6251            return spaces > 0 && c.is_ascii_uppercase();
6252        }
6253    }
6254    false
6255}
6256
6257/// `Ausfertigungsdatum: 20.12.1963`: a contents line ends in a digit, whatever
6258/// punctuation follows it.
6259fn ends_in_digit(text: &str) -> bool {
6260    text.trim_end()
6261        .trim_end_matches(|c: char| !(c.is_alphanumeric() || c == '_'))
6262        .chars()
6263        .next_back()
6264        .is_some_and(|c| c.is_ascii_digit())
6265}
6266
6267/// `Key: value`: a line holding a colon followed by text is a label and its
6268/// value, not the start of a sentence.
6269fn holds_key_value(text: &str) -> bool {
6270    text.match_indices(':')
6271        .any(|(at, _)| !text[at + 1..].trim_start().is_empty())
6272}
6273
6274/// `Leases`: three words or fewer with no closing punctuation is a heading.
6275fn reads_as_heading(text: &str) -> bool {
6276    text.split_whitespace().count() <= 3 && !text.ends_with(['.', '!', '?', ';', ','])
6277}
6278
6279/// A full stop and a capital on the same pitch: two paragraphs, not one.
6280fn ends_sentence_then_capital(upper: &str, lower: &str) -> bool {
6281    upper.ends_with(['.', '!', '?']) && lower.chars().next().is_some_and(|c| c.is_uppercase())
6282}
6283
6284/// Characters a marker-led line opens with as a bullet (#574).
6285///
6286/// The dozen the patent front pages and the annual reports use. Both
6287/// triangular bullets are here because the two codepoints render as one
6288/// glyph and either may be what the page drew.
6289const MARKER_BULLETS: [char; 13] = [
6290    '\u{2022}', // bullet
6291    '\u{25CF}', // black circle
6292    '\u{25AA}', // black small square
6293    '\u{25E6}', // white bullet
6294    '\u{2023}', // triangular bullet
6295    '\u{2043}', // hyphen bullet
6296    '\u{2219}', // bullet operator
6297    '\u{00B7}', // middle dot
6298    '\u{2013}', // en dash
6299    '\u{2014}', // em dash
6300    '-',        // hyphen-minus
6301    '*',        // asterisk
6302    '>',        // greater-than
6303];
6304
6305/// Whether a letter is a roman digit, either case (#574).
6306fn is_roman_digit(c: char) -> bool {
6307    matches!(
6308        c.to_ascii_lowercase(),
6309        'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm'
6310    )
6311}
6312
6313/// The marker a line opens with, if it opens with one (#574).
6314///
6315/// A bullet, `(n)` or `[n]` of one to three digits, `n.` or `n)`, `a.`,
6316/// `a)` or `(a)`, or a roman numeral `(i)` or `iv.`. Ordered markers want
6317/// white space and text after the delimiter, so `3.14` and a name's `A.`
6318/// stay prose; a bullet wants text after it. Answers the marker's length
6319/// in bytes without the trailing space, and its inked characters, which is
6320/// what maps the marker onto the spans that drew it.
6321fn marker_prefix(text: &str) -> Option<(usize, usize)> {
6322    let trimmed = text.trim_start();
6323    if trimmed.is_empty() {
6324        return None;
6325    }
6326    let first = trimmed.chars().next()?;
6327    if MARKER_BULLETS.contains(&first) {
6328        let rest = &trimmed[first.len_utf8()..];
6329        if rest.trim().is_empty() {
6330            return None;
6331        }
6332        return Some((first.len_utf8(), 1));
6333    }
6334    if first == '(' || first == '[' {
6335        let closing = if first == '(' { ')' } else { ']' };
6336        let body = &trimmed[first.len_utf8()..];
6337        let digits = body.chars().take_while(|c| c.is_ascii_digit()).count();
6338        if (1..=3).contains(&digits) {
6339            let mut rest = &body[digits..];
6340            if rest.starts_with(closing) {
6341                rest = &rest[closing.len_utf8()..];
6342                if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
6343                    return Some((first.len_utf8() + digits + closing.len_utf8(), 2 + digits));
6344                }
6345            }
6346        }
6347        if first == '(' {
6348            let letters = body.chars().take_while(|c| c.is_ascii_alphabetic()).count();
6349            if (1..=6).contains(&letters) {
6350                let word = &body[..letters];
6351                if letters == 1 || word.chars().all(is_roman_digit) {
6352                    let mut rest = &body[letters..];
6353                    if rest.starts_with(')') {
6354                        rest = &rest[1..];
6355                        if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
6356                            return Some((1 + letters + 1, 2 + letters));
6357                        }
6358                    }
6359                }
6360            }
6361        }
6362        return None;
6363    }
6364    if first.is_ascii_digit() {
6365        let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
6366        if (1..=3).contains(&digits) {
6367            let rest = &trimmed[digits..];
6368            if matches!(rest.chars().next(), Some('.') | Some(')')) {
6369                let after = &rest[1..];
6370                if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
6371                    return Some((digits + 1, digits + 1));
6372                }
6373            }
6374        }
6375        return None;
6376    }
6377    if first.is_ascii_alphabetic() {
6378        let letters = trimmed
6379            .chars()
6380            .take_while(|c| c.is_ascii_alphabetic())
6381            .count();
6382        if letters == 1 {
6383            let rest = &trimmed[1..];
6384            if matches!(rest.chars().next(), Some('.') | Some(')')) {
6385                let after = &rest[1..];
6386                if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
6387                    return Some((2, 2));
6388                }
6389            }
6390            return None;
6391        }
6392        if (2..=6).contains(&letters) {
6393            let word = &trimmed[..letters];
6394            if word.chars().all(is_roman_digit) {
6395                let rest = &trimmed[letters..];
6396                if matches!(rest.chars().next(), Some('.') | Some(')')) {
6397                    let after = &rest[1..];
6398                    if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
6399                        return Some((letters + 1, letters + 1));
6400                    }
6401                }
6402            }
6403        }
6404        return None;
6405    }
6406    None
6407}
6408
6409/// The vertical extent of a span, from its glyph boxes where they exist and
6410/// from the type size where they do not.
6411fn span_bottom_top(span: &TextSpan) -> (f64, f64) {
6412    let mut bottom = f64::INFINITY;
6413    let mut top = f64::NEG_INFINITY;
6414    for bounds in &span.char_bounds {
6415        bottom = bottom.min(bounds[1]);
6416        top = top.max(bounds[3]);
6417    }
6418    if bottom.is_finite() && top.is_finite() && top > bottom {
6419        return (bottom, top);
6420    }
6421    (span.y - span.font_size * 0.2, span.y + span.font_size * 0.8)
6422}
6423
6424/// Characters that are not white space: what a marker is counted in, because
6425/// the line's text joins its spans with spaces the spans never drew.
6426fn inked_chars(text: &str) -> usize {
6427    text.chars().filter(|c| !c.is_whitespace()).count()
6428}
6429
6430/// Where the text after a marker line's marker begins: the left edge of the
6431/// first span the marker does not reach into (#574).
6432///
6433/// `None` where the marker is glued inside its line's first span and has no
6434/// column of its own to measure. A run of white space after the marker is
6435/// the spacing the page set it apart with, not where its text begins.
6436fn text_left_after_marker(block: &Block, line: &[usize], marker_inked: usize) -> Option<f64> {
6437    if marker_inked == 0 {
6438        return None;
6439    }
6440    let mut seen = 0usize;
6441    for i in line {
6442        let span = &block.spans[*i];
6443        let count = inked_chars(&span.text);
6444        if seen == marker_inked {
6445            if count == 0 {
6446                continue;
6447            }
6448            return Some(span.x);
6449        }
6450        if seen > marker_inked {
6451            return None;
6452        }
6453        seen += count;
6454    }
6455    None
6456}
6457
6458/// Whether the line c continues the marker block a: all six clauses of the
6459/// marker-hang rule (#574).
6460///
6461/// a's FIRST line opens with a marker and its LAST line does not end in a
6462/// colon, c hangs under the first line's text at normal leading without
6463/// opening a marker of its own, and the last line ran to the measure rather
6464/// than turning back. The hang is read off the first line's own text column
6465/// where the page drew the marker in a span of its own, and off its left
6466/// edge where it did not.
6467///
6468/// Two lines of a rather than one, because the marker item may wrap over
6469/// several lines before the weight cut parts it from its continuation: the
6470/// marker is on the first line and the wrap on the last, and reading both
6471/// off the last line leaves a wrapped item uncovered.
6472fn marker_line_hangs_onto(
6473    a_block: &Block,
6474    a_first: &[usize],
6475    a_last: &[usize],
6476    c_block: &Block,
6477    c_line: &[usize],
6478) -> bool {
6479    let a_text = line_text(a_block, a_first);
6480    let c_text = line_text(c_block, c_line);
6481    let Some((_, a_inked)) = marker_prefix(&a_text) else {
6482        return false;
6483    };
6484    if marker_prefix(&c_text).is_some() {
6485        return false;
6486    }
6487    if line_text(a_block, a_last).trim_end().ends_with(':') {
6488        return false;
6489    }
6490    let anchor_of = |block: &Block, line: &[usize]| -> Option<usize> {
6491        line.iter()
6492            .copied()
6493            .max_by_key(|i| inked_chars(&block.spans[*i].text))
6494    };
6495    let (Some(a_at), Some(c_at)) = (anchor_of(a_block, a_last), anchor_of(c_block, c_line)) else {
6496        return false;
6497    };
6498    let a_anchor = &a_block.spans[a_at];
6499    let c_anchor = &c_block.spans[c_at];
6500    let em = a_anchor.font_size;
6501    if !em.is_finite() || em <= 0.0 {
6502        return false;
6503    }
6504    if a_anchor.y <= c_anchor.y {
6505        return false;
6506    }
6507    let left_of = |block: &Block, line: &[usize]| {
6508        line.iter()
6509            .map(|i| block.spans[*i].x)
6510            .fold(f64::INFINITY, f64::min)
6511    };
6512    let right_of = |block: &Block, line: &[usize]| {
6513        line.iter()
6514            .map(|i| crate::blocks::span_right(&block.spans[*i]))
6515            .fold(f64::NEG_INFINITY, f64::max)
6516    };
6517    let a_left = left_of(a_block, a_first);
6518    let a_right = right_of(a_block, a_last);
6519    let c_left = left_of(c_block, c_line);
6520    let c_right = right_of(c_block, c_line);
6521    if !(a_left.is_finite() && a_right.is_finite() && c_left.is_finite() && c_right.is_finite()) {
6522        return false;
6523    }
6524    if let Some(text_left) = text_left_after_marker(a_block, a_first, a_inked) {
6525        if (c_left - text_left).abs() > MARKER_TEXT_LEFT_TOL_EM * em {
6526            return false;
6527        }
6528    } else if !(c_left > a_left + MARKER_HANG_MIN_EM * em
6529        && c_left < a_left + MARKER_HANG_MAX_EM * em)
6530    {
6531        return false;
6532    }
6533    if a_right < a_right.max(c_right) - MARKER_WRAP_SLACK_EM * em {
6534        return false;
6535    }
6536    let a_bottom = a_last
6537        .iter()
6538        .map(|i| span_bottom_top(&a_block.spans[*i]).0)
6539        .fold(f64::INFINITY, f64::min);
6540    let c_top = c_line
6541        .iter()
6542        .map(|i| span_bottom_top(&c_block.spans[*i]).1)
6543        .fold(f64::NEG_INFINITY, f64::max);
6544    if !(a_bottom.is_finite() && c_top.is_finite()) {
6545        return false;
6546    }
6547    if ((a_bottom - c_top) / em) >= MARKER_GAP_MAX_EM {
6548        return false;
6549    }
6550    true
6551}
6552
6553/// The spans of a block grouped into lines, top first, each line left to
6554/// right. The same grouping the feature dump reads words by: sorted by
6555/// baseline, then by x, a new line where the baseline steps by half an em.
6556///
6557/// `pub(crate)` for the markdown serializer, which reads an all-monospace
6558/// paragraph's lines back out of it as a fenced block (#636).
6559pub(crate) fn lines_of_block(block: &Block) -> Vec<Vec<usize>> {
6560    let mut order: Vec<usize> = (0..block.spans.len()).collect();
6561    order.sort_by(|a, b| {
6562        block.spans[*b]
6563            .y
6564            .partial_cmp(&block.spans[*a].y)
6565            .unwrap_or(Ordering::Equal)
6566            .then_with(|| {
6567                block.spans[*a]
6568                    .x
6569                    .partial_cmp(&block.spans[*b].x)
6570                    .unwrap_or(Ordering::Equal)
6571            })
6572    });
6573    let mut lines: Vec<Vec<usize>> = Vec::new();
6574    let mut tops: Vec<f64> = Vec::new();
6575    for idx in order {
6576        let size = block.spans[idx].font_size;
6577        let tolerance = if size > 0.0 { 0.5 * size } else { 1e-6 };
6578        if let (Some(last), Some(top)) = (lines.last_mut(), tops.last().copied()) {
6579            if (top - block.spans[idx].y).abs() < tolerance {
6580                last.push(idx);
6581                continue;
6582            }
6583        }
6584        lines.push(vec![idx]);
6585        tops.push(block.spans[idx].y);
6586    }
6587    for line in &mut lines {
6588        line.sort_by(|a, b| {
6589            block.spans[*a]
6590                .x
6591                .partial_cmp(&block.spans[*b].x)
6592                .unwrap_or(Ordering::Equal)
6593        });
6594    }
6595    lines
6596}
6597
6598/// One line as it reads: its spans left to right through the same assembly
6599/// the merge built the block's text from.
6600///
6601/// `pub(crate)` beside [`lines_of_block`], for the same fenced block (#636).
6602pub(crate) fn line_text(block: &Block, line: &[usize]) -> String {
6603    let spans: Vec<TextSpan> = line.iter().map(|i| block.spans[*i].clone()).collect();
6604    TextBlock { spans }.text()
6605}
6606
6607/// The quarter turn that puts a page which is itself turned back in the frame
6608/// it was written in (#546).
6609///
6610/// A page carries a `/Rotate`, of its own or inherited from the `Pages` tree,
6611/// and `Page::initial_transform` turns every glyph on it with the page: the
6612/// text device sees a page of lines running up, down or backwards. Bands are
6613/// horizontal and they read one direction, so on such a page they lie ACROSS
6614/// its lines: each glyph of a line lands in a band of its own and the page
6615/// reads as letter salad -- `ro t a ti o n  9 0` where pdfbox `PDFBOX-6049`
6616/// says `rotation 90`, and `081 noitator` on its half-turned page. This is the
6617/// same failure #435 fixed for a run drawn against the direction of its page,
6618/// and it stayed on the pages where the turned direction is the MAJORITY: what
6619/// #435 assembles there is the upright text on a turned page.
6620///
6621/// So the layout runs in the frame the page was written in. Every span is
6622/// turned into the direction most of the page's glyphs are drawn in before
6623/// `PageStats`, the cut and the bands, and the blocks are turned back before
6624/// they are returned, which leaves the spans the caller gets in page space to
6625/// the bit.
6626///
6627/// QUARTER TURNS ONLY, and the reason is exactness rather than taste: the sine
6628/// and cosine of a quarter turn are 0 and ±1, so the turn and its inverse move
6629/// no coordinate by a single bit, and a page this pass declines to turn comes
6630/// out of it byte for byte as it went in. A page `/Rotate` is a multiple of 90
6631/// by §7.7.3.3, which is the whole of what this is about; a page drawn entirely
6632/// at a free angle keeps its own bands, exactly as it did before (they read it:
6633/// `a_page_written_entirely_at_an_angle_keeps_its_own_bands`).
6634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6635enum PageTurn {
6636    /// The page reads left to right already, or in no direction this undoes.
6637    None,
6638    /// Its lines run up the page: `/Rotate 90`.
6639    Quarter,
6640    /// Its lines run right to left: `/Rotate 180`.
6641    Half,
6642    /// Its lines run down the page: `/Rotate 270`.
6643    ThreeQuarter,
6644}
6645
6646impl PageTurn {
6647    /// The turn that makes the direction the page is WRITTEN in upright.
6648    ///
6649    /// Written in, and not merely the heaviest of: this turns the layout of a
6650    /// whole page, so the direction has to carry more than half its glyphs.
6651    /// The plurality rule #435 picks its cross-direction runs by is the right
6652    /// one there -- whatever is not the page's direction is a run on it -- and
6653    /// the wrong one here, because a page with no majority direction has no
6654    /// frame to be read in and turning it by a 26% plurality moves every block
6655    /// on it. pdfbox `AcroFormsRotation` is that page: four blocks of form
6656    /// fields at the four rotations, a quarter of the glyphs each, and the
6657    /// plurality rule turned its UPRIGHT page and put its fields in an order
6658    /// neither mutool nor poppler reads them in (order agreement 100 -> 50 on
6659    /// the gate set, where nothing else on 500 documents moved at all).
6660    /// A REFLECTION IS NOT A TURN, and only the determinant says which it is
6661    /// (codex, review round 2). `heaviest_direction` reads the baseline vector,
6662    /// and a horizontal reflection such as `[-1, 0, 0, 1, e, f]` runs its
6663    /// baseline in `(-1, 0)` exactly as a half turn does. Classifying that page
6664    /// as `Half` makes [`Self::into_frame`] negate BOTH coordinates, so a
6665    /// mirrored block whose lines descend the page comes back bottom to top --
6666    /// a reading order this pass invented out of a page nobody turned.
6667    ///
6668    /// Reflected glyphs are therefore counted in the page's total and in no
6669    /// direction's share. A page mostly drawn mirrored cannot reach a majority
6670    /// and comes out of this pass byte for byte as it went in, which is what
6671    /// `Self::None` means here.
6672    fn of(spans: &[TextSpan]) -> Self {
6673        let page_total: usize = spans.iter().map(glyph_weight).sum();
6674        if page_total == 0 {
6675            return Self::None;
6676        }
6677        let turned: Vec<&TextSpan> = spans
6678            .iter()
6679            .filter(|span| is_a_proper_rotation(span))
6680            .collect();
6681        let turned_total: usize = turned.iter().copied().map(glyph_weight).sum();
6682        let Some((page, share_of_turned)) = heaviest_direction(turned.into_iter()) else {
6683            return Self::None;
6684        };
6685        // The share is of the WHOLE page, mirrored glyphs included: measured
6686        // against the rotations alone, one upright line on a mirrored page
6687        // would carry 100 % of the vote.
6688        let share = share_of_turned * turned_total as f64 / page_total as f64;
6689        if share <= 0.5 {
6690            return Self::None;
6691        }
6692        [Self::Quarter, Self::Half, Self::ThreeQuarter]
6693            .into_iter()
6694            .find(|turn| directions_agree(page, turn.direction()))
6695            .unwrap_or(Self::None)
6696    }
6697
6698    /// The direction a page this turn undoes is written in.
6699    fn direction(self) -> Direction {
6700        match self {
6701            Self::None => (1.0, 0.0),
6702            Self::Quarter => (0.0, 1.0),
6703            Self::Half => (-1.0, 0.0),
6704            Self::ThreeQuarter => (0.0, -1.0),
6705        }
6706    }
6707
6708    /// The `(cos, sin)` of the rotation into the frame: the one that takes
6709    /// [`Self::direction`] to `(1, 0)`.
6710    fn rotation(self) -> (f64, f64) {
6711        match self {
6712            Self::None => (1.0, 0.0),
6713            Self::Quarter => (0.0, -1.0),
6714            Self::Half => (-1.0, 0.0),
6715            Self::ThreeQuarter => (0.0, 1.0),
6716        }
6717    }
6718
6719    /// Every span in the frame the page was written in.
6720    fn into_frame(self, mut spans: Vec<TextSpan>) -> Vec<TextSpan> {
6721        if self == Self::None {
6722            return spans;
6723        }
6724        let rotation = self.rotation();
6725        for span in &mut spans {
6726            // In its own frame a run is as wide as it advances along its
6727            // baseline, and that is not what `draw_glyph` accumulated: `width`
6728            // is the furthest a glyph reached in PAGE x, which for a run
6729            // drawn up the page is one advance however long the run is. Every
6730            // reader of `width` above this point -- the column gutters, the
6731            // band gaps, the cell edges -- would take a line of a turned page
6732            // for a single word.
6733            let along = baseline_extent(span);
6734            turn_span(span, rotation);
6735            span.width = along;
6736        }
6737        spans
6738    }
6739
6740    /// The ruling lines of #545 in the frame the page is read in.
6741    ///
6742    /// The device records horizontal rules and no others, and only the half
6743    /// turn maps a horizontal line to a horizontal line -- mirrored, which is
6744    /// why the two ends swap. A quarter turn maps one to a vertical line:
6745    /// what the device caught on such a page are its column separators, and
6746    /// its row rules were never horizontal to begin with, so they were never
6747    /// recorded at all. Those pages are cut without rules, exactly as every
6748    /// page was before #545, rather than with rules that mean the other thing
6749    /// (#546, review round 2).
6750    fn rules_in_frame(self, rules: &[HorizontalRule]) -> Cow<'_, [HorizontalRule]> {
6751        let rotation = self.rotation();
6752        match self {
6753            Self::None => Cow::Borrowed(rules),
6754            Self::Half => Cow::Owned(
6755                rules
6756                    .iter()
6757                    .map(|rule| {
6758                        let (left, y) = turn_point(rule.left, rule.y, rotation);
6759                        let (right, _) = turn_point(rule.right, rule.y, rotation);
6760                        HorizontalRule {
6761                            left: left.min(right),
6762                            right: left.max(right),
6763                            y,
6764                        }
6765                    })
6766                    .collect(),
6767            ),
6768            Self::Quarter | Self::ThreeQuarter => Cow::Owned(Vec::new()),
6769        }
6770    }
6771
6772    /// The blocks back in page space, with every span's geometry the caller's
6773    /// own again.
6774    fn back(self, mut blocks: Vec<TextBlock>) -> Vec<TextBlock> {
6775        if self == Self::None {
6776            return blocks;
6777        }
6778        let (cos, sin) = self.rotation();
6779        for span in blocks.iter_mut().flat_map(|block| block.spans.iter_mut()) {
6780            turn_span(span, (cos, -sin));
6781            span.width = page_space_width(span);
6782        }
6783        blocks
6784    }
6785
6786    /// The paragraphs back in page space (#567).
6787    ///
6788    /// The same restoration [`Self::back`] performs on lines, and then the
6789    /// block's own box again: a box computed in the frame the page was written
6790    /// in describes a rectangle nobody on the caller's side can find.
6791    fn back_blocks(self, mut blocks: Vec<Block>) -> Vec<Block> {
6792        if self == Self::None {
6793            return blocks;
6794        }
6795        let (cos, sin) = self.rotation();
6796        for block in blocks.iter_mut() {
6797            for span in block.spans.iter_mut() {
6798                turn_span(span, (cos, -sin));
6799                span.width = page_space_width(span);
6800            }
6801            crate::blocks::recompute_bbox(block);
6802        }
6803        blocks
6804    }
6805}
6806
6807/// How far a span reaches along its own baseline, from its origin.
6808fn baseline_extent(span: &TextSpan) -> f64 {
6809    let direction = span_direction(span);
6810    baseline_end(span, direction) - baseline_coordinates(span, direction).0
6811}
6812
6813/// The width `draw_glyph` measures: the furthest any of the span's glyphs
6814/// reaches in page x, from the span's origin.
6815///
6816/// This is what [`PageTurn::back`] restores, so that a caller of
6817/// `extract_text_blocks` on a turned page reads the same `width` off a span as
6818/// one on an upright page does. It is derived from the bounds rather than
6819/// remembered because the bounds are the same measurements `draw_glyph`
6820/// accumulated it from; a span with no bounds -- every hand-built one -- keeps
6821/// the width it came with.
6822fn page_space_width(span: &TextSpan) -> f64 {
6823    span.char_bounds
6824        .iter()
6825        .map(|bound| bound[2] - span.x)
6826        .reduce(f64::max)
6827        .unwrap_or(span.width)
6828}
6829
6830/// Turn one span's geometry by `(cos, sin)`.
6831///
6832/// What turns is what the layout reads as a position: the span's origin, the
6833/// baseline its transform names, and the origin of each glyph bound.
6834///
6835/// What does not turn is what is measured along that baseline and means the
6836/// same in either frame. A `char_bound` is one of those: `draw_glyph` writes
6837/// it as `[x, y, x + advance, y + font size]` around a PAGE-space origin, so
6838/// it is an advance and a font size hung off a point rather than the rectangle
6839/// a turned glyph covers -- which is what `baseline_end` reads back out of it.
6840/// Turning the two numbers as if the entry were a rectangle would turn a
6841/// measurement that was never in page space to begin with.
6842///
6843/// `tight_char_bounds` is deliberately left alone. That one IS a page-space
6844/// rectangle, and it is ink rather than layout: nothing between here and the
6845/// blocks reads it, so leaving it where it is keeps it exact for the caller who
6846/// does (`GeometryMode::RichGeometry`).
6847fn turn_span(span: &mut TextSpan, rotation: (f64, f64)) {
6848    let (x, y) = turn_point(span.x, span.y, rotation);
6849    span.x = x;
6850    span.y = y;
6851    if let Some(coeffs) = span.transform.as_mut() {
6852        let (a, b) = turn_point(coeffs[0], coeffs[1], rotation);
6853        let (c, d) = turn_point(coeffs[2], coeffs[3], rotation);
6854        let (e, f) = turn_point(coeffs[4], coeffs[5], rotation);
6855        *coeffs = [a, b, c, d, e, f];
6856    }
6857    for bound in &mut span.char_bounds {
6858        let (advance, size) = (bound[2] - bound[0], bound[3] - bound[1]);
6859        let (x0, y0) = turn_point(bound[0], bound[1], rotation);
6860        *bound = [x0, y0, x0 + advance, y0 + size];
6861    }
6862}
6863
6864/// A point turned by `(cos, sin)`.
6865fn turn_point(x: f64, y: f64, (cos, sin): (f64, f64)) -> (f64, f64) {
6866    (x * cos - y * sin, x * sin + y * cos)
6867}
6868
6869/// Take the runs drawn in another direction than the page's out of the blocks
6870/// the page's own layout put them in, and give each one back as the line it is
6871/// (#435).
6872///
6873/// Bands are horizontal, and they read one direction. The letters of a diagonal
6874/// watermark, of a rotated table header, of the marginalia down the side of a
6875/// page each land in a band of their own, interleaved with whatever upright text
6876/// shares that band, and the reader gets `rk Xa Orm test value aBte D df wN t pA
6877/// sS te` where pdfjs issue17069 says `test pdf watermark` and `SANDBOX`.
6878///
6879/// WHERE THE PAGE'S OWN DIRECTION COMES FROM. The direction most of the page's
6880/// glyphs are drawn in keeps the page's bands, and on a page that is ITSELF
6881/// turned -- a `/Rotate 90` page, whose every glyph reaches the device turned
6882/// with it -- those bands would lie across its lines and read it as badly as a
6883/// watermark read before this pass (pdfbox `PDFBOX-6049`, `AcroFormsRotation`).
6884/// Such a page is turned back into the frame it was written in before the cut
6885/// and the bands ever see it ([`PageTurn`], #546), so by the time this pass
6886/// runs the page's own direction is upright again and what is left for it is
6887/// what it is for: the run drawn against the direction of the page it is on.
6888///
6889/// This runs AFTER the page's cut and takes nothing away from it. Where the
6890/// page's columns and zones are is decided by every glyph on it, rotated ones
6891/// included -- a run of marginalia down the left edge is part of why a page
6892/// splits where it does, and a version of this that cut the page without them
6893/// moved the reading order of `issue6127` by sixteen points. So the layout is
6894/// the layout, and what changes here is only which line each rotated glyph is
6895/// read as part of.
6896fn reassemble_cross_direction_runs(blocks: Vec<TextBlock>) -> Vec<TextBlock> {
6897    let Some(page) = page_direction(&blocks) else {
6898        return blocks;
6899    };
6900    reassemble_with_direction(page, blocks)
6901}
6902
6903/// The same pass, told which direction the page reads in.
6904///
6905/// Split out for [`paragraph_blocks`], which runs it once per region of the cut
6906/// rather than once over the page: a run drawn across the type is read as part
6907/// of the region it was drawn in, and the page's own direction is still the
6908/// page's -- decided over every block on it, which is why it is an argument
6909/// here and not something each region votes on for itself.
6910fn reassemble_with_direction(page: Direction, blocks: Vec<TextBlock>) -> Vec<TextBlock> {
6911    let mut kept: Vec<TextBlock> = Vec::with_capacity(blocks.len());
6912    // Each cross-direction span, with the position in the page's order that the
6913    // block it was taken from held.
6914    let mut cross: Vec<PlacedSpan> = Vec::new();
6915    for block in blocks {
6916        let at = kept.len();
6917        let mut page_spans = Vec::with_capacity(block.spans.len());
6918        for span in block.spans {
6919            if directions_agree(page, span_direction(&span)) {
6920                page_spans.push(span);
6921            } else {
6922                cross.push((at, span));
6923            }
6924        }
6925        if !page_spans.is_empty() {
6926            kept.push(TextBlock { spans: page_spans });
6927        }
6928    }
6929
6930    if cross.is_empty() {
6931        return kept;
6932    }
6933
6934    // `lines_along_their_own_baselines` returns the runs in the order they are
6935    // to be read. They are inserted back to front -- so that inserting one
6936    // never moves the position another was measured at -- and two runs that
6937    // share a position are inserted in the reverse of their order, because each
6938    // insertion at the same index pushes the last one down (#435, review round
6939    // 2).
6940    let runs = lines_along_their_own_baselines(cross);
6941    for (at, run) in runs.into_iter().rev() {
6942        let at = at.min(kept.len());
6943        kept.insert(at, run);
6944    }
6945    kept
6946}
6947
6948/// The direction most of a page's glyphs are drawn in, or `None` for a page
6949/// with no text at all.
6950fn page_direction(blocks: &[TextBlock]) -> Option<Direction> {
6951    dominant_direction(blocks.iter().flat_map(|block| block.spans.iter()))
6952}
6953
6954/// A span's provenance from its glyphs': an estimate if any glyph was
6955/// estimated, tight if any was measured tight and none estimated, the advance
6956/// otherwise. One definition, for the span as `compute_tight_bounds` makes it
6957/// and for a span that loses glyphs afterwards (codex, #1974).
6958pub(crate) fn aggregate_bounds_source(sources: &[BoundsSource]) -> BoundsSource {
6959    if sources.contains(&BoundsSource::Estimate) {
6960        BoundsSource::Estimate
6961    } else if sources.contains(&BoundsSource::Tight) {
6962        BoundsSource::Tight
6963    } else {
6964        BoundsSource::Advance
6965    }
6966}
6967
6968/// #582's reading of tracking, asked again of a FINISHED span (#1973).
6969///
6970/// #582 believes a run is tracked once four gaps in a row agree, deciding at
6971/// each glyph from the gaps before it. A word of four letters has three --
6972/// `P E N N`, `P A G E` -- and a word space set wider than the tracking starts a
6973/// new span, so on a display line of short words the count never reaches four
6974/// and every letter keeps the space the consensus gave it. Once the span is
6975/// finished all its gaps are known, and the same question has an answer: the
6976/// span's tracking is its median gap, a consensus space at a gap within
6977/// [`TRACKING_TOLERANCE`]-and-a-bit of it -- or tighter, a kerned pair such as
6978/// `TA` -- is tracking and goes, and one clearly wider is a word boundary and
6979/// stays. Which is #582's choice about letters at one constant distance,
6980/// pinned there by `a_line_of_single_letter_words_reads_as_tracking_and_that_is_the_choice`.
6981///
6982/// Asked only of a span of capitals that shows the symptom (four single
6983/// letters between spaces), and only where its glyphs are its letters one for
6984/// one: every space in it is then the consensus's own and none is a glyph the
6985/// page drew. A span with a drawn space, a ligature or lower case is left to
6986/// the rules after this.
6987fn retrack_a_finished_span(span: &mut TextSpan, chars: &[char]) -> bool {
6988    let letters: Vec<char> = chars.iter().copied().filter(|c| *c != ' ').collect();
6989    if letters.len() != span.char_bounds.len() || letters.len() < 4 {
6990        return false;
6991    }
6992    if letters.iter().any(|c| c.is_lowercase()) || !letters.iter().any(|c| c.is_uppercase()) {
6993        return false;
6994    }
6995    let singles = span
6996        .text
6997        .split(' ')
6998        .filter(|w| w.chars().count() == 1 && w.chars().all(char::is_uppercase))
6999        .count();
7000    if singles < 4 {
7001        return false;
7002    }
7003    let gaps: Vec<f64> = span
7004        .char_bounds
7005        .windows(2)
7006        .map(|p| p[1][0] - p[0][2])
7007        .collect();
7008    let mut sorted = gaps.clone();
7009    sorted.sort_by(|a, b| a.total_cmp(b));
7010    // THE LOWER MEDIAN (codex, #1980). A short phrase can have as many word
7011    // gaps as letter gaps -- `I AM OK`: 1, 1, 3, 3 -- and the upper median then
7012    // takes the word space for the tracking and removes every space. The lower
7013    // one stays with the letters, and a kerned pair below them (`TA`) is too
7014    // rare in a word to pull it down.
7015    let tracking = sorted[(sorted.len() - 1) / 2];
7016    // Real tracking: the letters stand apart. In prose the boxes of a word's
7017    // letters touch -- they are advances -- so its median gap is nothing, and
7018    // the consensus's spaces there are word spaces, which this must not take.
7019    if tracking <= TRACKED_GAP_MIN_EM * span.font_size {
7020        return false;
7021    }
7022    let inside_a_word = tracking * (1.0 + TRACKING_TOLERANCE + 0.1);
7023    let mut spaced_before = vec![false; letters.len()];
7024    let mut k = 0;
7025    for c in chars {
7026        if *c == ' ' {
7027            if k < letters.len() {
7028                spaced_before[k] = true;
7029            }
7030        } else {
7031            k += 1;
7032        }
7033    }
7034    let mut text = String::with_capacity(chars.len());
7035    for (k, c) in letters.iter().enumerate() {
7036        if k > 0 && spaced_before[k] && gaps[k - 1] > inside_a_word {
7037            text.push(' ');
7038        }
7039        text.push(*c);
7040    }
7041    if chars.last() == Some(&' ') {
7042        text.push(' ');
7043    }
7044    let changed = text != span.text;
7045    span.text = text;
7046    changed
7047}
7048
7049/// Letter-spacing done with SPACE CHARACTERS, taken back out (#1973).
7050///
7051/// #582 reads tracking that the page draws as GAPS. A good share of display
7052/// lines draw it with space glyphs instead -- `P O W E L L  I N D U S T R I E S`,
7053/// a space after every capital and two where the words part -- and those
7054/// arrive as text, so no gap is ever there to judge: on the evidence of the
7055/// 24-09 round it is the largest class of text we write that the page does not
7056/// hold (0.74 % of our words on DocLayNet, against Docling's 0.07 %).
7057///
7058/// A run of four single capitals or more with one space between each is taken
7059/// for letter-spacing only when THE PAGE MARKS ITS WORD BOUNDARIES with a
7060/// double space (`P O W E L L  I N D U S T R I E S`). The single spaces then go
7061/// and each double one becomes one. With no such mark, `A B C D` stays as it
7062/// is: letters set apart on purpose and letters tracked by spaces are the same
7063/// bytes, and the boundary is the only thing that tells them apart. A pair of
7064/// capitals inside the run (`O P E R AT I O N S`, a kerned pair drawn as one)
7065/// does not end it.
7066fn untrack_spaced_letters(span: &mut TextSpan) {
7067    let chars: Vec<char> = span.text.chars().collect();
7068    if chars.len() < 7 || !chars.contains(&' ') {
7069        return;
7070    }
7071    // The finished span first; what it leaves is asked of the rules below.
7072    let chars: Vec<char> = if retrack_a_finished_span(span, &chars) {
7073        span.text.chars().collect()
7074    } else {
7075        chars
7076    };
7077    let aligned = span.char_bounds.len() == chars.len();
7078    let tight = span.tight_char_bounds.len() == chars.len();
7079    // Words and the separators between them, as char ranges.
7080    let mut words: Vec<(usize, usize)> = Vec::new();
7081    let mut at = 0;
7082    while at < chars.len() {
7083        if chars[at] == ' ' {
7084            at += 1;
7085            continue;
7086        }
7087        let start = at;
7088        while at < chars.len() && chars[at] != ' ' {
7089            at += 1;
7090        }
7091        words.push((start, at));
7092    }
7093    // Capitals and their punctuation only, as in `untrack_spaced_spans`.
7094    let small = |(a, b): (usize, usize)| {
7095        (b - a == 1 || b - a == 2)
7096            && chars[a..b]
7097                .iter()
7098                .all(|c| c.is_uppercase() || matches!(c, ',' | '.' | '\'' | '’' | '&' | '-'))
7099            && (b - a == 1 || chars[a..b].iter().any(|c| c.is_uppercase()))
7100    };
7101    let mut drop = vec![false; chars.len()];
7102    let mut w = 0;
7103    while w < words.len() {
7104        if !small(words[w]) {
7105            w += 1;
7106            continue;
7107        }
7108        // The run: small words, one or two spaces apart.
7109        let mut end = w + 1;
7110        while end < words.len()
7111            && small(words[end])
7112            && (1..=2).contains(&(words[end].0 - words[end - 1].1))
7113        {
7114            end += 1;
7115        }
7116        // A last "word" of one capital after a double space is not a tracked
7117        // word: `F C F C  S` is a label beside a letter, as the row rule
7118        // reads it (codex, #1974).
7119        while end > w + 1 {
7120            let last_double = (w + 1..end)
7121                .rev()
7122                .find(|&i| words[i].0 - words[i - 1].1 >= 2);
7123            match last_double {
7124                Some(at)
7125                    if words[at..end]
7126                        .iter()
7127                        .map(|&(a, b)| chars[a..b].iter().filter(|c| c.is_uppercase()).count())
7128                        .sum::<usize>()
7129                        < 2 =>
7130                {
7131                    end = at;
7132                }
7133                _ => break,
7134            }
7135        }
7136        let run = &words[w..end];
7137        let singles = run
7138            .iter()
7139            .filter(|&&(a, b)| b - a == 1 && chars[a].is_uppercase())
7140            .count();
7141        if singles >= 4 && run.len() >= 4 {
7142            let seps: Vec<(usize, usize)> = run.windows(2).map(|p| (p[0].1, p[1].0)).collect();
7143            // Only where the page marks its word boundaries with a double
7144            // space. Widths and gaps were tried and read figure labels and
7145            // OCR noise (`B C B C`, `I I I`) as tracked words.
7146            let boundary: Option<Vec<bool>> = seps
7147                .iter()
7148                .any(|(a, b)| b - a >= 2)
7149                .then(|| seps.iter().map(|(a, b)| b - a >= 2).collect());
7150            if let Some(boundary) = boundary {
7151                for (&(a, b), keep) in seps.iter().zip(boundary) {
7152                    // A boundary keeps its first space; everything else goes.
7153                    let from = if keep { a + 1 } else { a };
7154                    for d in drop.iter_mut().take(b).skip(from) {
7155                        *d = true;
7156                    }
7157                }
7158            }
7159        }
7160        w = end;
7161    }
7162    if !drop.contains(&true) {
7163        return;
7164    }
7165    span.text = chars
7166        .iter()
7167        .zip(&drop)
7168        .filter(|(_, d)| !**d)
7169        .map(|(c, _)| *c)
7170        .collect();
7171    if aligned {
7172        let mut k = 0;
7173        span.char_bounds.retain(|_| {
7174            k += 1;
7175            !drop[k - 1]
7176        });
7177    }
7178    if tight {
7179        let mut k = 0;
7180        span.tight_char_bounds.retain(|_| {
7181            k += 1;
7182            !drop[k - 1]
7183        });
7184    }
7185    // The rich-geometry vectors run beside the tight boxes, glyph for glyph.
7186    if span.glyph_advances.len() == chars.len() {
7187        let mut k = 0;
7188        span.glyph_advances.retain(|_| {
7189            k += 1;
7190            !drop[k - 1]
7191        });
7192    }
7193    if span.glyph_bounds_sources.len() == chars.len() {
7194        let mut k = 0;
7195        span.glyph_bounds_sources.retain(|_| {
7196            k += 1;
7197            !drop[k - 1]
7198        });
7199        // A space has no outline and marks its span an estimate; with the
7200        // spaces gone the span is what its letters are (codex, #1974).
7201        span.bounds_source = aggregate_bounds_source(&span.glyph_bounds_sources);
7202    }
7203}
7204
7205/// The direction most of a set of spans' glyphs are drawn in, or `None` for no
7206/// spans at all.
7207///
7208/// Weighted by glyphs and not by spans: one word of upright text is not the
7209/// page's direction because a rotated run happened to arrive as three spans.
7210/// And by GLYPHS and not by characters, which are not the same count: a
7211/// ligature decodes to two or three of them, and the spaces the space consensus
7212/// puts between two glyphs are characters that were never drawn. Either would
7213/// let a minority direction outvote the page's own on a page where the two are
7214/// close. `char_bounds` holds one entry per glyph drawn; the character count
7215/// answers only for a span that carries no bounds, which is every hand-built
7216/// one (#435, review round 2).
7217fn dominant_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<Direction> {
7218    heaviest_direction(spans).map(|(direction, _)| direction)
7219}
7220
7221/// What one span weighs in a vote about direction: the glyphs it drew, or its
7222/// characters when it carries no bounds. One definition, because two of them
7223/// would let the same page have two majorities.
7224fn glyph_weight(span: &TextSpan) -> usize {
7225    if span.char_bounds.is_empty() {
7226        span.text.chars().count().max(1)
7227    } else {
7228        span.char_bounds.len()
7229    }
7230}
7231
7232/// Whether a span was drawn by a proper rotation rather than a reflection.
7233///
7234/// A baseline vector cannot tell the two apart. `[-1, 0, 0, 1, e, f]` mirrors
7235/// the page about its vertical axis and leaves the baseline running in `(-1,
7236/// 0)` -- the same direction a half turn produces -- while the page's vertical
7237/// axis stays upright and nothing has turned. The determinant is what separates
7238/// them: it is positive for a rotation and negative for a reflection.
7239///
7240/// A span with no transform is hand-built and reads upright, which is a proper
7241/// rotation by the identity.
7242fn is_a_proper_rotation(span: &TextSpan) -> bool {
7243    match span.transform {
7244        None => true,
7245        Some([a, b, c, d, _, _]) => a * d - b * c > 0.0,
7246    }
7247}
7248
7249/// The heaviest direction and the share of the glyphs drawn in it, weighed by
7250/// the rule [`dominant_direction`] documents.
7251fn heaviest_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<(Direction, f64)> {
7252    let mut directions: Vec<(Direction, usize)> = Vec::new();
7253    for span in spans {
7254        let direction = span_direction(span);
7255        let weight = glyph_weight(span);
7256        match directions
7257            .iter_mut()
7258            .find(|(known, _)| directions_agree(*known, direction))
7259        {
7260            Some(entry) => entry.1 += weight,
7261            None => directions.push((direction, weight)),
7262        }
7263    }
7264    let total: usize = directions.iter().map(|(_, weight)| *weight).sum();
7265    // The first of the heaviest, so that a page whose two directions carry the
7266    // same number of glyphs reads in the one it was drawn in first rather than
7267    // in whichever the iterator saw last.
7268    let mut winner: Option<(Direction, usize)> = None;
7269    for (direction, weight) in directions {
7270        if winner.map(|(_, best)| weight > best).unwrap_or(true) {
7271            winner = Some((direction, weight));
7272        }
7273    }
7274    winner.map(|(direction, weight)| (direction, weight as f64 / total as f64))
7275}
7276
7277/// Assemble cross-direction spans into lines along their own baselines.
7278///
7279/// The rule the page's bands follow, in each run's own frame: spans within the
7280/// band tolerance of each other across the baseline are one line, ordered along
7281/// it. A line is placed where the page's order held the first of its spans, so
7282/// a watermark drawn across the middle of a page is read there and not after
7283/// the footer.
7284fn lines_along_their_own_baselines(spans: Vec<PlacedSpan>) -> Vec<(usize, TextBlock)> {
7285    // One group per direction: two runs at two angles never join, however close
7286    // their glyphs land.
7287    let mut groups: Vec<(Direction, Vec<PlacedSpan>)> = Vec::new();
7288    for (at, span) in spans {
7289        let direction = span_direction(&span);
7290        match groups
7291            .iter_mut()
7292            .find(|(known, _)| directions_agree(*known, direction))
7293        {
7294            Some((_, group)) => group.push((at, span)),
7295            None => groups.push((direction, vec![(at, span)])),
7296        }
7297    }
7298
7299    let mut lines: Vec<(usize, TextBlock)> = Vec::new();
7300    for (direction, group) in groups {
7301        let mut rows: Vec<(f64, usize, Vec<TextSpan>)> = Vec::new();
7302        for (at, span) in group {
7303            let across = baseline_coordinates(&span, direction).1;
7304            let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
7305            match rows
7306                .iter_mut()
7307                .find(|(row_across, _, _)| (row_across - across).abs() <= tolerance)
7308            {
7309                Some((_, first, row)) => {
7310                    *first = (*first).min(at);
7311                    row.push(span);
7312                }
7313                None => rows.push((across, at, vec![span])),
7314            }
7315        }
7316
7317        // Read in the order the page's own order first held them, and where
7318        // two lines start at the same place -- which is what a block of
7319        // rotated text does -- from the top of the run's own frame down.
7320        rows.sort_by(|a, b| {
7321            a.1.cmp(&b.1)
7322                .then_with(|| b.0.partial_cmp(&a.0).unwrap_or(Ordering::Equal))
7323        });
7324        for (_, at, mut row) in rows {
7325            row.sort_by(|a, b| {
7326                baseline_coordinates(a, direction)
7327                    .0
7328                    .partial_cmp(&baseline_coordinates(b, direction).0)
7329                    .unwrap_or(Ordering::Equal)
7330            });
7331            lines.push((at, TextBlock { spans: row }));
7332        }
7333    }
7334    // Across directions too: the page's order decides, and a stable sort keeps
7335    // each direction's own line order inside it.
7336    lines.sort_by(|a, b| a.0.cmp(&b.0));
7337    lines
7338}
7339
7340/// How far apart, in ems of the previous span's size, two quarter-turned
7341/// spans may stand along their shared baseline and still be glyphs of one
7342/// run (#574).
7343///
7344/// A word space is a fraction of an em; a tracked display line, and a word gap
7345/// the producer positioned rather than drew, run wider, to about an em. Two
7346/// ems is past every within-run gap the tuning pages show and short of the
7347/// distance between two runs that share a baseline -- the axis labels of a
7348/// chart, the row labels of a stub -- which stand several ems apart or they
7349/// would read as one line.
7350const ROTATED_RUN_MAX_GAP_EM: f64 = 2.0;
7351
7352/// How wide a gap along a turned baseline must be before the run it sits in
7353/// is merged for the cut's sake (#574).
7354///
7355/// The cut parts a run at a gap on the paragraph-break scale: two words
7356/// 1.2 em apart come back two blocks, reversed for a run that climbs the
7357/// page. A gap of an em or less never does -- the marginal runs that stand
7358/// alone in a margin are drawn one text matrix with uniform advances, a word
7359/// gap the width of a glyph -- so a run whose widest gap is an em, standing
7360/// alone outside any table, is left as drawn: #435 already reads it forwards,
7361/// and merging it buys nothing downstream can use.
7362const LONE_RUN_MAX_GAP_EM: f64 = 1.0;
7363
7364/// One minority run drawn turned on an upright page, as one span, before the
7365/// cut ever sees it (#574).
7366///
7367/// The producers of the defect's pages draw a turned run one glyph per show
7368/// operation, so the device keeps every glyph a span of its own: the page
7369/// space adjacency it merges on never holds across a baseline that stands up
7370/// the page. What #435 assembles afterwards is then unmade twice. The cut
7371/// reads a word gap of the run as a paragraph break and parts the run into
7372/// regions, which the page's top-to-bottom order reads backwards for a run
7373/// that climbs it (`rate interest average Weighted`); and the table grid reads
7374/// a stack of turned heads as rows of one-glyph cells, which the stacked split
7375/// then writes out a letter at a time (`T R O P E R L A U N N A`).
7376///
7377/// So each quarter-turned run is one text item before any grid is built:
7378/// consecutive quarter-turned spans that share an orientation and a baseline,
7379/// within a word gap of each other along it, ordered by their coordinate along
7380/// the baseline. One span cannot be cut in two, banded apart, or gridded a
7381/// glyph at a time, and what the grid and the cells then read is the run whole
7382/// and forwards.
7383///
7384/// The text is assembled by the same code that assembles a line of prose --
7385/// the word spacing, the overprint dedupe, the tracking -- so a run this joins
7386/// reads byte for byte as #435 reads it where the cut leaves it alone. Each
7387/// guard below names what it keeps unchanged:
7388///
7389/// * only quarter-turned spans are candidates ([`span_is_quarter_turned`]:
7390///   within ten degrees of 90, 180 or 270 degrees), so upright text, small
7391///   skews and free-angle regions come back untouched -- the defect class is
7392///   quarter turns only (six reversed runs, all at 90 degrees), and a
7393///   free-angle region already reads forwards (OTC_STKAF p-c3c8);
7394/// * its callers pass only upright pages: in the frame of a turned page the
7395///   minority reads turned, and merging it re-decides a landscape table;
7396/// * a style change still ends the run, the way it ends a span in the device,
7397///   so a bold word inside a quarter-turned run keeps its face;
7398/// * a gap wider than any word gap ends the run, so two runs that share a
7399///   baseline stay two;
7400/// * a gap another quarter-turned run's glyph starts in ends the run too, so
7401///   an axis label's capitals never join across the small digits set between
7402///   them;
7403/// * a quarter-turned span with no partner on its baseline is left exactly as
7404///   it was.
7405///
7406/// A group merges only when the layout would otherwise break it: a gap wide
7407/// enough to part it at the cut, or a sibling quarter-turned run on its row
7408/// the grid would read it with. A run standing lone and tight already reads
7409/// forwards through #435, and merging it only changes the shape downstream
7410/// counts -- thirteen glyph spans read as thirteen lines, one span as one,
7411/// which on one proxy page carries a marginal run to heading level and
7412/// re-decides the page's tables around it. Lone and tight stays as drawn,
7413/// everywhere, including a run held inside a table region's rectangle that
7414/// is margin furniture and not table ink.
7415/// A lone run inside a table region is the accepted residual: the grid slices
7416/// only rows of two or more, so a single turned line beside upright text is
7417/// thin and refused, as it is without this merge; no tuning page shows
7418/// otherwise.
7419fn merge_rotated_runs(spans: Vec<TextSpan>) -> Vec<TextSpan> {
7420    if !spans.iter().any(span_is_quarter_turned) {
7421        return spans;
7422    }
7423    /// A baseline of quarter-turned text taking shape: the direction its
7424    /// members share, the across-coordinate of its first span, and its members
7425    /// in draw order.
7426    struct Baseline {
7427        direction: Direction,
7428        across: f64,
7429        members: Vec<usize>,
7430    }
7431    let mut baselines: Vec<Baseline> = Vec::new();
7432    'spans: for (index, span) in spans.iter().enumerate() {
7433        if !span_is_quarter_turned(span) {
7434            continue;
7435        }
7436        let direction = span_direction(span);
7437        let across = baseline_coordinates(span, direction).1;
7438        // The band tolerance #435 groups a run's own lines by, asked of the
7439        // span joining: sub-point drift in the matrices still joins, and the
7440        // next column's head never does.
7441        let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
7442        for baseline in &mut baselines {
7443            if !directions_agree(baseline.direction, direction)
7444                || (baseline.across - across).abs() > tolerance
7445            {
7446                continue;
7447            }
7448            let first = &spans[baseline.members[0]];
7449            if first.font_name != span.font_name
7450                || first.is_bold != span.is_bold
7451                || first.is_italic != span.is_italic
7452                || first.color != span.color
7453            {
7454                continue;
7455            }
7456            baseline.members.push(index);
7457            continue 'spans;
7458        }
7459        baselines.push(Baseline {
7460            direction,
7461            across,
7462            members: vec![index],
7463        });
7464    }
7465
7466    // A baseline in pieces where another quarter-turned run stands inside
7467    // it: the members of one run are consecutive along it, and a gap another
7468    // run's glyph starts in is not a word gap of this one. A chart's axis label
7469    // reads `W`, `1`, `0`, `Omega`, `Delta` down one baseline with the two
7470    // small digits set in another face between the capitals; joining the
7471    // capitals across the digits parts the label where nothing parts it (#574:
7472    // the label comes back `01` in one block and `Delta Omega W` in another).
7473    // The label's own tight pairs stay whole -- intervention only ever splits.
7474    let mut split: Vec<Baseline> = Vec::with_capacity(baselines.len());
7475    for baseline in baselines {
7476        let mut members = baseline.members;
7477        members.sort_by(|a, b| {
7478            baseline_coordinates(&spans[*a], baseline.direction)
7479                .0
7480                .partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
7481                .unwrap_or(Ordering::Equal)
7482        });
7483        let mut piece = vec![members[0]];
7484        for pair in members.windows(2) {
7485            let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
7486            let end = baseline_end(prev, baseline.direction);
7487            let start = baseline_coordinates(curr, baseline.direction).0;
7488            let intervened = (start > end)
7489                && spans.iter().enumerate().any(|(index, span)| {
7490                    !members.contains(&index)
7491                        && span_is_quarter_turned(span)
7492                        && directions_agree(span_direction(span), baseline.direction)
7493                        && {
7494                            let (along, across) = baseline_coordinates(span, baseline.direction);
7495                            let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
7496                            along > end
7497                                && along < start
7498                                && (baseline.across - across).abs() <= tolerance
7499                        }
7500                });
7501            let size = prev.font_size.max(f64::MIN_POSITIVE);
7502            if start - end > ROTATED_RUN_MAX_GAP_EM * size || intervened {
7503                split.push(Baseline {
7504                    direction: baseline.direction,
7505                    across: baseline.across,
7506                    members: std::mem::take(&mut piece),
7507                });
7508            }
7509            piece.push(pair[1]);
7510        }
7511        split.push(Baseline {
7512            direction: baseline.direction,
7513            across: baseline.across,
7514            members: piece,
7515        });
7516    }
7517    let mut baselines = split;
7518
7519    // Keep the groups the layout would break and let the rest through as
7520    // drawn.
7521    {
7522        // Each group's reading-first baseline and size, for the sibling test
7523        // below. Reading-first is where the grid would row the run: the
7524        // bottom of a run that climbs the page, the top of one that falls.
7525        struct Seat {
7526            baseline: f64,
7527            size: f64,
7528        }
7529        let seats: Vec<Seat> = baselines
7530            .iter()
7531            .map(|baseline| {
7532                let first = baseline
7533                    .members
7534                    .iter()
7535                    .min_by(|a, b| {
7536                        baseline_coordinates(&spans[**a], baseline.direction)
7537                            .0
7538                            .partial_cmp(&baseline_coordinates(&spans[**b], baseline.direction).0)
7539                            .unwrap_or(Ordering::Equal)
7540                    })
7541                    .copied()
7542                    .unwrap_or(0);
7543                Seat {
7544                    baseline: spans[first].y,
7545                    size: baseline
7546                        .members
7547                        .iter()
7548                        .map(|i| spans[*i].font_size)
7549                        .fold(0.0, f64::max),
7550                }
7551            })
7552            .collect();
7553        let mut keep = vec![false; baselines.len()];
7554        for (ordinal, baseline) in baselines.iter().enumerate() {
7555            // A gap on the paragraph-break scale parts the run at the cut,
7556            // and the pieces come back in page order, backwards for a run
7557            // that climbs the page. Past two ems the gap splits the baseline
7558            // into runs below instead, so it is not this trigger: stacked
7559            // labels part where they should.
7560            let mut members = baseline.members.clone();
7561            members.sort_by(|a, b| {
7562                baseline_coordinates(&spans[*a], baseline.direction)
7563                    .0
7564                    .partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
7565                    .unwrap_or(Ordering::Equal)
7566            });
7567            let mut wide = false;
7568            for pair in members.windows(2) {
7569                let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
7570                let gap = baseline_coordinates(curr, baseline.direction).0
7571                    - baseline_end(prev, baseline.direction);
7572                let size = prev.font_size.max(f64::MIN_POSITIVE);
7573                if gap > LONE_RUN_MAX_GAP_EM * size && gap <= ROTATED_RUN_MAX_GAP_EM * size {
7574                    wide = true;
7575                    break;
7576                }
7577            }
7578            if wide {
7579                keep[ordinal] = true;
7580                continue;
7581            }
7582            // A sibling quarter-turned run on the same row is the row the grid
7583            // would read them as, one-glyph cells across: side-by-side heads
7584            // share a reading-first baseline to the point. Parts of one run split
7585            // by a style change never do -- consecutive parts start at least
7586            // an advance apart, past the half em below -- nor do stacked
7587            // labels or runs that read opposite ways, so the lone and the
7588            // stacked stay as drawn.
7589            keep[ordinal] = seats.iter().enumerate().any(|(other, seat)| {
7590                other != ordinal
7591                    && (seats[ordinal].baseline - seat.baseline).abs()
7592                        <= 0.5 * seats[ordinal].size.max(seat.size)
7593            });
7594        }
7595        let mut kept: Vec<Baseline> = Vec::with_capacity(baselines.len());
7596        for (baseline, keep) in baselines.into_iter().zip(keep) {
7597            if keep {
7598                kept.push(baseline);
7599            }
7600        }
7601        baselines = kept;
7602        if baselines.is_empty() {
7603            return spans;
7604        }
7605    }
7606
7607    // Each baseline along itself, in reading order, split into runs where the
7608    // white is wider than any word gap.
7609    let mut runs: Vec<Vec<usize>> = Vec::new();
7610    for baseline in &baselines {
7611        let mut members = baseline.members.clone();
7612        members.sort_by(|a, b| {
7613            baseline_coordinates(&spans[*a], baseline.direction)
7614                .0
7615                .partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
7616                .unwrap_or(Ordering::Equal)
7617        });
7618        let mut run = vec![members[0]];
7619        for pair in members.windows(2) {
7620            let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
7621            let gap = baseline_coordinates(curr, baseline.direction).0
7622                - baseline_end(prev, baseline.direction);
7623            if gap > ROTATED_RUN_MAX_GAP_EM * prev.font_size.max(f64::MIN_POSITIVE) {
7624                runs.push(std::mem::take(&mut run));
7625            }
7626            run.push(pair[1]);
7627        }
7628        runs.push(run);
7629    }
7630
7631    // Each run of two or more as one span, emitted where its first-drawn
7632    // member stood; a quarter-turned span with no partner keeps its own place.
7633    let mut replacement: Vec<Option<TextSpan>> = (0..spans.len()).map(|_| None).collect();
7634    let mut folded = vec![false; spans.len()];
7635    for run in runs.iter().filter(|run| run.len() >= 2) {
7636        let members: Vec<TextSpan> = run.iter().map(|i| spans[*i].clone()).collect();
7637        // The run as #435 would assemble it: its spans in reading order
7638        // through the line assembly, so the string is the one the page holds.
7639        // The bounds below follow the same order the text was joined in.
7640        let text = TextBlock {
7641            spans: members.clone(),
7642        }
7643        .visual_text();
7644        let reading_first = &members[0];
7645        let x = members
7646            .iter()
7647            .map(|span| span.x)
7648            .fold(f64::INFINITY, f64::min);
7649        let y = members
7650            .iter()
7651            .map(|span| span.y)
7652            .fold(f64::INFINITY, f64::min);
7653        let right = members
7654            .iter()
7655            .map(|span| span.measured_right())
7656            .fold(f64::NEG_INFINITY, f64::max);
7657        // Glyph-scale, and deliberately not the envelope: every reader of
7658        // `height` -- the band tolerance, the row pitch -- expects one line's
7659        // worth, and the envelope lives in the bounds, where the boxes are
7660        // actually read from.
7661        let height = members.iter().map(|span| span.height).fold(0.0, f64::max);
7662        let font_size = members
7663            .iter()
7664            .map(|span| span.font_size)
7665            .fold(0.0, f64::max);
7666        let mut char_bounds = Vec::new();
7667        let mut tight_char_bounds = Vec::new();
7668        let mut glyph_advances = Vec::new();
7669        let mut glyph_bounds_sources = Vec::new();
7670        for span in &members {
7671            char_bounds.extend(span.char_bounds.iter().copied());
7672            tight_char_bounds.extend(span.tight_char_bounds.iter().copied());
7673            glyph_advances.extend(span.glyph_advances.iter().copied());
7674            glyph_bounds_sources.extend(span.glyph_bounds_sources.iter().copied());
7675        }
7676        let merged = TextSpan {
7677            text,
7678            x,
7679            y,
7680            width: (right - x).max(0.0),
7681            height,
7682            font_size,
7683            font_name: reading_first.font_name.clone(),
7684            is_bold: reading_first.is_bold,
7685            is_italic: reading_first.is_italic,
7686            color: reading_first.color,
7687            // An estimate anywhere in the run, like the device's own merge.
7688            width_source: if members
7689                .iter()
7690                .any(|span| span.width_source == WidthSource::Estimate)
7691            {
7692                WidthSource::Estimate
7693            } else {
7694                WidthSource::Metric
7695            },
7696            char_bounds,
7697            geometry_mode: reading_first.geometry_mode,
7698            bounds_source: aggregate_bounds_source(&glyph_bounds_sources),
7699            tight_char_bounds,
7700            glyph_advances,
7701            glyph_bounds_sources,
7702            transform: reading_first.transform,
7703            font_weight: reading_first.font_weight,
7704            is_serif: reading_first.is_serif,
7705            is_ocr_layer: members.iter().any(|span| span.is_ocr_layer),
7706            is_monospace: reading_first.is_monospace,
7707            render_mode: reading_first.render_mode,
7708            font_metrics: reading_first.font_metrics,
7709            // The merged span ends where its last-drawn member does (#643).
7710            complete_run: members.last().is_some_and(|span| span.complete_run),
7711        };
7712        let first_drawn = run.iter().copied().min().unwrap_or(0);
7713        for index in run {
7714            folded[*index] = *index != first_drawn;
7715        }
7716        replacement[first_drawn] = Some(merged);
7717    }
7718
7719    let mut out: Vec<TextSpan> = Vec::with_capacity(spans.len());
7720    for (index, span) in spans.into_iter().enumerate() {
7721        if folded[index] {
7722            continue;
7723        }
7724        match replacement[index].take() {
7725            Some(merged) => out.push(merged),
7726            None => out.push(span),
7727        }
7728    }
7729    out
7730}
7731
7732fn xy_cut_recursive(
7733    spans: Vec<TextSpan>,
7734    depth: usize,
7735    stats: &PageStats,
7736    rules: &[HorizontalRule],
7737) -> Vec<Vec<TextBlock>> {
7738    if spans.is_empty() {
7739        return Vec::new();
7740    }
7741    if depth >= XY_CUT_MAX_DEPTH {
7742        return vec![band_based_blocks(spans, stats, rules)];
7743    }
7744
7745    // ANN[r17/TEX2] Pick whichever direction has the largest qualifying
7746    // gap. Always cutting vertically first breaks layouts where a
7747    // footer sits in the mid-x range — it would attach to the left
7748    // column instead of being recognized as a page-level zone. The
7749    // "largest gap wins" rule is the standard XY-Cut tie-breaker used
7750    // by academic OCR literature and matches pdf_oxide.
7751    let vcut = try_vertical_cut(&spans, stats, rules);
7752    let hcut = try_horizontal_cut(&spans, stats);
7753
7754    let (chosen, _) = match (vcut, hcut) {
7755        (Some((v_groups, v_gap)), Some((h_groups, h_gap))) => {
7756            if v_gap >= h_gap {
7757                (Some(v_groups), v_gap)
7758            } else {
7759                (Some(h_groups), h_gap)
7760            }
7761        }
7762        (Some((v_groups, v_gap)), None) => (Some(v_groups), v_gap),
7763        (None, Some((h_groups, h_gap))) => (Some(h_groups), h_gap),
7764        (None, None) => (None, 0.0),
7765    };
7766
7767    if let Some(groups) = chosen {
7768        let mut out = Vec::new();
7769        for group in groups {
7770            out.extend(xy_cut_recursive(group, depth + 1, stats, rules));
7771        }
7772        return out;
7773    }
7774
7775    vec![band_based_blocks(spans, stats, rules)]
7776}
7777
7778/// Emit per-band row blocks without any column detection. Used as the
7779/// leaf of XY-Cut recursion — at this point the region either has no
7780/// further cuts or the density guard refused them.
7781fn band_based_blocks(
7782    spans: Vec<TextSpan>,
7783    stats: &PageStats,
7784    rules: &[HorizontalRule],
7785) -> Vec<TextBlock> {
7786    // XY-Cut can miss recurring gutters when a small number of bands span the
7787    // full page width (e.g. a running header above a 3-column body). In that
7788    // case, fall back to the older band/gutter detector inside the leaf region
7789    // instead of flattening everything row-major.
7790    group_spans_into_blocks_legacy_with_stats(spans, stats, rules)
7791}
7792
7793/// Median font-size helper. Currently unreferenced after `PageStats` took over
7794/// the typography baseline computation; kept available for future tuning paths.
7795#[allow(dead_code)]
7796fn median_font_size(spans: &[TextSpan]) -> f64 {
7797    if spans.is_empty() {
7798        return 12.0;
7799    }
7800    let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
7801    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
7802    sizes[sizes.len() / 2]
7803}
7804
7805/// Whether span `at` of a band stands in a gutter without being its ink: a
7806/// marker hung into it (one glyph set apart, `hung`), or a word space drawn as
7807/// a run of its own (#574).
7808///
7809/// NYSE_MSA outdents its `t` bullets 10 pt past the left column's measure and
7810/// draws the space after each as a run of its own, so no x on the page is free
7811/// of every span and the two columns were read across, line by line. A run of
7812/// spaces wider than an em is not looked past: a table that fills its row with
7813/// spaces from cell to cell has said the row is one.
7814fn is_marker_or_space(hung: &HashSet<usize>, at: usize, span: &TextSpan) -> bool {
7815    hung.contains(&at)
7816        || (!span.text.chars().any(|c| !c.is_whitespace())
7817            && span.measured_right() - span.x <= span.font_size.max(1.0))
7818}
7819
7820/// Whether both sides of `cut_x` are running text: the median line on each
7821/// side holds more than `RUNNING_LETTERS` letters.
7822///
7823/// Asked of a gutter found only by looking past markers and spaces, because
7824/// the cell column of a table is also a strip of white with a few spans in it
7825/// -- NYSE_JWN's exhibit index, `Filed herewith electronically` down the right
7826/// -- and a cell is short where a column's line runs on.
7827fn both_sides_run_on(bands: &[TextBand], cut_x: f64) -> bool {
7828    let mut left: Vec<usize> = Vec::new();
7829    let mut right: Vec<usize> = Vec::new();
7830    for band in bands {
7831        // A band with ink on a side counts on that side even when its ink
7832        // is all figures: a row of amounts is a row with no letters, and
7833        // left out it would let one long header stand for the whole side
7834        // (codex, #2045).
7835        let (mut l, mut r) = (0usize, 0usize);
7836        let (mut l_ink, mut r_ink) = (false, false);
7837        for span in &band.spans {
7838            let inked = span.text.chars().any(|c| !c.is_whitespace());
7839            // Letters, because a row of amounts is as long as a line of text
7840            // and says nothing a reader would call running on.
7841            let chars = span.text.chars().filter(|c| c.is_alphabetic()).count();
7842            if span.x + (span.measured_right() - span.x) * 0.5 < cut_x {
7843                l += chars;
7844                l_ink |= inked;
7845            } else {
7846                r += chars;
7847                r_ink |= inked;
7848            }
7849        }
7850        if l_ink {
7851            left.push(l);
7852        }
7853        if r_ink {
7854            right.push(r);
7855        }
7856    }
7857    let median = |v: &mut Vec<usize>| {
7858        v.sort_unstable();
7859        v.get(v.len() / 2).copied().unwrap_or(0)
7860    };
7861    median(&mut left) > RUNNING_LETTERS && median(&mut right) > RUNNING_LETTERS
7862}
7863
7864/// Attempt a vertical (column) cut. Returns the span groups plus the
7865/// gap size (in pt) if a suitable gutter is found AND the density +
7866/// alignment guards accept.
7867///
7868/// ANN[r17/TEX2] Three guards together avoid false-positive columns:
7869///   1. `min_gap` is the MAX of (median_font, 10pt) — deliberately
7870///      lower than `median_font * 2` so narrow-gutter academic papers
7871///      (12pt gutters, common in print) are still detected. It does not
7872///      scale with the region: see the note on the constants above.
7873///   2. `columns_are_dense` rejects column splits where either side
7874///      has <2 spans or <8 chars/band — catches table cells.
7875///   3. `columns_are_band_aligned` rejects cuts where any band would
7876///      end up on only one side of the cut while being wider than
7877///      ~70% of that side's column width — catches full-width
7878///      paragraphs (Intro / Outro) that accidentally sit in the
7879///      left-column x-range.
7880fn try_vertical_cut(
7881    spans: &[TextSpan],
7882    stats: &PageStats,
7883    rules: &[HorizontalRule],
7884) -> Option<(Vec<Vec<TextSpan>>, f64)> {
7885    if spans.len() < 2 * XY_CUT_MIN_SPANS_PER_COLUMN {
7886        return None;
7887    }
7888    // A LINE NUMBER DRAWN INTO ITS LINE IS PEELED OFF FIRST (#1973), so that
7889    // the margin scan below can see it for what it is.
7890    let peeled = peel_line_numbers(spans);
7891    let spans: &[TextSpan] = &peeled;
7892
7893    let region_left = spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
7894    let region_right = spans
7895        .iter()
7896        .map(TextSpan::right)
7897        .fold(f64::NEG_INFINITY, f64::max);
7898    let region_width = region_right - region_left;
7899    if region_width <= 0.0 {
7900        return None;
7901    }
7902
7903    // ANN[r17/TEX2][r17/TEX4] Threshold uses the ADAPTIVE median-word-gap
7904    // from the bands rather than a flat font-size multiple. Narrow-gutter
7905    // academic layouts have 12pt gutters next to 4pt word spaces — the
7906    // adaptive threshold scales with the actual typography used on this
7907    // page. Clamped to `XY_CUT_VERTICAL_GAP_FLOOR` to avoid firing on
7908    // ordinary inter-word spaces when character advance data is noisy.
7909    // median_font and the width fraction act only as safety rails for
7910    // pathological inputs.
7911    let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
7912    // THE PAGE'S OWN RULING LINES COME FIRST. A region the document rules into
7913    // rows is a table, and a table read down its columns is the defect this
7914    // guard exists for (#545). Asked here as well as in the leaf below because
7915    // a table with a wide gutter is taken by this cut and never reaches the
7916    // leaf at all.
7917    let all_bands: Vec<usize> = (0..bands.len()).collect();
7918    if region_is_ruled(&bands, &all_bands, rules) {
7919        return None;
7920    }
7921    let adaptive = compute_adaptive_column_gap(&bands);
7922    let floor = stats.median_font_size.max(XY_CUT_VERTICAL_GAP_FLOOR);
7923    let min_gap = adaptive.min(floor).max(XY_CUT_VERTICAL_GAP_FLOOR);
7924
7925    // Intervals [x_left, x_right] of every span; we look for an x value
7926    // that is free of ALL intervals (full-height gap).
7927    // MEASURED EDGES ONLY. `right()` returns x + width.max(estimated_width()),
7928    // and the estimate is a character count times a nominal advance -- on
7929    // tracemonkey's abstract it overshoots the measured edge by 61pt, which is
7930    // wider than the gutter it then covers. A gap scan that asks "is any x free
7931    // of every span" cannot use an edge that is deliberately generous.
7932    // Unmeasured spans (width 0) keep the estimate, since a point-wide interval
7933    // would open a gap where there is text.
7934    //
7935    // AND THE MARGIN COLUMN IS NOT INK FOR THIS PURPOSE (#582). A statute and a
7936    // US patent print their line numbers down the middle of the page, INSIDE
7937    // the gutter, so no x is free of every span and the cut is unfindable at
7938    // any depth -- the page comes back read line by line across both columns.
7939    // `a_margin_column` is what those figures are and a word is not. The
7940    // intervals are taken from the bands rather than from the region's own
7941    // slice, because the set that names the spans to look past is indexed on
7942    // each band's spans and two numberings of one set of spans is how an index
7943    // goes wrong.
7944    let word_space = (adaptive / COLUMN_GAP_MEDIAN_MULTIPLIER).max(0.5);
7945    let (mut skipped, figures) = a_margin_column(&bands, region_width, min_gap, word_space);
7946    // A BULLET HUNG INTO THE GUTTER IS NOT THE GUTTER'S INK (#574). A column
7947    // of items outdents its markers to the left of its text, and on a page
7948    // set with a narrow gutter they stand in it: NYSE_MSA's `t` bullets sit
7949    // 10 pt from the left column's measure where the columns' text is 18 pt
7950    // apart, and the page was read across both columns line by line. A glyph
7951    // set apart from the rest of its band -- one character, then more white
7952    // than a word space -- is looked past here and still goes with the column
7953    // its midpoint falls in.
7954    let hung: Vec<HashSet<usize>> = bands
7955        .iter()
7956        .map(|row| {
7957            (0..row.spans.len())
7958                .filter(|&at| {
7959                    let span = &row.spans[at];
7960                    // LEADING, with white before it as well as after: the
7961                    // last glyph of a left-column line, `)` in a run of its
7962                    // own, has its line's text right against it (codex,
7963                    // #2041).
7964                    let apart_before = row.spans[..at]
7965                        .iter()
7966                        .rev()
7967                        .find(|prev| prev.text.chars().any(|c| !c.is_whitespace()))
7968                        .is_none_or(|prev| {
7969                            span.x - prev.measured_right()
7970                                >= HUNG_MARKER_GAP_EM * span.font_size.max(1.0)
7971                        });
7972                    span.text.chars().filter(|c| !c.is_whitespace()).count() == 1
7973                        && apart_before
7974                        && row.spans[at + 1..]
7975                            .iter()
7976                            .find(|next| next.text.chars().any(|c| !c.is_whitespace()))
7977                            .is_some_and(|next| {
7978                                next.x - span.measured_right()
7979                                    >= HUNG_MARKER_GAP_EM * span.font_size.max(1.0)
7980                            })
7981                })
7982                .collect()
7983        })
7984        .collect();
7985    let scan =
7986        |skipped: &[HashSet<usize>], from: usize, past_markers: bool| -> Option<(f64, f64)> {
7987            let mut intervals: Vec<(f64, f64)> = bands
7988                .iter()
7989                .enumerate()
7990                .skip(from)
7991                .flat_map(|(band, row)| {
7992                    let skipped = &skipped[band];
7993                    let hung = &hung[band];
7994                    row.spans
7995                        .iter()
7996                        .enumerate()
7997                        .filter(move |(at, s)| {
7998                            !skipped.contains(at)
7999                                && (!past_markers || !is_marker_or_space(hung, *at, s))
8000                        })
8001                        .map(|(_, s)| {
8002                            let r = if s.width > 0.0 {
8003                                s.measured_right()
8004                            } else {
8005                                s.right()
8006                            };
8007                            (s.x, r.max(s.x + 0.001))
8008                        })
8009                })
8010                .collect();
8011            intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
8012            let mut cursor = intervals.first()?.1;
8013            let mut best: Option<(f64, f64)> = None; // (gap_size, cut_x)
8014            for (left, right) in intervals.iter().skip(1) {
8015                if *left > cursor {
8016                    let gap = *left - cursor;
8017                    if gap >= min_gap && !matches!(best, Some((b, _)) if b >= gap) {
8018                        best = Some((gap, (cursor + *left) * 0.5));
8019                    }
8020                }
8021                cursor = cursor.max(*right);
8022            }
8023            best
8024        };
8025    // A RUNNING HEAD ACROSS THE GUTTER IS NOT THE PAGE (#1973). A US patent
8026    // centres `US 7,268,176 B2` over the gutter and sets its column numbers a
8027    // line under it, close enough to the body that no horizontal cut takes them
8028    // off first -- so the head alone leaves no x free, and the page was read
8029    // across both columns. Where the gutter is found once the top band or two
8030    // are left out, and those bands are a line of a few runs each, they are a
8031    // group of their own and read first.
8032    //
8033    // ONLY ON A PAGE THAT NUMBERS ITS LINES IN THE GUTTER, which is the page
8034    // this is for. Anywhere else a centred title over a table is the same
8035    // shape -- `SCHEDULE 3` over the two columns of a statute's schedule, a
8036    // heading over a reconciliation table -- and setting it aside handed the
8037    // table to the column cut and read it down its columns (validation,
8038    // uksi_20200121 0.939 -> 0.627, OTC_SDXHF 0.774 -> 0.455).
8039    let mut head = 0;
8040    let mut best_gap = scan(&skipped, 0, false);
8041    // Numbered BY A COLUMN OF FIGURES, as the margin scan found it: every
8042    // member it confirmed on its own band is a figure. The scan also looks past
8043    // a column of tab runs down a financial table (a 10-K, 0.641 -> 0.535) and
8044    // a list's markers, and one digit among `i`, `ii` is not a numbered page
8045    // (codex, #1991).
8046    let numbered = figures && skipped.iter().any(|band| !band.is_empty());
8047    if best_gap.is_none() && numbered {
8048        for drop in 1..=HEAD_BANDS_MAX.min(bands.len().saturating_sub(2)) {
8049            if bands[drop - 1].spans.len() > HEAD_BAND_MAX_SPANS {
8050                break;
8051            }
8052            if let Some(found) = scan(&skipped, drop, false) {
8053                best_gap = Some(found);
8054                head = drop;
8055                break;
8056            }
8057        }
8058    }
8059
8060    // ONLY WHERE NOTHING ELSE OPENS A GUTTER is it looked for past the
8061    // markers and word spaces standing in it, and what it then separates has
8062    // to be running text on both sides -- see `is_marker_or_space`.
8063    let mut past_markers = false;
8064    if best_gap.is_none() {
8065        best_gap = scan(&skipped, 0, true);
8066        past_markers = best_gap.is_some();
8067    }
8068    let (gap_size, cut_x) = best_gap?;
8069    // A marker looked past belongs to the column it hangs before, so the cut
8070    // goes just left of the first one standing in the gap.
8071    let cut_x = if past_markers {
8072        bands
8073            .iter()
8074            .zip(&hung)
8075            .flat_map(|(row, hung)| {
8076                row.spans
8077                    .iter()
8078                    .enumerate()
8079                    .filter(|(at, s)| is_marker_or_space(hung, *at, s))
8080                    .map(|(_, s)| s.x)
8081                    .collect::<Vec<_>>()
8082            })
8083            .filter(|x| (x - cut_x).abs() < gap_size * 0.5)
8084            .fold(cut_x, f64::min)
8085            - 0.01
8086    } else {
8087        cut_x
8088    };
8089
8090    // AND THE SKIP HAS TO HAVE BEEN WHAT WAS IN THE WAY. A thin column
8091    // somewhere ELSE on the page -- the hanging figures of a bibliography at
8092    // the left edge of each column, which are narrow, short and at one x on
8093    // every band -- is not a reason to believe in a gutter it does not stand
8094    // in. Where the column the scan looked past is not inside the gap that was
8095    // found, the scan is run again with nothing skipped and THAT cut is the
8096    // answer: refusing instead would take the columns off a page that has a
8097    // perfectly good gutter, which cost `tracemonkey.pdf` page 14 its reading
8098    // order, 100.0 to 74.5, where both foreign readers agree at 100.
8099    let margin_of = |sets: &[HashSet<usize>]| -> Vec<TextSpan> {
8100        bands
8101            .iter()
8102            .enumerate()
8103            .flat_map(|(band, row)| {
8104                let set = &sets[band];
8105                row.spans
8106                    .iter()
8107                    .enumerate()
8108                    .filter(move |(at, _)| set.contains(at))
8109                    .map(|(_, s)| s.clone())
8110            })
8111            .collect()
8112    };
8113    let mut margin = margin_of(&skipped);
8114    let inside = |spans: &[TextSpan], cut_x: f64, gap: f64| {
8115        spans
8116            .iter()
8117            .all(|s| s.x >= cut_x - gap && s.measured_right() <= cut_x + gap)
8118    };
8119    let (gap_size, cut_x) = if margin.is_empty() || inside(&margin, cut_x, gap_size) {
8120        (gap_size, cut_x)
8121    } else {
8122        skipped = vec![HashSet::new(); bands.len()];
8123        margin.clear();
8124        scan(&skipped, head, past_markers)?
8125    };
8126    let head_spans: Vec<TextSpan> = bands[..head].iter().flat_map(|b| b.spans.clone()).collect();
8127    let in_margin: HashSet<(u64, u64)> = margin
8128        .iter()
8129        .chain(&head_spans)
8130        .map(|s| (s.x.to_bits(), s.y.to_bits()))
8131        .collect();
8132
8133    // Split spans around the cut. A span whose midpoint is < cut_x belongs to
8134    // the left group; the margin column is a group of its OWN, so the line
8135    // numbers come out as their own block instead of inside the running text
8136    // of whichever column they happened to fall on.
8137    let mut left_group = Vec::new();
8138    let mut right_group = Vec::new();
8139    for span in spans {
8140        if in_margin.contains(&(span.x.to_bits(), span.y.to_bits())) {
8141            continue;
8142        }
8143        let midpoint = span.x + (span.right() - span.x) * 0.5;
8144        if midpoint < cut_x {
8145            left_group.push(span.clone());
8146        } else {
8147            right_group.push(span.clone());
8148        }
8149    }
8150
8151    if !columns_are_dense(&left_group, &right_group, stats) {
8152        return None;
8153    }
8154    // WITHOUT THE MARGIN COLUMN, for the reason the gap scan is run without it:
8155    // a band that carries a line number would otherwise reach across the cut
8156    // by the width of two figures and be read as a band sitting on one side.
8157    let aligned: Vec<TextSpan> = if in_margin.is_empty() {
8158        spans.to_vec()
8159    } else {
8160        spans
8161            .iter()
8162            .filter(|s| !in_margin.contains(&(s.x.to_bits(), s.y.to_bits())))
8163            .cloned()
8164            .collect()
8165    };
8166    let aligned: Vec<TextSpan> = if past_markers {
8167        aligned
8168            .into_iter()
8169            .filter(|s| s.text.chars().filter(|c| !c.is_whitespace()).count() > 1)
8170            .collect()
8171    } else {
8172        aligned
8173    };
8174    if past_markers && !both_sides_run_on(&bands[head..], cut_x) {
8175        return None;
8176    }
8177    if !columns_are_band_aligned(&aligned, cut_x, region_left, region_right, stats) {
8178        return None;
8179    }
8180    if a_column_of_labels(&bands[head..], cut_x, region_left, region_right, min_gap) {
8181        return None;
8182    }
8183    // THE SPANS THE SCAN SKIPPED REACH THE GUARD, and this is the caller the
8184    // parameter was added for. Counted as ink, a column of line numbers beside
8185    // the gutter puts a wide strip of white between itself and the rest of its
8186    // side on nearly every band, which reads as a bank's own cell boundary --
8187    // and the guard would refuse exactly the cut that skipping them was meant
8188    // to allow (#582, on the parameter #580 landed).
8189    if the_cut_is_a_column_of_a_table(&bands[head..], cut_x, gap_size, &skipped[head..]) {
8190        return None;
8191    }
8192
8193    let mut groups = Vec::new();
8194    if !head_spans.is_empty() {
8195        groups.push(head_spans);
8196    }
8197    groups.push(left_group);
8198    groups.push(right_group);
8199    if !margin.is_empty() {
8200        groups.push(margin);
8201    }
8202    Some((groups, gap_size))
8203}
8204
8205/// A line number drawn into the line beside it, split off as a span of its own
8206/// (#1973).
8207///
8208/// `a_margin_column` looks past a span that IS a line number. An OCR text layer
8209/// -- every US patent of DocLayNet's validation half set in two columns, 45 of
8210/// their 65 pages -- draws each right-column line as one object that opens in
8211/// the gutter with the line's number, so `10 example, aluminum and steel` is
8212/// one span from the gutter to the margin: a number and a line, which that
8213/// scan cannot look past, and the page was read across both columns. On the
8214/// rig that was one document, 9840656_AdobeOCR, and the whole of the text gap
8215/// to the reference: 0.340 against 0.886, 0.050 of the mean over eleven.
8216///
8217/// THE GEOMETRY DECIDES, as it does for the margin column: the head of the
8218/// span is one to three figures, then white, and the rest of the span starts
8219/// on an edge at least [`MARGIN_COLUMN_MIN_BANDS`] other spans start on --
8220/// the column's own left edge -- while the figures stand left of it. A line
8221/// that opens with a number at the column's edge (`10 people attended`) does
8222/// not stick out and is left whole; a figure that sticks out of a column
8223/// nothing else starts on is not peeled either. Whether the peeled figure is
8224/// then a margin column is `a_margin_column`'s question, asked as before.
8225fn peel_line_numbers(spans: &[TextSpan]) -> Vec<TextSpan> {
8226    let mut out = Vec::with_capacity(spans.len());
8227    for (at, span) in spans.iter().enumerate() {
8228        match peel_one(span, at, spans) {
8229            Some((figure, rest)) => {
8230                out.push(figure);
8231                out.push(rest);
8232            }
8233            None => match peel_trailing(span, at, spans) {
8234                Some((rest, figure)) => {
8235                    out.push(rest);
8236                    out.push(figure);
8237                }
8238                None => out.push(span.clone()),
8239            },
8240        }
8241    }
8242    out
8243}
8244
8245fn peel_one(span: &TextSpan, at: usize, spans: &[TextSpan]) -> Option<(TextSpan, TextSpan)> {
8246    let digits = span.text.chars().take_while(char::is_ascii_digit).count();
8247    if !(1..=3).contains(&digits) || !span.text[digits..].starts_with(' ') {
8248        return None;
8249    }
8250    let bounds = &span.char_bounds;
8251    if bounds.len() <= digits {
8252        return None;
8253    }
8254    let size = span.font_size.max(1.0);
8255    let figure_right = bounds[digits - 1][2];
8256    let clear = size * MARGIN_COLUMN_MIN_CLEARANCE_EM;
8257    // The rest opens at the first glyph clear of the figures: a drawn space
8258    // between them is a glyph of its own and belongs to neither.
8259    let first = (digits..bounds.len()).find(|&k| bounds[k][0] >= figure_right + clear)?;
8260    let rest_x = bounds[first][0];
8261    let tolerance = size * PEEL_EDGE_EM;
8262    if span.x >= rest_x - tolerance {
8263        return None;
8264    }
8265    let on_edge = spans
8266        .iter()
8267        .enumerate()
8268        .filter(|(other, s)| *other != at && (s.x - rest_x).abs() <= tolerance)
8269        .count();
8270    if on_edge < MARGIN_COLUMN_MIN_BANDS {
8271        return None;
8272    }
8273    let rest_text = span.text[digits..].trim_start();
8274    if rest_text.is_empty() {
8275        return None;
8276    }
8277    let cut = |v: &[[f64; 4]], lo: usize, hi: usize| {
8278        if v.len() == bounds.len() {
8279            v[lo..hi].to_vec()
8280        } else {
8281            Vec::new()
8282        }
8283    };
8284    let right = span.measured_right();
8285    let mut figure = span.clone();
8286    figure.text = span.text[..digits].to_string();
8287    figure.width = figure_right - span.x;
8288    figure.char_bounds = bounds[..digits].to_vec();
8289    figure.tight_char_bounds = cut(&span.tight_char_bounds, 0, digits);
8290    let mut rest = span.clone();
8291    rest.text = rest_text.to_string();
8292    rest.x = rest_x;
8293    rest.width = (right - rest_x).max(0.0);
8294    rest.char_bounds = bounds[first..].to_vec();
8295    // THE ORIGIN MOVES WITH THE SPAN (codex, #1991): `transform` locates the
8296    // span's first glyph, and the rest's first glyph is `first`, not the
8297    // figure it was drawn after.
8298    if let Some(t) = rest.transform.as_mut() {
8299        t[4] += bounds[first][0] - bounds[0][0];
8300        t[5] += bounds[first][1] - bounds[0][1];
8301    }
8302    rest.tight_char_bounds = cut(&span.tight_char_bounds, first, bounds.len());
8303    // The rich-geometry vectors run glyph for glyph beside the tight boxes;
8304    // split where they line up, dropped where they do not.
8305    if span.glyph_advances.len() == bounds.len() {
8306        figure.glyph_advances = span.glyph_advances[..digits].to_vec();
8307        rest.glyph_advances = span.glyph_advances[first..].to_vec();
8308    } else {
8309        figure.glyph_advances.clear();
8310        rest.glyph_advances.clear();
8311    }
8312    if span.glyph_bounds_sources.len() == bounds.len() {
8313        figure.glyph_bounds_sources = span.glyph_bounds_sources[..digits].to_vec();
8314        rest.glyph_bounds_sources = span.glyph_bounds_sources[first..].to_vec();
8315    } else {
8316        figure.glyph_bounds_sources.clear();
8317        rest.glyph_bounds_sources.clear();
8318    }
8319    Some((figure, rest))
8320}
8321
8322/// A line number drawn at the END of the line before it, the mirror of
8323/// [`peel_one`] (#574). A US patent prints its line numbers in the gutter, and
8324/// where a left-column line runs close to them the OCR layer's words join the
8325/// figure into the line's run: `market, citric acid and/or 35` (8287919)
8326/// ended nine points into a gutter of fourteen, and the page was read across
8327/// both columns line by line. Peeled the same way: one to three figures at the
8328/// end, white before them, and the rest ending on an edge at least
8329/// [`MARGIN_COLUMN_MIN_BANDS`] other spans end on -- the column's own right
8330/// edge -- with the figures standing right of it.
8331///
8332/// Counted from the END, because that is where the glyphs and the text agree:
8333/// a space the device inserts between two glyphs has no box, so the boxes and
8334/// the characters are out of step from the first inserted space on, but a
8335/// space is only ever inserted BEFORE a glyph, so whatever ends the text was
8336/// drawn.
8337fn peel_trailing(span: &TextSpan, at: usize, spans: &[TextSpan]) -> Option<(TextSpan, TextSpan)> {
8338    let body = span.text.trim_end();
8339    let tail_ws = span.text[body.len()..].chars().count();
8340    let digits = body.chars().rev().take_while(char::is_ascii_digit).count();
8341    if !(1..=3).contains(&digits) || !body[..body.len() - digits].ends_with(' ') {
8342        return None;
8343    }
8344    let rest_text = body[..body.len() - digits].trim_end();
8345    if rest_text.is_empty() {
8346        return None;
8347    }
8348    let bounds = &span.char_bounds;
8349    if bounds.len() <= tail_ws + digits {
8350        return None;
8351    }
8352    let figure_end = bounds.len() - tail_ws;
8353    let figure_start = figure_end - digits;
8354    let size = span.font_size.max(1.0);
8355    let figure_left = bounds[figure_start][0];
8356    let clear = size * MARGIN_COLUMN_MIN_CLEARANCE_EM;
8357    // The rest ends at the last glyph clear of the figures: a drawn space
8358    // between them reaches up to the figure and belongs to neither.
8359    let last = (0..figure_start)
8360        .rev()
8361        .find(|&k| bounds[k][2] <= figure_left - clear)?;
8362    let rest_right = bounds[last][2];
8363    let tolerance = size * PEEL_EDGE_EM;
8364    let figure_right = bounds[figure_end - 1][2];
8365    if figure_right <= rest_right + tolerance {
8366        return None;
8367    }
8368    let on_edge = spans
8369        .iter()
8370        .enumerate()
8371        .filter(|(other, s)| *other != at && (s.measured_right() - rest_right).abs() <= tolerance)
8372        .count();
8373    if on_edge < MARGIN_COLUMN_MIN_BANDS {
8374        return None;
8375    }
8376    let cut = |v: &[[f64; 4]], lo: usize, hi: usize| {
8377        if v.len() == bounds.len() {
8378            v[lo..hi].to_vec()
8379        } else {
8380            Vec::new()
8381        }
8382    };
8383    let mut rest = span.clone();
8384    rest.text = rest_text.to_string();
8385    rest.width = (rest_right - span.x).max(0.0);
8386    rest.char_bounds = bounds[..=last].to_vec();
8387    rest.tight_char_bounds = cut(&span.tight_char_bounds, 0, last + 1);
8388    let mut figure = span.clone();
8389    figure.text = body[body.len() - digits..].to_string();
8390    figure.x = figure_left;
8391    figure.width = (figure_right - figure_left).max(0.0);
8392    figure.char_bounds = bounds[figure_start..figure_end].to_vec();
8393    figure.tight_char_bounds = cut(&span.tight_char_bounds, figure_start, figure_end);
8394    // The origin moves with the figure, as it does with a peeled rest.
8395    if let Some(t) = figure.transform.as_mut() {
8396        t[4] += bounds[figure_start][0] - bounds[0][0];
8397        t[5] += bounds[figure_start][1] - bounds[0][1];
8398    }
8399    if span.glyph_advances.len() == bounds.len() {
8400        rest.glyph_advances = span.glyph_advances[..=last].to_vec();
8401        figure.glyph_advances = span.glyph_advances[figure_start..figure_end].to_vec();
8402    } else {
8403        rest.glyph_advances.clear();
8404        figure.glyph_advances.clear();
8405    }
8406    if span.glyph_bounds_sources.len() == bounds.len() {
8407        rest.glyph_bounds_sources = span.glyph_bounds_sources[..=last].to_vec();
8408        figure.glyph_bounds_sources = span.glyph_bounds_sources[figure_start..figure_end].to_vec();
8409    } else {
8410        rest.glyph_bounds_sources.clear();
8411        figure.glyph_bounds_sources.clear();
8412    }
8413    Some((rest, figure))
8414}
8415
8416/// Whether a column of candidates is a count of the page's lines: every member
8417/// a whole number, rising strictly from the top band down, all of them multiples
8418/// of one step of at least two -- `5`, `10`, `15`, with the members the scan did
8419/// not confirm simply missing (#574).
8420fn counts_the_lines(column: &[(usize, usize, f64)], bands: &[TextBand]) -> bool {
8421    let mut members: Vec<(usize, u32)> = Vec::with_capacity(column.len());
8422    for &(band, at, _) in column {
8423        // One to three figures, as a printed line number is. Nothing earlier
8424        // limits a candidate's length, and a column of years rises by a common
8425        // step as well: `2018`, `2020`, `2022` (codex, #2056).
8426        let text = bands[band].spans[at].text.trim();
8427        if text.chars().count() > MARGIN_FIGURE_MAX_CHARS {
8428            return false;
8429        }
8430        match text.parse::<u32>() {
8431            Ok(value) => members.push((band, value)),
8432            Err(_) => return false,
8433        }
8434    }
8435    if members.len() < MARGIN_COLUMN_MIN_BANDS {
8436        return false;
8437    }
8438    members.sort_unstable();
8439    let rising = members
8440        .windows(2)
8441        .all(|pair| pair[0].0 < pair[1].0 && pair[0].1 < pair[1].1);
8442    let step = members.iter().fold(0, |g, &(_, v)| gcd(g, v));
8443    rising && step >= 2
8444}
8445
8446fn gcd(a: u32, b: u32) -> u32 {
8447    if b == 0 {
8448        a
8449    } else {
8450        gcd(b, a % b)
8451    }
8452}
8453
8454/// How many bands at the top of a region may be set aside as its running head
8455/// when they are what fills the gutter (#1973): the head and the line of
8456/// column numbers a patent sets under it.
8457const HEAD_BANDS_MAX: usize = 2;
8458
8459/// The most runs a band set aside as a head may hold. A running head is a line
8460/// or two of a few runs; a band of more is a line of the page's own text, and
8461/// a region whose own first line crosses the gutter is not two columns.
8462const HEAD_BAND_MAX_SPANS: usize = 3;
8463
8464/// How near an edge a span has to start to be on it, in ems of the peeled
8465/// span's type: half an em, well inside a gutter and well over the jitter of
8466/// an OCR layer's left edges (a fifth of a point on the pages it was built for).
8467const PEEL_EDGE_EM: f64 = 0.5;
8468
8469/// Whether a run is a figure and nothing else, as a printed line number is.
8470fn only_figures(text: &str) -> bool {
8471    let t = text.trim();
8472    !t.is_empty() && t.chars().all(|c| c.is_ascii_digit())
8473}
8474
8475/// The most characters a member of a margin column may carry when it is taken
8476/// in by the column rather than by its own band (#1973): a line number is
8477/// three figures at most.
8478const MARGIN_FIGURE_MAX_CHARS: usize = 3;
8479
8480/// How wide a margin column may be, as a fraction of the region it stands in.
8481///
8482/// A tenth. A line number is two or three figures against a measure of sixty
8483/// characters; a column of a table is a third of its row, and this is what
8484/// refuses it before any of the other conditions is asked.
8485const MARGIN_COLUMN_MAX_WIDTH_FRACTION: f64 = 0.10;
8486
8487/// How much of its own band's text a margin column may carry.
8488///
8489/// COUNTED IN CHARACTERS AND NOT IN WIDTH, so that a proportional face does
8490/// not decide it: `10` beside a line of prose is two characters of sixty.
8491const MARGIN_COLUMN_MAX_CHAR_SHARE: f64 = 0.15;
8492
8493/// How much white a margin column needs on each side, in ems of its own type.
8494///
8495/// A fifth: under a word space in any face and over the fit between letters.
8496const MARGIN_COLUMN_MIN_CLEARANCE_EM: f64 = 0.2;
8497
8498/// How many bands a margin column has to stand on before it is one.
8499///
8500/// Three, for the reason the running-head rule of #571 asks for three pages: a
8501/// pair of short runs at one x is a coincidence a page cannot rule out.
8502const MARGIN_COLUMN_MIN_BANDS: usize = 3;
8503
8504/// The spans the gap scan may look past, per band (#582).
8505///
8506/// A statute and a US patent print their line numbers down the middle of the
8507/// page, INSIDE the gutter. The scan asks for an x free of every span, so such
8508/// a page has no free x at any depth and comes back read line by line across
8509/// both columns -- the whole of what the three patent pages of run
8510/// `structured-doclaynet-20260922T081244Z` lost, 1.227 of the 4.888 the
8511/// interleave class cost.
8512///
8513/// FOUR CONDITIONS, AND NONE OF THEM ABOUT WHAT THE SPAN SAYS. A rule that
8514/// read the text would fire on a verse number and on a marginal note, and
8515/// would have to be taught every language's figures:
8516///
8517/// 1. WHITE ON BOTH SIDES within its own band, measured against its OWN type
8518///    rather than against the gutter: a margin column stands inside a gutter
8519///    of twelve points and has three or four either side, so a threshold taken
8520///    from the gutter's width would refuse every real one.
8521/// 2. NARROW -- at most [`MARGIN_COLUMN_MAX_WIDTH_FRACTION`] of the region.
8522/// 3. A NEGLIGIBLE SHARE OF ITS BAND'S CHARACTERS.
8523/// 4. A COLUMN, AND EXACTLY ONE: the candidates stand at nearly the same x on
8524///    at least [`MARGIN_COLUMN_MIN_BANDS`] bands, and where two such columns
8525///    are found NOTHING is skipped.
8526///
8527/// The second and the fourth are what keep a table of figures out, and they
8528/// were asked for rather than assumed (T2, #577/#580): every cell of a bank of
8529/// figures has white on both sides, so the first condition alone would make
8530/// the boundary INSIDE a table findable and undo what #577 took out. A cell is
8531/// a third of its row, which the second refuses; and a table has several such
8532/// columns, which the fourth refuses.
8533/// Returns the spans to look past, one set per band, and whether the column
8534/// they form is FIGURES in every member its own bands found -- the evidence a
8535/// caller needs before it treats the page as one that numbers its lines
8536/// (codex, #1991): a column of `i`, `1`, `ii` is a list's markers.
8537fn a_margin_column(
8538    bands: &[TextBand],
8539    region_width: f64,
8540    min_gap: f64,
8541    word_space: f64,
8542) -> (Vec<HashSet<usize>>, bool) {
8543    let mut out: Vec<HashSet<usize>> = vec![HashSet::new(); bands.len()];
8544    if bands.len() < MARGIN_COLUMN_MIN_BANDS || region_width <= 0.0 {
8545        return (out, false);
8546    }
8547    let mut candidates: Vec<(usize, usize, f64)> = Vec::new();
8548    for (band, row) in bands.iter().enumerate() {
8549        let letters: usize = row.spans.iter().map(|s| s.text.chars().count()).sum();
8550        if letters == 0 {
8551            continue;
8552        }
8553        for (at, span) in row.spans.iter().enumerate() {
8554            let right = span.measured_right().max(span.x);
8555            if right - span.x > MARGIN_COLUMN_MAX_WIDTH_FRACTION * region_width {
8556                continue;
8557            }
8558            if span.text.chars().count() as f64 > MARGIN_COLUMN_MAX_CHAR_SHARE * letters as f64 {
8559                continue;
8560            }
8561            let before = row
8562                .spans
8563                .iter()
8564                .filter(|other| other.measured_right() <= span.x)
8565                .map(TextSpan::measured_right)
8566                .fold(f64::NEG_INFINITY, f64::max);
8567            let after = row
8568                .spans
8569                .iter()
8570                .filter(|other| other.x >= right)
8571                .map(|other| other.x)
8572                .fold(f64::INFINITY, f64::min);
8573            if !before.is_finite() || !after.is_finite() {
8574                continue;
8575            }
8576            let clear = (span.font_size * MARGIN_COLUMN_MIN_CLEARANCE_EM).max(word_space.min(1.0));
8577            if span.x - before <= clear || after - right <= clear {
8578                continue;
8579            }
8580            candidates.push((band, at, span.x + (right - span.x) * 0.5));
8581        }
8582    }
8583    if candidates.len() < MARGIN_COLUMN_MIN_BANDS {
8584        return (out, false);
8585    }
8586    candidates.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(Ordering::Equal));
8587    let mut columns: Vec<Vec<(usize, usize, f64)>> = Vec::new();
8588    for candidate in candidates {
8589        match columns.last_mut() {
8590            Some(column) if candidate.2 - column[column.len() - 1].2 <= min_gap => {
8591                column.push(candidate)
8592            }
8593            _ => columns.push(vec![candidate]),
8594        }
8595    }
8596    let mut found: Vec<&Vec<(usize, usize, f64)>> = columns
8597        .iter()
8598        .filter(|column| {
8599            let mut on: Vec<usize> = column.iter().map(|c| c.0).collect();
8600            on.sort_unstable();
8601            on.dedup();
8602            on.len() >= MARGIN_COLUMN_MIN_BANDS
8603        })
8604        .collect();
8605    // EXACTLY ONE. Two of them are the banks of a table, not a margin.
8606    //
8607    // UNLESS ONE OF THEM COUNTS THE PAGE'S LINES (#574). A US patent with a
8608    // table on it has the table's banks AND its gutter's line numbers, and the
8609    // rule above refused them all, so the page was read across both columns:
8610    // `6420562_AdobeOCR` p. 53, a bank of `0.0`, `4.0`, `64.9` beside the
8611    // gutter's `5`, `10` ... `65`. What a line-number column has and a bank of
8612    // figures does not is a count: whole numbers rising down the page by a
8613    // common step. A table's row numbers rise by one and are refused by the
8614    // step; a column of years has four figures and is refused by its width.
8615    if found.len() > 1 {
8616        found.retain(|column| counts_the_lines(column, bands));
8617    }
8618    if found.len() != 1 {
8619        return (out, false);
8620    }
8621    let column = found.pop().expect("one column");
8622    for (band, at, _) in column.iter() {
8623        out[*band].insert(*at);
8624    }
8625    // AND ITS MEMBERS THAT STAND BESIDE NO LINE (#1973). A line number beside
8626    // the first line of a paragraph in the right column, where the left
8627    // column's paragraph has just ended, has white after it and nothing before
8628    // it -- the first clause asks for both, and the figure stayed in the
8629    // gutter as ink. Once the column is found by the members that do have both,
8630    // a figure at the same x with white after it is one of them.
8631    //
8632    // A COLUMN OF FIGURES ONLY, where every member found is one. The same shape
8633    // with nothing before it is also the marker of a list hung out of its
8634    // column -- `i`, `ii`, `iii` down the left of a reconciliation table --
8635    // and taken into the column those markers let the cut through the table
8636    // (validation, OTC_SDXHF 0.774 -> 0.455). A column of line numbers says
8637    // what it is in every member it has.
8638    if !column
8639        .iter()
8640        .all(|&(band, at, _)| only_figures(&bands[band].spans[at].text))
8641    {
8642        return (out, false);
8643    }
8644    let centre = column.iter().map(|c| c.2).sum::<f64>() / column.len() as f64;
8645    for (band, row) in bands.iter().enumerate() {
8646        let letters: usize = row.spans.iter().map(|s| s.text.chars().count()).sum();
8647        for (at, span) in row.spans.iter().enumerate() {
8648            // Three characters at most, and not necessarily figures: the OCR
8649            // layer that draws these reads a `5` as an `s` often enough, and a
8650            // column that is figures in every member it was found by has said
8651            // what it holds (9017715_AdobeOCR, `hormone- s dependent`).
8652            if out[band].contains(&at) || span.text.trim().chars().count() > MARGIN_FIGURE_MAX_CHARS
8653            {
8654                continue;
8655            }
8656            let right = span.measured_right().max(span.x);
8657            if (span.x + (right - span.x) * 0.5 - centre).abs() > min_gap
8658                || right - span.x > MARGIN_COLUMN_MAX_WIDTH_FRACTION * region_width
8659                || span.text.chars().count() as f64 > MARGIN_COLUMN_MAX_CHAR_SHARE * letters as f64
8660            {
8661                continue;
8662            }
8663            let after = row
8664                .spans
8665                .iter()
8666                .filter(|other| other.x >= right)
8667                .map(|other| other.x)
8668                .fold(f64::INFINITY, f64::min);
8669            let clear = (span.font_size * MARGIN_COLUMN_MIN_CLEARANCE_EM).max(word_space.min(1.0));
8670            // AND WITH NOTHING AFTER IT (#574): where the right column has no
8671            // line -- a paragraph ends, a table stands in the other column --
8672            // the figure has the left column's line before it and white to the
8673            // page's edge. White before it is then what says it stands apart.
8674            let before = row
8675                .spans
8676                .iter()
8677                .filter(|other| other.measured_right() <= span.x)
8678                .map(TextSpan::measured_right)
8679                .fold(f64::NEG_INFINITY, f64::max);
8680            let apart = if after.is_finite() {
8681                after - right > clear
8682            } else {
8683                !before.is_finite() || span.x - before > clear
8684            };
8685            if apart {
8686                out[band].insert(at);
8687            }
8688        }
8689    }
8690    (out, true)
8691}
8692
8693/// Whether the gap being cut at is a boundary INSIDE a table rather than THE
8694/// gutter of a page set in columns.
8695///
8696/// `a_column_of_labels` above reads the table whose thin side is a column of
8697/// names. This is the other half of the same defect and the one shape none of
8698/// the guards here could see (#577): a table of figures whose two banks of
8699/// columns are both dense on every band. `region_is_ruled` cannot refuse it --
8700/// the document rules the head off and nothing else, which is one gap of eight
8701/// -- and `columns_are_dense` was written to catch a thin side, so a bank of
8702/// numbers passes it. Cut down the strip between the banks, the table comes out
8703/// as two tables side by side: every row of it is halved, and the figures of
8704/// row four stand under the figures of row one.
8705///
8706/// WHAT SEPARATES THE TWO CASES IS WHETHER THE CUT IS SPECIAL. A page set in
8707/// columns puts its widest strip of white between the columns, and a line of
8708/// prose inside one has nothing but word spaces in it -- so a side of that cut
8709/// holds bands with no gap anywhere near the gutter's width. A bank of a table
8710/// is columns of cells, so its own bands break at gaps as wide as the strip
8711/// between the banks or wider: the gutter that was cut at is not the widest
8712/// thing on the page, it is one of several the same size, and a table is what
8713/// that means.
8714///
8715/// BOTH SIDES, and that is the guard against the page this must not refuse. A
8716/// paper set in two columns with a table in ONE of them breaks the lines of
8717/// that column and not of the other, and its column cut is right; a run of
8718/// bands broken on both sides of the cut, most of the way down, is one grid.
8719///
8720/// AND A SPAN THE GAP SCAN SKIPPED IS NOT A CELL BOUNDARY. `ignored` names, per
8721/// band, the spans the caller left out of the scan that found this gap -- the
8722/// case it exists for is a thin column standing INSIDE a gutter, printed line
8723/// numbers down the middle of a statute or a patent, which makes every x in the
8724/// gutter occupied and the cut unfindable at any depth (#582). Those spans have
8725/// to be left out here too. Counted as ink, a column of numbers beside the
8726/// gutter puts a wide strip of white between itself and the rest of its side on
8727/// nearly every band -- which reads as a bank's own cell boundary, and this
8728/// guard would refuse exactly the cut that skipping them was meant to allow.
8729///
8730/// ONE SET PER BAND, parallel to `bands`, because a band's spans are numbered
8731/// within that band: the band that carries a line number holds three runs where
8732/// the band beside it holds two, so one set of indices for the whole region
8733/// would strike the number on one band and a column of text on the next (#582).
8734/// An empty slice ignores nothing, which is what `try_vertical_cut` passes; a
8735/// caller that skips spans builds its sets from the same banding the guard is
8736/// given.
8737fn the_cut_is_a_column_of_a_table(
8738    bands: &[TextBand],
8739    cut_x: f64,
8740    gap: f64,
8741    ignored: &[HashSet<usize>],
8742) -> bool {
8743    if gap <= 0.0 {
8744        return false;
8745    }
8746    // THE SAME EDGE ON BOTH SIDES OF THE DIVISION. `region_left` and
8747    // `region_right` are taken from `TextSpan::right()`, which is the greater of
8748    // the measured width and an estimate from the character count -- and on
8749    // prose that estimate overshoots the glyphs by tens of points. The extent
8750    // below is measured ink. Dividing one by the other inflates the denominator
8751    // and understates the fill, which is how a page of prose could be read as a
8752    // bank of cells and a legitimate column cut refused (codex, #577). So the
8753    // measure is the measured extent of the bands themselves.
8754    //
8755    // THE EXTENT IS OF THE BANDS AS THE GUARD SEES THEM, so `ignored` reaches it
8756    // too: a line-number column left out of the scan must not set the edge that
8757    // the fill is divided by, or the side it stands on reads as emptier than it is.
8758    let (ink_left, ink_right) = measured_extent(bands, ignored);
8759    let left = one_side_of_a_cut(
8760        bands,
8761        cut_x,
8762        true,
8763        (cut_x - ink_left).max(0.0),
8764        gap,
8765        ignored,
8766    );
8767    let right = one_side_of_a_cut(
8768        bands,
8769        cut_x,
8770        false,
8771        (ink_right - cut_x).max(0.0),
8772        gap,
8773        ignored,
8774    );
8775    let (left, right) = match (left, right) {
8776        (Some(left), Some(right)) => (left, right),
8777        _ => return false,
8778    };
8779    let banks = |side: &SideOfACut| (side.broken as f64) > BANK_BREAK_SHARE * side.inked as f64;
8780    if banks(&left) && banks(&right) {
8781        return true;
8782    }
8783    // ONE SIDE OF CELLS IS ENOUGH WHERE THE OTHER SIDE IS CELLS TOO. The other
8784    // half of this shape is a table whose first column is one cell a row --
8785    // names, dates, a label -- against a bank of figures. That side breaks
8786    // nowhere, because one cell cannot have a gap in it, so the test above sees
8787    // one bank and lets the cut through; `a_column_of_labels` does not refuse it
8788    // either, because it asks the thin side to have ink on far fewer bands than
8789    // the wide one and here both sides have ink on nearly all of them (#577).
8790    //
8791    // What decides is the measurement `region_is_a_grid` is built on: a line of
8792    // prose fills its measure and a cell does not. So a side that breaks
8793    // nowhere is still a column OF the table while its typical band leaves
8794    // white beside it -- and the page this must not refuse, a paper set in two
8795    // columns with a table in one of them, has prose on the other side, filling
8796    // it.
8797    (banks(&left) && right.fill <= GRID_SLICE_FILL)
8798        || (banks(&right) && left.fill <= GRID_SLICE_FILL)
8799}
8800
8801/// The measured ink of a set of bands, left and right.
8802///
8803/// `measured_right` rather than `right`, because the fill this feeds is computed
8804/// from measured ink and a measure taken from the estimate would not be the same
8805/// question (codex, #577).
8806///
8807/// `ignored` is the same per-band set the guard is given: a span left out of the
8808/// gap scan is left out of the extent too, or the edge it sets would be divided
8809/// into a fill it does not appear in (#582). An empty slice ignores nothing.
8810fn measured_extent(bands: &[TextBand], ignored: &[HashSet<usize>]) -> (f64, f64) {
8811    let mut left = f64::INFINITY;
8812    let mut right = f64::NEG_INFINITY;
8813    for (b, band) in bands.iter().enumerate() {
8814        let skip = ignored.get(b);
8815        for (i, span) in band.spans.iter().enumerate() {
8816            if skip.is_some_and(|set| set.contains(&i)) {
8817                continue;
8818            }
8819            left = left.min(span.x);
8820            right = right.max(span.measured_right());
8821        }
8822    }
8823    if left.is_finite() && right > left {
8824        (left, right)
8825    } else {
8826        (0.0, 0.0)
8827    }
8828}
8829
8830/// One side of a proposed cut: how many bands have ink on it, how many of them
8831/// break at a gap as wide as the cut's, and how much of the side its typical
8832/// band covers.
8833struct SideOfACut {
8834    inked: usize,
8835    broken: usize,
8836    fill: f64,
8837}
8838
8839/// `None` where the side has ink on too few bands to say anything.
8840fn one_side_of_a_cut(
8841    bands: &[TextBand],
8842    cut_x: f64,
8843    is_left: bool,
8844    measure: f64,
8845    gap: f64,
8846    ignored: &[HashSet<usize>],
8847) -> Option<SideOfACut> {
8848    if measure <= 0.0 {
8849        return None;
8850    }
8851    let mut inked = 0usize;
8852    let mut broken = 0usize;
8853    let mut fills: Vec<f64> = Vec::new();
8854    for (at, band) in bands.iter().enumerate() {
8855        let skipped = ignored.get(at);
8856        let mut ink: Vec<(f64, f64)> = band
8857            .spans
8858            .iter()
8859            .enumerate()
8860            .filter(|(span, _)| skipped.is_none_or(|set| !set.contains(span)))
8861            .map(|(_, span)| span)
8862            .filter(|span| {
8863                let middle = span.x + (span.measured_right() - span.x) * 0.5;
8864                (middle < cut_x) == is_left
8865            })
8866            .map(|span| (span.x, span.measured_right()))
8867            .collect();
8868        if ink.is_empty() {
8869            continue;
8870        }
8871        inked += 1;
8872        ink.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
8873        // The widest strip of white INSIDE this side of the band. A cell
8874        // boundary of a bank, where the side is a bank; a word space, where it
8875        // is a line of prose.
8876        let widest = ink
8877            .windows(2)
8878            .map(|pair| pair[1].0 - pair[0].1)
8879            .fold(0.0_f64, f64::max);
8880        if widest >= gap {
8881            broken += 1;
8882        }
8883        let from = ink.iter().map(|pair| pair.0).fold(f64::INFINITY, f64::min);
8884        let to = ink
8885            .iter()
8886            .map(|pair| pair.1)
8887            .fold(f64::NEG_INFINITY, f64::max);
8888        if to > from {
8889            fills.push(((to - from) / measure).min(1.0));
8890        }
8891    }
8892    if inked < BANK_MIN_ROWS || fills.is_empty() {
8893        return None;
8894    }
8895    fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
8896    Some(SideOfACut {
8897        inked,
8898        broken,
8899        fill: fills[fills.len() / 2],
8900    })
8901}
8902
8903/// Whether the thin side of the cut is a column of LABELS for the other side:
8904/// a table whose cells hold sentences (#576).
8905///
8906/// `region_is_ruled` above says the same thing about a table the document drew
8907/// lines round. This is the commoner table -- no lines, a narrow first column
8908/// of names and a wide one of descriptions -- and cut down its gutter every
8909/// row of it is lost: the names come out as one paragraph, the descriptions as
8910/// another, and nothing says which name went with which description.
8911///
8912/// THE MEASUREMENT IS WHERE THE THIN SIDE'S INK SITS. A label stands against
8913/// the START of what it labels, so every band the thin side has ink on is a
8914/// band where the wide side BEGINS something: the wide side's step into that
8915/// band is wider than the leading it sets its own lines at. A note in the
8916/// margin -- the case this must not refuse -- falls where it falls, in the
8917/// middle of the running text beside it, and its bands are ordinary lines of
8918/// that text.
8919///
8920/// Every band, not most: the thin side is a handful of bands, and a share over
8921/// a handful is a coin toss. One label in the middle of a paragraph and this
8922/// is not a table.
8923fn a_column_of_labels(
8924    bands: &[TextBand],
8925    cut_x: f64,
8926    region_left: f64,
8927    region_right: f64,
8928    min_gap: f64,
8929) -> bool {
8930    fn on(band: &TextBand, left: bool, cut_x: f64) -> Vec<&TextSpan> {
8931        band.spans
8932            .iter()
8933            .filter(|span| {
8934                let middle = span.x + (span.measured_right() - span.x) * 0.5;
8935                (middle < cut_x) == left
8936            })
8937            .collect()
8938    }
8939    let inked = |left: bool| -> Vec<usize> {
8940        (0..bands.len())
8941            .filter(|at| !on(&bands[*at], left, cut_x).is_empty())
8942            .collect()
8943    };
8944    let (thin, wide, thin_is_left) = {
8945        let (l, r) = (inked(true), inked(false));
8946        if l.len() <= r.len() {
8947            (l, r, true)
8948        } else {
8949            (r, l, false)
8950        }
8951    };
8952    if thin.len() < LABEL_MIN_ROWS || thin.len() * 4 > wide.len() * 3 {
8953        return false;
8954    }
8955
8956    // AND A LABEL IS SHORT. The other way a side has ink on few bands is that
8957    // it is the shorter column of a page set in columns -- one that ends
8958    // higher than its neighbour -- and its lines fill their measure to the
8959    // last word, which is what a line of prose does and a cell does not. So
8960    // the typical line of the thin side has to leave white beside it before
8961    // any of this is read as a label at all.
8962    let measure = if thin_is_left {
8963        cut_x - region_left
8964    } else {
8965        region_right - cut_x
8966    };
8967    if measure <= 0.0 {
8968        return false;
8969    }
8970    // AND A LABEL IS ONE CELL: a thin side whose own bands break into several
8971    // runs of ink is not a column at all but more columns, which is what a
8972    // page of glyph samples laid out in a grid looks like from here.
8973    let mut fills: Vec<f64> = Vec::with_capacity(thin.len());
8974    for at in &thin {
8975        let slice = on(&bands[*at], thin_is_left, cut_x);
8976        let broken = slice
8977            .windows(2)
8978            .any(|pair| pair[1].x - pair[0].measured_right() >= min_gap);
8979        if broken {
8980            return false;
8981        }
8982        let from = slice.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
8983        let to = slice
8984            .iter()
8985            .map(|s| s.measured_right())
8986            .fold(f64::NEG_INFINITY, f64::max);
8987        if to > from {
8988            fills.push(((to - from) / measure).min(1.0));
8989        }
8990    }
8991    if fills.is_empty() {
8992        return false;
8993    }
8994    fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
8995    if fills[fills.len() / 2] > GRID_SLICE_FILL {
8996        return false;
8997    }
8998
8999    // The wide side's own leading: the middle step between the bands it has
9000    // ink on. A step wider than `LABEL_PITCH_JUMP` of it is where it begins
9001    // something rather than carrying on.
9002    let mut steps: Vec<f64> = wide
9003        .windows(2)
9004        .map(|pair| bands[pair[0]].y - bands[pair[1]].y)
9005        .filter(|step| *step > 0.0)
9006        .collect();
9007    if steps.len() < 2 {
9008        return false;
9009    }
9010    steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9011    let leading = steps[steps.len() / 2];
9012    if leading <= 0.0 {
9013        return false;
9014    }
9015
9016    // A LABEL MAY WRAP. `Community resources` set over two lines is one label
9017    // and its second line stands against nothing, so a band the thin side
9018    // carries on into -- one leading under a thin band of its own -- is as
9019    // much part of a label as the band that opened it.
9020    let opens_the_wide_side = |at: usize| match wide.iter().position(|w| *w >= at) {
9021        Some(0) => true,
9022        Some(where_) if wide[where_] == at => {
9023            bands[wide[where_ - 1]].y - bands[at].y > leading * LABEL_PITCH_JUMP
9024        }
9025        _ => false,
9026    };
9027    thin.iter().all(|at| {
9028        *at == 0
9029            || opens_the_wide_side(*at)
9030            || (thin.binary_search(&(at - 1)).is_ok() && bands[at - 1].y - bands[*at].y <= leading)
9031    })
9032}
9033
9034/// ANN[r17/TEX2] Reject a vertical cut when any band sits on only one
9035/// side of the cut AND occupies more than ~70% of that side's column
9036/// width. Such bands are almost certainly full-width paragraphs that
9037/// happened to align with the left margin of one column, and forcing
9038/// them into that column re-orders them relative to text that follows.
9039fn columns_are_band_aligned(
9040    spans: &[TextSpan],
9041    cut_x: f64,
9042    region_left: f64,
9043    region_right: f64,
9044    stats: &PageStats,
9045) -> bool {
9046    let left_width = (cut_x - region_left).max(1.0);
9047    let right_width = (region_right - cut_x).max(1.0);
9048
9049    // Threshold chosen empirically: paragraph bodies in columnar
9050    // layouts usually fill ~60-70% of their column; anything wider
9051    // than 0.7× is a page-level element masquerading as column
9052    // content.
9053    const MAX_SINGLE_SIDE_FRACTION: f64 = 0.70;
9054
9055    let _ = (left_width, right_width, MAX_SINGLE_SIDE_FRACTION);
9056    // ONE SPAN'S INK REACHING ACROSS THE CUT is what "a full-width element
9057    // masquerading as column content" actually looks like. The guard used to
9058    // ask a different question -- is a band that sits on one side wider than
9059    // 0.70 of that side -- and on a real two-column page the answer is yes for
9060    // any ordinary line of body text, because a line fills about 0.9 of its
9061    // column. Every such page has single-sided bands (a heading, a column that
9062    // ends short, a figure), so the old test refused the canonical academic
9063    // page every time (#429).
9064    //
9065    // Width was a proxy for crossing, and it is a proxy that cannot tell a
9066    // banner from a column line. Its own fixture shows why: the banner runs
9067    // x 40..320 against a cut at 200, which is ink on both sides. It counted as
9068    // "one side" only because sides were decided on each span's midpoint.
9069    for span in spans {
9070        if span.x < cut_x && span.measured_right() > cut_x {
9071            return false;
9072        }
9073    }
9074
9075    // A LINE CANNOT BE HALF AGAIN AS WIDE AS THE TYPICAL LINE IN ITS OWN COLUMN
9076    // and still be a line of that column. That is the second way a full-width
9077    // element hides: `Intro paragraph` and `Outro paragraph` in
9078    // mixed_single_and_multi_column_regions_preserve_shared_bands are wider
9079    // than the column they sit above but still stop short of the gutter, so no
9080    // ink of theirs crosses the cut. Swallowed into the left column they would
9081    // read Intro, L1..L3, Outro, R1..R3 -- the outro before the right column it
9082    // precedes.
9083    //
9084    // Measured against the MEDIAN LINE OF THAT SIDE, not against the width of
9085    // the side. The old test used cut_x - region_left, which is a column plus
9086    // half a gutter, so an ordinary body line at ~0.9 of its column tripped it
9087    // and the canonical two-column page was refused every time (#429). The
9088    // median calibrates itself: on a real column most lines run full width, so
9089    // the median IS the column width and nothing legitimate exceeds it.
9090    const OUTLIER: f64 = 1.3;
9091    // EVERY band's per-side extent, not only the single-sided ones. Measuring
9092    // the typical line from the single-sided bands alone compares the outliers
9093    // with themselves: in the Intro/Outro fixture those two ARE the only
9094    // single-sided bands, so their median is their own width and nothing ever
9095    // stands out.
9096    let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
9097    let mut widths: (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
9098    let mut single: Vec<(bool, f64)> = Vec::new();
9099    for band in &bands {
9100        let mut left_lo = f64::INFINITY;
9101        let mut left_hi = f64::NEG_INFINITY;
9102        let mut right_lo = f64::INFINITY;
9103        let mut right_hi = f64::NEG_INFINITY;
9104        for span in &band.spans {
9105            let r = span.measured_right();
9106            if span.x + (r - span.x) * 0.5 < cut_x {
9107                left_lo = left_lo.min(span.x);
9108                left_hi = left_hi.max(r);
9109            } else {
9110                right_lo = right_lo.min(span.x);
9111                right_hi = right_hi.max(r);
9112            }
9113        }
9114        let has_left = left_hi > left_lo || left_lo.is_finite();
9115        let has_right = right_hi > right_lo || right_lo.is_finite();
9116        if has_left {
9117            widths.0.push((left_hi - left_lo).max(0.0));
9118        }
9119        if has_right {
9120            widths.1.push((right_hi - right_lo).max(0.0));
9121        }
9122        match (has_left, has_right) {
9123            (true, false) => single.push((true, (left_hi - left_lo).max(0.0))),
9124            (false, true) => single.push((false, (right_hi - right_lo).max(0.0))),
9125            _ => {}
9126        }
9127    }
9128    // A PATENT'S FRONT PAGE: THE COLUMN'S WIDTH IS ITS LONG LINES. Its
9129    // bibliographic column is mostly short lines -- `(22) Filed: Nov. 8,
9130    // 2002` over `(58) Field of Search ..... 252/500; 528/422` -- so the
9131    // median stood at 152 pt where the column runs 199, the column's own
9132    // longest line read as a banner over both columns, and every line joined
9133    // its neighbour across the gutter (#574, the rig-like proxy's patents).
9134    // Where the page numbers its fields with INID codes -- three or more
9135    // lines opening `(21)`, `(75)`, `(58)` at the region's left edge -- the
9136    // column is measured by its
9137    // ninetieth percentile. Only there: taken for every page, the same
9138    // measure cut financial tables and scientific pages that the median
9139    // keeps whole (tables -0.010, geometric order -0.012 on the proxy).
9140    let inid = spans
9141        .iter()
9142        .filter(|span| {
9143            let t = span.text.trim_start();
9144            let b = t.as_bytes();
9145            // At the column's LEFT EDGE: an equation's number `(23)` stands
9146            // at the right of a scientific page and is not a field.
9147            span.x <= region_left + 2.0 * span.font_size.max(1.0)
9148                && b.len() >= 4
9149                && b[0] == b'('
9150                && b[1].is_ascii_digit()
9151                && b[2].is_ascii_digit()
9152                && b[3] == b')'
9153        })
9154        .count()
9155        >= 3;
9156    let median = |v: &mut Vec<f64>| -> Option<f64> {
9157        if v.is_empty() {
9158            return None;
9159        }
9160        v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9161        Some(if inid {
9162            v[(v.len() * 9 / 10).min(v.len() - 1)]
9163        } else {
9164            v[v.len() / 2]
9165        })
9166    };
9167    let left_median = median(&mut widths.0);
9168    let right_median = median(&mut widths.1);
9169    for (is_left, w) in single {
9170        let m = if is_left { left_median } else { right_median };
9171        if let Some(m) = m {
9172            if m > 0.0 && w > m * OUTLIER {
9173                return false;
9174            }
9175        }
9176    }
9177    true
9178}
9179
9180/// Density guard — reject column splits that look like tables (few,
9181/// short spans per column). A column is "dense" when it has at least
9182/// MIN_SPANS_PER_COLUMN spans and the average character count per band
9183/// exceeds MIN_CHARS_PER_BAND.
9184fn columns_are_dense(left: &[TextSpan], right: &[TextSpan], stats: &PageStats) -> bool {
9185    for col in [left, right] {
9186        if col.len() < XY_CUT_MIN_SPANS_PER_COLUMN {
9187            return false;
9188        }
9189        let bands = group_spans_into_bands_with_stats(col.to_vec(), stats);
9190        if bands.is_empty() {
9191            return false;
9192        }
9193        let total_chars: usize = col.iter().map(|s| s.text.chars().count()).sum();
9194        let chars_per_band = total_chars as f64 / bands.len() as f64;
9195        if chars_per_band < XY_CUT_MIN_CHARS_PER_BAND {
9196            return false;
9197        }
9198    }
9199    true
9200}
9201
9202/// Attempt a horizontal (zone / paragraph) cut. Unlike vertical cuts
9203/// this does NOT need a density guard — splitting top-from-bottom
9204/// cannot re-order content.
9205fn try_horizontal_cut(spans: &[TextSpan], stats: &PageStats) -> Option<(Vec<Vec<TextSpan>>, f64)> {
9206    if spans.len() < 2 {
9207        return None;
9208    }
9209    // Sort by descending y (PDF y grows upward).
9210    let mut sorted = spans.to_vec();
9211    sorted.sort_by(|a, b| {
9212        b.y.partial_cmp(&a.y)
9213            .unwrap_or(Ordering::Equal)
9214            .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
9215    });
9216
9217    // ANN[r17/TEX4] Paragraph / zone cuts scale with MEDIAN LINE
9218    // SPACING when available — this is the typographically correct
9219    // baseline (paragraph break ≈ 1.8 × line-spacing). When the page
9220    // has only one band, or stats haven't observed spacing yet, fall
9221    // back to the font-size multiple the legacy path used.
9222    let min_gap = if stats.median_line_spacing > 0.0 {
9223        stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
9224    } else {
9225        stats.median_font_size * XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER
9226    };
9227    // AND NEVER BELOW THE LEADING THE PAGE IS SET ON (#580). The threshold
9228    // above scales with `median_line_spacing`, which is the FIRST QUARTILE of
9229    // the band steps -- so a page carrying anything tighter than its body (a
9230    // table, a stack of figure labels, a run of sub- and superscripts) puts the
9231    // quartile on that tighter thing and the threshold lands BELOW the body's
9232    // own leading. Every line break on the page then clears it.
9233    //
9234    // WHAT THAT COSTS IS THE WHOLE PAGE, not one cut, and the reason is in
9235    // `blocks::paragraphs`: its stretches are bounded PER REGION, so lines that
9236    // the cut put in two regions can never be merged back. On
9237    // `PMC6019521-p0007` the threshold sat at 10.82 against a leading of 12.00
9238    // and the page came out as 83 blocks, 72 of them a single line of prose --
9239    // one block per line, unrecoverable downstream.
9240    //
9241    // A FLOOR AND NOT A DIFFERENT STATISTIC. Replacing the quartile with the
9242    // leading was measured too and it is wrong: the threshold then rises above
9243    // the paragraph break as well, no horizontal cut fires, the vertical cut is
9244    // refused on the full-width band that is left, and a two-column page reads
9245    // letter-perfect nonsense -- left and right column lines glued alternately.
9246    // The horizontal cut firing at paragraph-break scale is what makes the
9247    // vertical cut findable at all. So the window the threshold has to sit in is
9248    // (the leading, the paragraph break], and the floor is the only end of it
9249    // that was missing.
9250    let leading = leading_of_a_region(spans, stats);
9251    let min_gap = if leading > 0.0 {
9252        min_gap.max(leading * HORIZONTAL_CUT_LEADING_FLOOR)
9253    } else {
9254        min_gap
9255    };
9256
9257    // Look for the largest gap between consecutive span y-values.
9258    // Once, before the scan: every candidate is asked the same question about the
9259    // same bands, and building them per candidate would make the guard's cost
9260    // quadratic in the gaps a page has.
9261    let bands = group_spans_into_bands(sorted.clone());
9262    let mut best: Option<(f64, f64)> = None; // (gap_size, cut_y)
9263    let tolerance = stats.median_font_size * BAND_Y_FRACTION;
9264    let mut band_bottom = sorted[0].y;
9265
9266    for span in sorted.iter().skip(1) {
9267        if (band_bottom - span.y).abs() <= tolerance {
9268            band_bottom = band_bottom.min(span.y);
9269            continue;
9270        }
9271        let gap = band_bottom - span.y;
9272        if gap >= min_gap {
9273            let cut_y = (band_bottom + span.y) * 0.5;
9274            match best {
9275                Some((best_gap, _)) if best_gap >= gap => {}
9276                _ => best = Some((gap, cut_y)),
9277            }
9278        }
9279        band_bottom = span.y;
9280    }
9281
9282    let (gap_size, cut_y) = best?;
9283
9284    // AND NOT INSIDE ONE GRID (#596): a table whose rows are grouped under labels
9285    // leaves more white between the groups than between its rows, and the
9286    // threshold above is a multiple of the ROW pitch.
9287    //
9288    // THE WHOLE REGION IS REFUSED, and the review asked for the other shape:
9289    // skip the grid-internal candidates while scanning and take the largest gap
9290    // left, so that a SMALLER qualifying gap which separates the table from its
9291    // caption is not lost with them. The hazard is real -- a table left merged
9292    // with prose above it is a region `tables::find` refuses outright -- but the
9293    // cure measured worse and the numbers are why it is not taken: over the 43
9294    // annotated pages this machine may hold, refusing the region leaves a summed
9295    // distance of 25 from the annotated table counts, and scanning past the
9296    // grid-internal gaps leaves 32 against 50 unguarded. `PMC3037919-p0007` goes
9297    // back from 1 table to 4 and `PMC2268700-p0008` from 1 to 3.
9298    //
9299    // The reason is this guard's own blind spots, which are counted on #596: where
9300    // the region has fewer than two bands on a side, or the widest band shows
9301    // fewer than two separations, a gap INSIDE the grid is not recognised as one.
9302    // Scanning on therefore does not find the caption's gap, it finds the next
9303    // unrecognised gap inside the table and cuts there. Refusing the region keeps
9304    // the table whole; scanning past cuts it somewhere else.
9305    if the_cut_is_inside_a_grid(&bands, cut_y, gap_size) {
9306        return None;
9307    }
9308
9309    let mut top_group = Vec::new();
9310    let mut bottom_group = Vec::new();
9311    for span in spans {
9312        if span.y > cut_y {
9313            top_group.push(span.clone());
9314        } else {
9315            bottom_group.push(span.clone());
9316        }
9317    }
9318    if top_group.is_empty() || bottom_group.is_empty() {
9319        return None;
9320    }
9321    Some((vec![top_group, bottom_group], gap_size))
9322}
9323
9324/// Legacy band+column-detection path, kept for reference and as the
9325/// fallback inside `band_based_blocks` test coverage. Not currently
9326/// used — XY-Cut supersedes it.
9327#[allow(dead_code)]
9328fn group_spans_into_blocks_legacy(spans: Vec<TextSpan>) -> Vec<TextBlock> {
9329    let bands = group_spans_into_bands(spans);
9330    group_spans_into_blocks_legacy_from_bands(bands, &[])
9331}
9332
9333fn group_spans_into_blocks_legacy_with_stats(
9334    spans: Vec<TextSpan>,
9335    stats: &PageStats,
9336    rules: &[HorizontalRule],
9337) -> Vec<TextBlock> {
9338    let bands = group_spans_into_bands_with_stats(spans, stats);
9339    group_spans_into_blocks_legacy_from_bands(bands, rules)
9340}
9341
9342fn group_spans_into_blocks_legacy_from_bands(
9343    bands: Vec<TextBand>,
9344    rules: &[HorizontalRule],
9345) -> Vec<TextBlock> {
9346    if bands.is_empty() {
9347        return Vec::new();
9348    }
9349
9350    let column_gap_threshold = compute_adaptive_column_gap(&bands);
9351
9352    let mut blocks = Vec::new();
9353    let mut idx = 0;
9354
9355    while idx < bands.len() {
9356        let gap_midpoints = bands[idx].gap_midpoints(column_gap_threshold);
9357        if gap_midpoints.is_empty() {
9358            idx = push_row(&bands, idx, &mut blocks);
9359            continue;
9360        }
9361
9362        let mut boundaries = gap_midpoints.clone();
9363        let mut band_indices = vec![idx];
9364        let mut gapped_band_count = 1usize;
9365        let mut region_left = bands[idx].left();
9366        let mut region_right = bands[idx].right();
9367        let mut next_idx = idx + 1;
9368
9369        while next_idx < bands.len() {
9370            let next_band = &bands[next_idx];
9371            let next_gap_midpoints = next_band.gap_midpoints(column_gap_threshold);
9372            if next_gap_midpoints.is_empty() {
9373                if next_band
9374                    .fits_single_column(&boundaries, region_left, region_right)
9375                    .is_some()
9376                {
9377                    band_indices.push(next_idx);
9378                    next_idx += 1;
9379                    continue;
9380                }
9381                break;
9382            }
9383
9384            if !boundaries_match(&boundaries, &next_gap_midpoints, column_gap_threshold) {
9385                break;
9386            }
9387
9388            update_boundaries(&mut boundaries, &next_gap_midpoints, gapped_band_count);
9389            gapped_band_count += 1;
9390            band_indices.push(next_idx);
9391            region_left = region_left.min(next_band.left());
9392            region_right = region_right.max(next_band.right());
9393            next_idx += 1;
9394        }
9395
9396        if region_is_columnar(&bands, &band_indices, &boundaries, gapped_band_count)
9397            && !region_is_ruled(&bands, &band_indices, rules)
9398            && !region_is_a_grid(&bands, &band_indices, &boundaries)
9399        {
9400            append_column_region_blocks(&bands, &band_indices, &boundaries, &mut blocks);
9401            idx = next_idx;
9402        } else {
9403            idx = push_row(&bands, idx, &mut blocks);
9404        }
9405    }
9406
9407    blocks
9408}
9409
9410/// Emit the band at `idx` and return the index of the first band after what was
9411/// emitted.
9412///
9413/// A band is normally one line of the page and becomes one block. The exception
9414/// is the row of a table one of whose cells wraps: the wrapped cell's second
9415/// line is a band of its own, and emitting the bands in turn puts it after the
9416/// row's remaining cells instead of after the text it continues (#434).
9417fn push_row(bands: &[TextBand], idx: usize, blocks: &mut Vec<TextBlock>) -> usize {
9418    if let Some(next) = append_wrapped_table_row(bands, idx, blocks) {
9419        return next;
9420    }
9421    blocks.push(bands[idx].row_block());
9422    idx + 1
9423}
9424
9425/// The left edges of the cells the band at `idx` draws, as the row itself
9426/// states them: one edge per span start, and two starts within
9427/// `CELL_EDGE_TOLERANCE` are one edge.
9428///
9429/// Cell edges are taken from starts rather than from the gaps between spans
9430/// because a cell's measured extent is not a column boundary: `8.2.5.26-t05`
9431/// draws four of its five columns less than 16 pt apart, which no gutter
9432/// threshold that leaves ordinary word spacing alone would ever see.
9433fn cell_edges(band: &TextBand) -> Vec<f64> {
9434    let mut edges: Vec<f64> = band.spans.iter().map(|span| span.x).collect();
9435    edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9436    edges.dedup_by(|a, b| (*a - *b).abs() <= CELL_EDGE_TOLERANCE);
9437    edges
9438}
9439
9440/// The cell a span belongs to: the edge it starts on, when it also stops before
9441/// the next edge. A span that starts between two edges, or that reaches into the
9442/// next cell, belongs to no cell of this grid and refuses the whole row.
9443///
9444/// The extent asked for is the measured one. A span's nominal width is the sum
9445/// of the advances the font asks for, which regularly overshoots the ink and so
9446/// the cell: `t05`'s wrapped cell advances 78 pt past its own text and into the
9447/// column after it.
9448fn cell_of(span: &TextSpan, edges: &[f64]) -> Option<usize> {
9449    let idx = edges
9450        .iter()
9451        .rposition(|edge| span.x >= edge - CELL_EDGE_TOLERANCE)?;
9452    if (span.x - edges[idx]).abs() > CELL_EDGE_TOLERANCE {
9453        return None;
9454    }
9455    if let Some(next_edge) = edges.get(idx + 1) {
9456        if span.measured_right() > next_edge - CELL_EDGE_TOLERANCE {
9457            return None;
9458        }
9459    }
9460    Some(idx)
9461}
9462
9463/// The tallest glyph of the band, which is the unit both vertical distances
9464/// below are measured in.
9465fn band_line_height(band: &TextBand) -> f64 {
9466    band.spans
9467        .iter()
9468        .map(|span| span.height.max(span.font_size))
9469        .fold(0.0_f64, f64::max)
9470}
9471
9472/// Whether `band` is another row of the same grid: every one of its spans sits
9473/// in a cell of `edges`, and it carries at least `TABLE_GRID_MIN_COLUMNS` of
9474/// them.
9475///
9476/// This is what separates a table row from two lines of prose that happen to
9477/// break in the right place. One line's spans always align with themselves; a
9478/// grid is only a grid when a second row draws the same edges.
9479/// The single cell of the grid a band fills, when it fills exactly one.
9480///
9481/// This is what a wrapped line looks like from above, and what the next row of
9482/// a table does not look like: a row with an empty cell still fills two of
9483/// them, so it is not mistaken for the line of a cell that ran on.
9484fn band_fills_one_cell(band: &TextBand, edges: &[f64]) -> Option<usize> {
9485    let mut filled: Option<usize> = None;
9486    for span in &band.spans {
9487        let cell = cell_of(span, edges)?;
9488        match filled {
9489            Some(seen) if seen != cell => return None,
9490            _ => filled = Some(cell),
9491        }
9492    }
9493    filled
9494}
9495
9496fn band_matches_grid(band: &TextBand, edges: &[f64]) -> bool {
9497    let mut occupied = vec![false; edges.len()];
9498    for span in &band.spans {
9499        match cell_of(span, edges) {
9500            Some(cell) => occupied[cell] = true,
9501            None => return false,
9502        }
9503    }
9504    occupied.iter().filter(|seen| **seen).count() >= TABLE_GRID_MIN_COLUMNS
9505}
9506
9507/// Emit the row at `idx` cell by cell when one of its cells wraps, and return
9508/// the index of the first band after the row. `None` when the band is not such
9509/// a row, which is the ordinary case.
9510///
9511/// The row is emitted slice-major: every cell in turn, and within a cell its
9512/// own lines in the order they are drawn. That is what both readers do, and it
9513/// is the only order in which a wrapped cell reads as one piece of text.
9514///
9515/// A wrapped line and the next row of the table look alike from above -- both
9516/// are a band under the row with some of its cells filled -- and telling them
9517/// apart is the whole of the work here. Two things say "line" rather than
9518/// "row": it fills a single cell of the grid, and it sits closer to the row
9519/// than the rows of the grid sit to each other.
9520///
9521/// What it does not do, deliberately:
9522///   * a row two of whose cells wrap onto the same line is a line that fills
9523///     two cells, which is what the next row of the table looks like, so it
9524///     stays row-major;
9525///   * so does a row every one of whose cells has a line under it: nothing
9526///     there says which line continues the row and which one begins the next,
9527///     and it is also the shape a two-column region takes when one column runs
9528///     on alone -- reordering that would be the column cut of #389 class O-B
9529///     done badly;
9530///   * a row of two cells is left alone, because that is also what a page set
9531///     in columns looks like -- see `TABLE_ROW_MIN_CELLS`;
9532///   * the first row of a table is left alone, because a single line's cell
9533///     edges are also the word boundaries of a sentence and no row above it
9534///     corroborates them;
9535///   * a continuation that is indented, centred or right-aligned inside its
9536///     cell does not start on the cell's edge and is not recognised.
9537fn append_wrapped_table_row(
9538    bands: &[TextBand],
9539    idx: usize,
9540    blocks: &mut Vec<TextBlock>,
9541) -> Option<usize> {
9542    let head = &bands[idx];
9543    if head.spans.len() < TABLE_ROW_MIN_CELLS {
9544        return None;
9545    }
9546    let edges = cell_edges(head);
9547    if edges.len() < TABLE_ROW_MIN_CELLS {
9548        return None;
9549    }
9550    let line_height = band_line_height(head);
9551    if line_height <= 0.0 {
9552        return None;
9553    }
9554
9555    let head_cells: Vec<usize> = head
9556        .spans
9557        .iter()
9558        .map(|span| cell_of(span, &edges))
9559        .collect::<Option<Vec<usize>>>()?;
9560
9561    // The pitch of the grid, from the band directly above: one line plus
9562    // whatever the cells are padded by. Nothing below may sit that far from
9563    // what it continues, or it is the next row rather than a wrapped line.
9564    let above = idx.checked_sub(1)?;
9565    let row_pitch = bands[above].y - head.y;
9566    if row_pitch <= 0.0 {
9567        return None;
9568    }
9569    let max_line_gap = (line_height * WRAPPED_CELL_LINE_GAP_FACTOR)
9570        .min(row_pitch * WRAPPED_CELL_MAX_PITCH_FRACTION);
9571
9572    // The nearest row above that draws the same cell edges. It is what says
9573    // this is a grid at all: one line's spans always align with themselves, so
9574    // only a second row makes the edges evidence of anything. The bands stepped
9575    // over on the way are the wrapped lines of that row.
9576    let mut corroborator = above;
9577    loop {
9578        if band_matches_grid(&bands[corroborator], &edges) {
9579            break;
9580        }
9581        if band_fills_one_cell(&bands[corroborator], &edges).is_none()
9582            || idx - corroborator >= TABLE_GRID_MAX_LOOKBACK
9583            || corroborator == 0
9584        {
9585            return None;
9586        }
9587        corroborator -= 1;
9588    }
9589
9590    // The continuation lines: each fills one cell of the grid that the row
9591    // itself fills, and sits less than the grid's pitch below the last line of
9592    // *that cell*.
9593    //
9594    // Per cell, and not below the line before it whichever cell that was: a
9595    // cell's lines are consecutive, so its third line is two gaps below the row
9596    // while another cell's second line is one. Measuring against the last line
9597    // accepted would let the row after a wrapped one -- if it happens to fill a
9598    // single cell -- sit one gap below that wrapped line and be read as a third
9599    // line of the row it follows.
9600    let mut continuations: Vec<(&TextBand, usize)> = Vec::new();
9601    let mut last_line_in_cell = vec![head.y; edges.len()];
9602    let mut next_idx = idx + 1;
9603    while next_idx < bands.len() {
9604        let band = &bands[next_idx];
9605        // One cell's line cannot carry more runs than the whole row it
9606        // continues; a band that does is a row of its own.
9607        if band.spans.len() >= head.spans.len() {
9608            break;
9609        }
9610        let Some(cell) = band_fills_one_cell(band, &edges) else {
9611            break;
9612        };
9613        if !head_cells.contains(&cell) {
9614            break;
9615        }
9616        let line_gap = last_line_in_cell[cell] - band.y;
9617        if line_gap <= 0.0 || line_gap > max_line_gap {
9618            break;
9619        }
9620        last_line_in_cell[cell] = band.y;
9621        continuations.push((band, cell));
9622        next_idx += 1;
9623    }
9624    if continuations.is_empty() {
9625        return None;
9626    }
9627
9628    let wrapped_cells: Vec<usize> = continuations.iter().map(|(_, cell)| *cell).collect();
9629    if head_cells.iter().all(|cell| wrapped_cells.contains(cell)) {
9630        return None;
9631    }
9632
9633    for cell in 0..edges.len() {
9634        let row_spans: Vec<TextSpan> = head
9635            .spans
9636            .iter()
9637            .zip(&head_cells)
9638            .filter(|(_, span_cell)| **span_cell == cell)
9639            .map(|(span, _)| span.clone())
9640            .collect();
9641        if !row_spans.is_empty() {
9642            blocks.push(TextBlock { spans: row_spans });
9643        }
9644        for (band, wrapped_cell) in &continuations {
9645            if *wrapped_cell == cell {
9646                blocks.push(band.row_block());
9647            }
9648        }
9649    }
9650
9651    Some(next_idx)
9652}
9653
9654/// Legacy wrapper used by call sites that haven't been handed PageStats.
9655/// It derives stats locally. Prefer `group_spans_into_bands_with_stats`
9656/// inside the XY-Cut pipeline to avoid recomputing the stats per call.
9657fn group_spans_into_bands(spans: Vec<TextSpan>) -> Vec<TextBand> {
9658    let stats = PageStats::from_spans(&spans);
9659    group_spans_into_bands_with_stats(spans, &stats)
9660}
9661
9662fn group_spans_into_bands_with_stats(mut spans: Vec<TextSpan>, stats: &PageStats) -> Vec<TextBand> {
9663    if spans.is_empty() {
9664        return Vec::new();
9665    }
9666
9667    spans.sort_by(|a, b| {
9668        b.y.partial_cmp(&a.y)
9669            .unwrap_or(Ordering::Equal)
9670            .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
9671    });
9672
9673    // ANN[r17/TEX4] Band tolerance scales with this page's median font
9674    // size rather than a fixed 5pt floor. Single-page spreads with
9675    // huge display fonts (24pt+) previously merged unrelated lines; a
9676    // fractional threshold keeps that from happening without hurting
9677    // body-text pages.
9678    let page_tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
9679
9680    let mut bands: Vec<TextBand> = Vec::new();
9681
9682    for span in spans {
9683        let tolerance = (span.height * BAND_Y_FRACTION)
9684            .max(page_tolerance)
9685            .max(BAND_Y_TOLERANCE);
9686        if let Some(band) = bands
9687            .iter_mut()
9688            .find(|band| (band.y - span.y).abs() <= tolerance)
9689        {
9690            let span_count = band.spans.len() as f64;
9691            band.y = (band.y * span_count + span.y) / (span_count + 1.0);
9692            band.spans.push(span);
9693        } else {
9694            bands.push(TextBand::new(span));
9695        }
9696    }
9697
9698    for band in &mut bands {
9699        band.sort_spans();
9700    }
9701
9702    bands.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
9703    bands
9704}
9705
9706fn boundaries_match(boundaries: &[f64], gap_midpoints: &[f64], column_gap_threshold: f64) -> bool {
9707    let tolerance = (column_gap_threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
9708    boundaries.len() == gap_midpoints.len()
9709        && boundaries
9710            .iter()
9711            .zip(gap_midpoints)
9712            .all(|(lhs, rhs)| (lhs - rhs).abs() <= tolerance)
9713}
9714
9715fn update_boundaries(boundaries: &mut [f64], gap_midpoints: &[f64], seen_gapped_bands: usize) {
9716    for (boundary, midpoint) in boundaries.iter_mut().zip(gap_midpoints) {
9717        *boundary =
9718            (*boundary * seen_gapped_bands as f64 + midpoint) / (seen_gapped_bands as f64 + 1.0);
9719    }
9720}
9721
9722fn region_is_columnar(
9723    bands: &[TextBand],
9724    band_indices: &[usize],
9725    boundaries: &[f64],
9726    gapped_band_count: usize,
9727) -> bool {
9728    if boundaries.is_empty()
9729        || gapped_band_count < MIN_COLUMN_GAPPED_BANDS
9730        || band_indices.is_empty()
9731        || (gapped_band_count as f64 / band_indices.len() as f64) < MIN_COLUMN_GAP_SUPPORT
9732    {
9733        return false;
9734    }
9735
9736    let mut non_empty_slices = 0usize;
9737    let mut dense_slices = 0usize;
9738    let mut slices_per_column = vec![0usize; boundaries.len() + 1];
9739
9740    for &band_idx in band_indices {
9741        let slices = bands[band_idx].split_by_boundaries(boundaries);
9742        for (column_idx, slice) in slices.iter().enumerate() {
9743            if slice.is_empty() {
9744                continue;
9745            }
9746
9747            non_empty_slices += 1;
9748            slices_per_column[column_idx] += 1;
9749
9750            let char_count = slice
9751                .iter()
9752                .map(|span| span.text.chars().count())
9753                .sum::<usize>();
9754            if slice.len() >= 2 || char_count >= 8 {
9755                dense_slices += 1;
9756            }
9757        }
9758    }
9759
9760    if non_empty_slices < boundaries.len() + 2 {
9761        return false;
9762    }
9763
9764    if slices_per_column.contains(&0) {
9765        return false;
9766    }
9767
9768    // A COLUMN OF MARKERS IS A LIST (#574). A bullet set well apart from its
9769    // item -- ASX_MRM draws a 19 pt `•` 13 pt before each item's text -- is a
9770    // gap on every band at one x, and read as a column the page came back as
9771    // four bullets and then four items, which no list pass can put together
9772    // again. Where every cell of the first column is a list marker, the bands
9773    // are lines.
9774    //
9775    // AN ITEM OPENS WITH A WORD: a `–` for nil down the first column of a
9776    // block of amounts is a column of cells, and the band beside it holds
9777    // figures (DocLayNet validation, a statement of receivables).
9778    let mut marked = 0usize;
9779    let markers_first = band_indices.iter().all(|&band_idx| {
9780        let slices = bands[band_idx].split_by_boundaries(boundaries);
9781        let joined = |slice: &Vec<TextSpan>| -> String {
9782            slice
9783                .iter()
9784                .map(|span| span.text.as_str())
9785                .collect::<String>()
9786        };
9787        let Some(first) = slices.first() else {
9788            return true;
9789        };
9790        let text = joined(first);
9791        let text = text.trim();
9792        if text.is_empty() {
9793            return true;
9794        }
9795        let item_follows = slices
9796            .iter()
9797            .skip(1)
9798            .map(joined)
9799            .find(|t| !t.trim().is_empty())
9800            // Words, wherever they start: `“Revenue increased”`, `2024
9801            // results` are items; a cell of figures is not (codex, #2045).
9802            .is_some_and(|t| t.chars().any(char::is_alphabetic));
9803        marked += 1;
9804        is_bullet_or_list_marker(text) && item_follows
9805    });
9806    if markers_first && marked >= 2 {
9807        return false;
9808    }
9809
9810    (dense_slices as f64 / non_empty_slices as f64) >= MIN_DENSE_SLICE_RATIO
9811}
9812
9813/// Whether a cell's whole text is a list's marker: a bullet glyph, a pointer
9814/// or picture the font had no letter for, or a short ordinal like `1.`, `a)`.
9815fn is_bullet_or_list_marker(text: &str) -> bool {
9816    let mut chars = text.chars();
9817    match (chars.next(), chars.next()) {
9818        (Some(c), None) => {
9819            crate::lists::BULLET_CHARS.contains(&c) || crate::lists::is_a_pointer_or_a_picture(c)
9820        }
9821        _ => is_list_marker(text),
9822    }
9823}
9824
9825/// Whether this region is a TABLE rather than a page set in columns (#570).
9826///
9827/// `region_is_ruled` answers the same question from the document's own
9828/// graphics and says nothing at all about a table drawn without ruling lines --
9829/// which is the commonest kind there is, and which `region_is_columnar` reads
9830/// down its columns: the rows of it come back as one paragraph per column, and
9831/// every row of the table is lost. So there is a second answer, and it is made
9832/// of the one measurement that separates the two cases without a ruling line.
9833///
9834/// A LINE OF PROSE FILLS ITS COLUMN AND A CELL DOES NOT. That is the whole of
9835/// it. A paper set in two columns sets each of them to a measure and fills it:
9836/// the slice a band leaves in a column is as wide as the column, to the last
9837/// word of the last line. A table's cells are words and figures, ragged and
9838/// short, and the white to the right of them is what makes the thing look like
9839/// a table to a reader. So: a region whose columns are all filled in most of
9840/// its rows, and whose typical cell covers well under its column, is a grid.
9841///
9842/// Deliberately narrow. Three rows at least, every column present in three
9843/// quarters of them, and the MEDIAN fill under `GRID_SLICE_FILL` -- so one
9844/// short line in a column of prose, or one long cell in a table, decides
9845/// nothing.
9846fn region_is_a_grid(bands: &[TextBand], band_indices: &[usize], boundaries: &[f64]) -> bool {
9847    if band_indices.len() < GRID_MIN_ROWS || boundaries.is_empty() {
9848        return false;
9849    }
9850
9851    let mut left = f64::INFINITY;
9852    let mut right = f64::NEG_INFINITY;
9853    for &band_idx in band_indices {
9854        for span in &bands[band_idx].spans {
9855            left = left.min(span.x);
9856            right = right.max(span.measured_right());
9857        }
9858    }
9859    if !(left.is_finite() && right > left) {
9860        return false;
9861    }
9862    let edges: Vec<f64> = std::iter::once(left)
9863        .chain(boundaries.iter().copied())
9864        .chain(std::iter::once(right))
9865        .collect();
9866
9867    let mut present = vec![0usize; boundaries.len() + 1];
9868    let mut fills: Vec<f64> = Vec::new();
9869    for &band_idx in band_indices {
9870        for (column, slice) in bands[band_idx]
9871            .split_by_boundaries(boundaries)
9872            .iter()
9873            .enumerate()
9874        {
9875            if slice.is_empty() {
9876                continue;
9877            }
9878            present[column] += 1;
9879            let slice_left = slice
9880                .iter()
9881                .map(|span| span.x)
9882                .fold(f64::INFINITY, f64::min);
9883            let slice_right = slice
9884                .iter()
9885                .map(TextSpan::measured_right)
9886                .fold(f64::NEG_INFINITY, f64::max);
9887            let width = edges[column + 1] - edges[column];
9888            if width > 0.0 && slice_right > slice_left {
9889                fills.push(((slice_right - slice_left) / width).min(1.0));
9890            }
9891        }
9892    }
9893
9894    let needed = GRID_COLUMN_SUPPORT * band_indices.len() as f64;
9895    if present.iter().any(|seen| (*seen as f64) < needed) {
9896        return false;
9897    }
9898    if fills.is_empty() {
9899        return false;
9900    }
9901    fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9902    fills[fills.len() / 2] <= GRID_SLICE_FILL
9903}
9904
9905/// Whether the page rules this region into rows (#545).
9906///
9907/// A rule counts when it crosses the region -- `RULE_SPAN_FRACTION` of the
9908/// region's own width, so a line under a single cell does not speak for the
9909/// table -- and lies between the baselines of two bands that follow each other
9910/// in the region. Enough of the region's band gaps carrying such a rule is the
9911/// document saying, in its own graphics, that these bands are rows; and rows
9912/// are read across, never down.
9913///
9914/// What it does not claim: a table drawn without ruling lines is invisible
9915/// here, and this is deliberately the whole of the claim. The alternative
9916/// offered for that case was a width heuristic, and a width heuristic cannot
9917/// tell a table from a paper set in two columns -- it fixed the Isartor
9918/// appendix and cost `tracemonkey` p2 and p13, which is the class O-B danger
9919/// the #389 report names for exactly this defect.
9920fn region_is_ruled(bands: &[TextBand], band_indices: &[usize], rules: &[HorizontalRule]) -> bool {
9921    if rules.is_empty() || band_indices.len() < MIN_RULED_ROW_SEPARATORS + 1 {
9922        return false;
9923    }
9924
9925    let mut left = f64::INFINITY;
9926    let mut right = f64::NEG_INFINITY;
9927    for &band_idx in band_indices {
9928        for span in &bands[band_idx].spans {
9929            left = left.min(span.x);
9930            right = right.max(span.measured_right());
9931        }
9932    }
9933    let width = right - left;
9934    if width <= 0.0 {
9935        return false;
9936    }
9937
9938    let mut gaps = 0usize;
9939    let mut ruled = 0usize;
9940    for pair in band_indices.windows(2) {
9941        let upper = bands[pair[0]].y;
9942        let lower = bands[pair[1]].y;
9943        if upper <= lower {
9944            continue;
9945        }
9946        gaps += 1;
9947        let crossed = rules.iter().any(|rule| {
9948            rule.y > lower
9949                && rule.y < upper
9950                && (rule.right.min(right) - rule.left.max(left)) >= width * RULE_SPAN_FRACTION
9951        });
9952        if crossed {
9953            ruled += 1;
9954        }
9955    }
9956
9957    gaps > 0
9958        && ruled >= MIN_RULED_ROW_SEPARATORS
9959        && (ruled as f64 / gaps as f64) >= MIN_RULED_GAP_SUPPORT
9960}
9961
9962fn append_column_region_blocks(
9963    bands: &[TextBand],
9964    band_indices: &[usize],
9965    boundaries: &[f64],
9966    blocks: &mut Vec<TextBlock>,
9967) {
9968    let column_count = boundaries.len() + 1;
9969    let mut column_bands = vec![Vec::<TextSpan>::new(); column_count];
9970
9971    for &band_idx in band_indices {
9972        let slices = bands[band_idx].split_by_boundaries(boundaries);
9973        for (column_idx, slice) in slices.into_iter().enumerate() {
9974            if slice.is_empty() {
9975                continue;
9976            }
9977            column_bands[column_idx].push(TextSpan::default());
9978            let marker_idx = column_bands[column_idx].len() - 1;
9979            column_bands[column_idx][marker_idx] = TextSpan {
9980                x: f64::NEG_INFINITY,
9981                y: bands[band_idx].y,
9982                ..TextSpan::default()
9983            };
9984            column_bands[column_idx].extend(slice);
9985        }
9986    }
9987
9988    for spans in column_bands {
9989        let mut current: Vec<TextSpan> = Vec::new();
9990        for span in spans {
9991            if span.x == f64::NEG_INFINITY {
9992                if !current.is_empty() {
9993                    current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
9994                    blocks.push(TextBlock {
9995                        spans: std::mem::take(&mut current),
9996                    });
9997                }
9998                continue;
9999            }
10000            current.push(span);
10001        }
10002        if !current.is_empty() {
10003            current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
10004            blocks.push(TextBlock { spans: current });
10005        }
10006    }
10007}
10008
10009/// Join per-block lines, stitching end-of-line hyphenated word-wraps the
10010/// way pdftotext / MuPDF / PDFBox do.
10011///
10012/// Trigger conditions (all must hold):
10013/// 1. Previous line ends with `-` preceded by an alphabetic character.
10014/// 2. The alphabetic suffix before the `-` has >= 3 characters.
10015/// 3. The next line (trimmed) starts with an ASCII lowercase letter.
10016/// 4. The lowercase prefix of the next line has >= 3 characters.
10017///
10018/// When triggered, the trailing `-` is removed and the two halves are
10019/// concatenated without a space or newline.
10020///
10021/// This avoids false positives on compound words ("real-time"), bullet
10022/// lists, numeric ranges ("42-"), and short fragments.
10023fn stitch_hyphenated_lines(lines: &[String]) -> String {
10024    let mut out = String::new();
10025    for (idx, line) in lines.iter().enumerate() {
10026        if idx == 0 {
10027            out.push_str(line);
10028            continue;
10029        }
10030
10031        let next_trimmed = line.trim_start();
10032
10033        // Check the accumulated output for end-of-line hyphen pattern
10034        let should_merge = is_hyphen_wrap_candidate(&out, next_trimmed);
10035
10036        if should_merge {
10037            out.pop(); // drop the trailing '-'
10038            out.push_str(next_trimmed);
10039        } else {
10040            out.push('\n');
10041            out.push_str(line);
10042        }
10043    }
10044    out
10045}
10046
10047/// Check if the accumulated text ends with a hyphen-wrap pattern and the
10048/// continuation is a valid merge target.
10049pub(crate) fn is_hyphen_wrap_candidate(accumulated: &str, next_trimmed: &str) -> bool {
10050    // Must end with '-'
10051    if !accumulated.ends_with('-') {
10052        return false;
10053    }
10054
10055    // Character before '-' must be alphabetic
10056    let before_hyphen = accumulated.chars().rev().nth(1);
10057    if !before_hyphen.is_some_and(|c| c.is_alphabetic()) {
10058        return false;
10059    }
10060
10061    // Count consecutive alphabetic chars before the '-' (the word fragment)
10062    let alpha_prefix_len = accumulated
10063        .chars()
10064        .rev()
10065        .skip(1) // skip the '-'
10066        .take_while(|c| c.is_alphabetic())
10067        .count();
10068    if alpha_prefix_len < 3 {
10069        return false;
10070    }
10071
10072    // Next line must start with lowercase ASCII
10073    let first_next = next_trimmed.chars().next();
10074    if !first_next.is_some_and(|c| c.is_ascii_lowercase()) {
10075        return false;
10076    }
10077
10078    // Count consecutive lowercase chars at start of next line
10079    let next_alpha_len = next_trimmed
10080        .chars()
10081        .take_while(|c| c.is_ascii_lowercase())
10082        .count();
10083    if next_alpha_len < 3 {
10084        return false;
10085    }
10086
10087    true
10088}
10089
10090/// Normalize extracted text to match pdftotext conventions.
10091///
10092/// 1. Trim trailing whitespace from each line.
10093/// 2. Collapse runs of more than two consecutive newlines into exactly two.
10094/// 3. Preserve form-feed characters (`\x0C`) as page separators.
10095/// 4. End with a single trailing newline (or empty for empty input).
10096pub(crate) fn normalize_text_output(text: &str) -> String {
10097    if text.is_empty() {
10098        return String::new();
10099    }
10100
10101    let mut lines: Vec<&str> = Vec::new();
10102    for line in text.split('\n') {
10103        lines.push(line.trim_end());
10104    }
10105
10106    // Remove trailing empty lines (we'll add exactly one \n at the end)
10107    while lines.last() == Some(&"") {
10108        lines.pop();
10109    }
10110
10111    if lines.is_empty() {
10112        return String::new();
10113    }
10114
10115    let mut result = String::with_capacity(text.len());
10116    let mut consecutive_empty = 0u32;
10117
10118    for (i, line) in lines.iter().enumerate() {
10119        if line.is_empty() || *line == "\x0C" {
10120            if line.is_empty() {
10121                consecutive_empty += 1;
10122                // Collapse >2 consecutive blank lines to 2
10123                if consecutive_empty <= 2 {
10124                    result.push('\n');
10125                }
10126            } else {
10127                // Bare form-feed line
10128                consecutive_empty = 0;
10129                result.push_str(line);
10130                if i + 1 < lines.len() {
10131                    result.push('\n');
10132                }
10133            }
10134        } else {
10135            // Both form-feed-prefixed and regular lines are emitted as-is.
10136            consecutive_empty = 0;
10137            result.push_str(line);
10138            if i + 1 < lines.len() {
10139                result.push('\n');
10140            }
10141        }
10142    }
10143
10144    // Ensure single trailing newline
10145    if !result.is_empty() && !result.ends_with('\n') {
10146        result.push('\n');
10147    }
10148
10149    result
10150}
10151
10152#[cfg(test)]
10153mod tests {
10154    use super::*;
10155
10156    fn span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
10157        TextSpan {
10158            text: text.into(),
10159            x,
10160            y,
10161            width,
10162            height: 12.0,
10163            font_size: 12.0,
10164            ..TextSpan::default()
10165        }
10166    }
10167
10168    /// `span` with one 6 pt glyph box per character from `x` on.
10169    fn boxed(text: &str, x: f64) -> TextSpan {
10170        let n = text.chars().count();
10171        let mut s = span(text, x, 100.0, 6.0 * n as f64);
10172        s.char_bounds = (0..n)
10173            .map(|i| {
10174                let left = x + 6.0 * i as f64;
10175                [left, 100.0, left + 6.0, 112.0]
10176            })
10177            .collect();
10178        s
10179    }
10180
10181    /// A U+20DD that reaches the line assembly is never moved or left out:
10182    /// placing a row-derived mark is the interpreter's (C4 rule 6), so a
10183    /// circle from `ToUnicode` keeps its x order here.
10184    #[test]
10185    fn a_circle_from_another_source_keeps_its_place() {
10186        let mut mark = boxed("\u{20DD}", 98.0);
10187        mark.char_bounds = vec![[98.0, 100.0, 110.0, 112.0]];
10188        mark.width = 12.0;
10189        let enclosing = TextBlock {
10190            spans: vec![mark.clone(), boxed("c", 100.0)],
10191        };
10192        assert!(
10193            enclosing.text().starts_with('\u{20DD}'),
10194            "{:?}",
10195            enclosing.text()
10196        );
10197        let lone = TextBlock { spans: vec![mark] };
10198        assert_eq!(lone.text(), "\u{20DD}");
10199    }
10200
10201    /// A row as a letter-spaced line draws it: one span per glyph, letters
10202    /// `tracking` apart, a word space drawn as a span of its own where
10203    /// `words` part (#1973). `gap` puts that much before the word instead.
10204    fn tracked_row(words: &[&str], tracking: f64, gap: Option<(usize, f64)>) -> TextBlock {
10205        let mut spans = Vec::new();
10206        let mut x = 0.0;
10207        for (w, word) in words.iter().enumerate() {
10208            if w > 0 {
10209                match gap {
10210                    Some((at, wide)) if at == w => x += wide,
10211                    _ => {
10212                        spans.push(span(" ", x, 0.0, 3.0));
10213                        x += 3.0 + tracking;
10214                    }
10215                }
10216            }
10217            for c in word.chars() {
10218                spans.push(span(&c.to_string(), x, 0.0, 8.0));
10219                x += 8.0 + tracking;
10220            }
10221        }
10222        TextBlock { spans }
10223    }
10224
10225    #[test]
10226    fn a_row_tracked_by_drawn_spaces_reads_as_its_words() {
10227        // `P O W E L L  I N D U S T R I E S`: five points of tracking on
10228        // twelve-point capitals is over the word-space threshold, so each
10229        // letter read as a word (#1973). The drawn space says where the words
10230        // part.
10231        let row = tracked_row(&["POWELL", "INDUSTRIES,", "INC."], 5.0, None);
10232        assert_eq!(row.text(), "POWELL INDUSTRIES, INC.");
10233    }
10234
10235    #[test]
10236    fn a_row_of_letters_with_no_drawn_space_is_left_alone() {
10237        // No mark says where words part: letters set apart on purpose.
10238        let row = tracked_row(&["ABCDE"], 5.0, None);
10239        assert_eq!(row.text(), "A B C D E");
10240        // Nor lower case, a formula or a listing, whatever is drawn between.
10241        let row = tracked_row(&["xyzw", "abcd"], 5.0, None);
10242        assert_eq!(row.text(), "x y z w   a b c d");
10243    }
10244
10245    #[test]
10246    fn lower_case_between_drawn_spaces_does_not_make_the_capitals_tracked() {
10247        // Capitals, lower-case letters one apart, capitals again, a space
10248        // drawn between each -- a formula's variables between two labels.
10249        // Only capitals make a tracked row, so neither space is the boundary
10250        // of one.
10251        let row = tracked_row(&["ABCD", "xyzw", "EFGH"], 5.0, None);
10252        assert_eq!(row.text(), "A B C D   x y z w   E F G H");
10253    }
10254
10255    #[test]
10256    fn a_single_letter_after_the_drawn_space_is_not_a_tracked_word() {
10257        // A figure's label `F C F C`, a space, and a one-letter word: nothing
10258        // here is a tracked line of words.
10259        let row = tracked_row(&["FCFC", "S"], 5.0, None);
10260        assert_eq!(row.text(), "F C F C   S");
10261    }
10262
10263    #[test]
10264    fn a_gap_far_wider_than_the_tracking_keeps_its_space() {
10265        // `NEW YORK` and then, with no drawn space but a wide gap, `DESK`: the
10266        // gap is a word boundary the page did not draw.
10267        let row = tracked_row(&["NEW", "YORK", "DESK"], 5.0, Some((2, 40.0)));
10268        assert_eq!(row.text(), "NEW YORK DESK");
10269    }
10270
10271    #[test]
10272    fn letter_spacing_by_space_characters_reads_as_its_words() {
10273        // The same line when the page draws it in one span, a space after
10274        // every capital and two where the words part (#1973).
10275        let mut tracked = span(
10276            "P O W E L L  I N D U S T R I E S ,  I N C .",
10277            0.0,
10278            0.0,
10279            200.0,
10280        );
10281        untrack_spaced_letters(&mut tracked);
10282        assert_eq!(tracked.text, "POWELL INDUSTRIES, INC.");
10283        // A single space everywhere says nothing about the words: left alone.
10284        let mut apart = span("A B C D E F G", 0.0, 0.0, 100.0);
10285        untrack_spaced_letters(&mut apart);
10286        assert_eq!(apart.text, "A B C D E F G");
10287        // Lower case is a formula or a listing.
10288        let mut formula = span("x y z w  a b c d", 0.0, 0.0, 100.0);
10289        untrack_spaced_letters(&mut formula);
10290        assert_eq!(formula.text, "x y z w  a b c d");
10291        // A label beside one letter is not two tracked words (codex, #1974).
10292        let mut label = span("F C F C  S", 0.0, 0.0, 100.0);
10293        untrack_spaced_letters(&mut label);
10294        assert_eq!(label.text, "F C F C  S");
10295    }
10296
10297    /// A span as the consensus leaves a tracked word: every letter a glyph
10298    /// `advance` wide and `gaps[k]` before letter `k + 1`, and a space in the
10299    /// text between each of them (#1973).
10300    fn consensus_spaced(letters: &str, advance: f64, gaps: &[f64], size: f64) -> TextSpan {
10301        let mut x = 0.0;
10302        let mut bounds = Vec::new();
10303        for (k, _) in letters.chars().enumerate() {
10304            bounds.push([x, 0.0, x + advance, size]);
10305            x += advance + gaps.get(k).copied().unwrap_or(0.0);
10306        }
10307        let text = letters
10308            .chars()
10309            .map(String::from)
10310            .collect::<Vec<_>>()
10311            .join(" ");
10312        TextSpan {
10313            char_bounds: bounds,
10314            font_size: size,
10315            ..span(&text, 0.0, 0.0, x)
10316        }
10317    }
10318
10319    #[test]
10320    fn a_short_tracked_word_reads_as_one() {
10321        // `P E N N` at 6.2 points, 0.9 apart: three gaps, never the four #582
10322        // waits for, and a new span at the word space (#1973).
10323        let mut penn = consensus_spaced("PENN", 3.0, &[0.9, 0.9, 0.9], 6.2);
10324        untrack_spaced_letters(&mut penn);
10325        assert_eq!(penn.text, "PENN");
10326        // Kerned pairs sit tighter than the tracking, and are inside the word.
10327        let mut kerned = consensus_spaced(
10328            "OUTSTANDING",
10329            30.0,
10330            &[3.0, 3.0, 3.0, 3.0, 0.6, 3.0, 3.0, 3.0, 1.5, 3.0],
10331            60.0,
10332        );
10333        untrack_spaced_letters(&mut kerned);
10334        assert_eq!(kerned.text, "OUTSTANDING");
10335    }
10336
10337    #[test]
10338    fn a_gap_wider_than_the_tracking_is_a_word_boundary() {
10339        let mut two = consensus_spaced("ABCDEF", 8.0, &[1.0, 1.0, 3.0, 1.0, 1.0], 12.0);
10340        untrack_spaced_letters(&mut two);
10341        assert_eq!(two.text, "ABC DEF");
10342        // As many word gaps as letter gaps (codex, #1980).
10343        let mut short = consensus_spaced("IAMOK", 8.0, &[3.0, 1.0, 3.0, 1.0], 12.0);
10344        untrack_spaced_letters(&mut short);
10345        assert_eq!(short.text, "I AM OK");
10346    }
10347
10348    #[test]
10349    fn a_finished_span_is_left_alone_unless_it_is_tracked_capitals() {
10350        // Set solid: the boxes touch, and the consensus's spaces are words.
10351        let mut solid = consensus_spaced("ABCDE", 8.0, &[0.0, 0.0, 0.0, 0.0], 12.0);
10352        untrack_spaced_letters(&mut solid);
10353        assert_eq!(solid.text, "A B C D E");
10354        // Lower case: a formula's variables.
10355        let mut lower = consensus_spaced("xyzwv", 8.0, &[1.0, 1.0, 1.0, 1.0], 12.0);
10356        untrack_spaced_letters(&mut lower);
10357        assert_eq!(lower.text, "x y z w v");
10358        // Nor capitals beside lower case one apart, a label and a formula.
10359        let mut mixed = consensus_spaced("ABCDxyz", 8.0, &[1.0; 6], 12.0);
10360        untrack_spaced_letters(&mut mixed);
10361        assert_eq!(mixed.text, "A B C D x y z");
10362        // A glyph more than letters: a drawn space or a ligature, whose place
10363        // in the text this cannot know.
10364        let mut drawn = consensus_spaced("ABCDE", 8.0, &[1.0, 1.0, 1.0, 1.0], 12.0);
10365        drawn.char_bounds.push([100.0, 0.0, 104.0, 12.0]);
10366        untrack_spaced_letters(&mut drawn);
10367        assert_eq!(drawn.text, "A B C D E");
10368    }
10369
10370    fn sized(text: &str, x: f64, y: f64, width: f64, size: f64) -> TextSpan {
10371        TextSpan {
10372            font_size: size,
10373            height: size,
10374            ..span(text, x, y, width)
10375        }
10376    }
10377
10378    #[test]
10379    fn a_label_drawn_again_as_its_shadow_is_one_label() {
10380        // FAA-H-8083-6 (DocLayNet train), a moving map: `KFTC` drawn twice,
10381        // the second copy 0.94 right and 0.85 lower at 4.9 points, and read
10382        // `KFTCKFTC` (#1973).
10383        let first = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
10384        let shadow = sized("KFTC", 264.13, 656.97, 12.88, 4.9);
10385        assert!(spans_are_overprint_duplicates(&first, &shadow));
10386        // A size larger, as a halo is: overlapping 77 per cent.
10387        let small = sized("NRST", 385.24, 476.85, 10.93, 4.90);
10388        let large = sized("NRST", 387.79, 477.16, 12.33, 5.52);
10389        assert!(spans_are_overprint_duplicates(&small, &large));
10390    }
10391
10392    #[test]
10393    fn a_column_head_set_twice_half_over_itself_is_one_head() {
10394        // NASDAQ_SHEN_2003 (DocLayNet): `2003` and its copy 7.99 right, 0.86
10395        // higher, overlapping 60 per cent -- read `20032003` (#623).
10396        let first = sized("2003", 346.22, 549.77, 20.16, 10.08);
10397        let copy = sized("2003", 354.21, 550.63, 20.16, 10.08);
10398        assert!(spans_are_overprint_duplicates(&first, &copy));
10399        // Less than half over each other is two words, even of one text.
10400        let left = sized("2003", 346.22, 549.77, 20.16, 10.08);
10401        let right = sized("2003", 358.0, 549.77, 20.16, 10.08);
10402        assert!(!spans_are_overprint_duplicates(&left, &right));
10403        // Two characters keep the stricter share.
10404        let a = sized("12", 100.0, 100.0, 10.0, 10.0);
10405        let b = sized("12", 104.0, 100.3, 10.0, 10.0);
10406        assert!(!spans_are_overprint_duplicates(&a, &b));
10407    }
10408
10409    #[test]
10410    fn a_shadow_needs_the_same_text_of_two_characters_close_by() {
10411        // One character: the two `1`s of a matrix, the `ll` of a word.
10412        let one = sized("1", 100.0, 100.0, 2.5, 5.0);
10413        let other = sized("1", 100.5, 100.9, 2.5, 5.0);
10414        assert!(!spans_are_overprint_duplicates(&one, &other));
10415        // Other text.
10416        let a = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
10417        let b = sized("KCLN", 264.13, 656.97, 12.88, 4.9);
10418        assert!(!spans_are_overprint_duplicates(&a, &b));
10419        // Further off than a quarter em: the next line, not a shadow.
10420        let line = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
10421        let below = sized("KFTC", 263.19, 654.50, 12.88, 4.9);
10422        assert!(!spans_are_overprint_duplicates(&line, &below));
10423    }
10424
10425    #[test]
10426    fn a_tracked_span_that_loses_its_spaces_says_what_its_letters_are() {
10427        // Rich geometry marks a span holding a drawn space an estimate: a space
10428        // has no outline. With the spaces taken out the span is as tight as
10429        // its letters (codex, #1974).
10430        let text = "A B C D  E F G H";
10431        let mut tracked = span(text, 0.0, 0.0, 100.0);
10432        tracked.glyph_bounds_sources = text
10433            .chars()
10434            .map(|c| {
10435                if c == ' ' {
10436                    BoundsSource::Estimate
10437                } else {
10438                    BoundsSource::Tight
10439                }
10440            })
10441            .collect();
10442        tracked.bounds_source = BoundsSource::Estimate;
10443        untrack_spaced_letters(&mut tracked);
10444        assert_eq!(tracked.text, "ABCD EFGH");
10445        assert_eq!(tracked.glyph_bounds_sources.len(), 9);
10446        assert_eq!(
10447            tracked.bounds_source,
10448            BoundsSource::Estimate,
10449            "the one space kept is still a space"
10450        );
10451        let mut all_tight = span("A B C D  E F G H", 0.0, 0.0, 100.0);
10452        all_tight.glyph_bounds_sources = vec![BoundsSource::Tight; 16];
10453        all_tight.bounds_source = BoundsSource::Estimate;
10454        untrack_spaced_letters(&mut all_tight);
10455        assert_eq!(all_tight.bounds_source, BoundsSource::Tight);
10456    }
10457
10458    /// A two-column academic page, the shape #429 is about: a full-width title,
10459    /// then two columns of body text whose right column ends one line short of
10460    /// the left. Every line is a realistic fraction of its column width.
10461    ///
10462    /// Returned in reading order — title, all of column one, then all of column
10463    /// two — so the test can say what the right answer is instead of guessing.
10464    ///
10465    /// The lines are prose-length on purpose. `estimated_width()` is
10466    /// `font_size * 0.5 * chars`, so a realistic line of about fifty characters
10467    /// estimates wider than it measures, and `right()` — which takes the larger
10468    /// of the two — then reaches across the gutter. Short labels would make a
10469    /// tidier fixture that never reproduces the defect.
10470    fn two_column_page() -> Vec<TextSpan> {
10471        const LEFT_X: f64 = 40.0;
10472        const RIGHT_X: f64 = 320.0;
10473        const COLUMN_WIDTH: f64 = 250.0;
10474        const TOP: f64 = 700.0;
10475        const LEADING: f64 = 14.0;
10476        const LINES: usize = 20;
10477
10478        let mut out = vec![span(
10479            "A Trace-based Just-in-Time Compiler",
10480            40.0,
10481            740.0,
10482            530.0,
10483        )];
10484        for i in 0..LINES {
10485            out.push(span(
10486                &format!("left line {i:02} of the two-column academic body text"),
10487                LEFT_X,
10488                TOP - (i as f64) * LEADING,
10489                COLUMN_WIDTH,
10490            ));
10491        }
10492        // One line short: the right column ends above the left, which leaves the
10493        // left column's last band alone on its side of the gutter. Every real
10494        // two-column page has such bands -- a heading, a column that ends early,
10495        // a figure -- and the guard this test is about used to refuse the cut
10496        // for exactly that reason.
10497        for i in 0..(LINES - 1) {
10498            out.push(span(
10499                &format!("right line {i:02} of the two-column academic body text"),
10500                RIGHT_X,
10501                TOP - (i as f64) * LEADING,
10502                COLUMN_WIDTH,
10503            ));
10504        }
10505        out
10506    }
10507
10508    /// #429: a two-column body is read column by column.
10509    ///
10510    /// The whole page in one assertion, because reading order is a property of
10511    /// the whole page and a per-block check would pass while the columns were
10512    /// interleaved. Revert either half of the fix and this goes red: with the
10513    /// gap scan on `right()` no gutter is found at all, and with the old
10514    /// band-aligned guard the cut is refused once it is.
10515    #[test]
10516    fn a_two_column_body_is_read_column_by_column() {
10517        let spans = two_column_page();
10518        let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
10519
10520        let produced: Vec<String> = group_spans_into_blocks(spans, &[])
10521            .into_iter()
10522            .flat_map(|b| b.spans.into_iter().map(|s| s.text))
10523            .collect();
10524
10525        assert_eq!(produced, expected);
10526    }
10527
10528    /// A JOURNAL GUTTER IS NARROWER THAN FOUR PERCENT OF THE PAGE (#389).
10529    ///
10530    /// The shape that the region-width rail refused: 9 pt type, a 493 pt text
10531    /// block, and the 13.2 pt gutter tracemonkey is actually set with. Four
10532    /// percent of that block is 19.7 pt, which is wider than the gutter of any
10533    /// journal, and the cut was refused on pages 2 and 13 of tracemonkey,
10534    /// highlights and tracemonkey_with_editable_annotations -- six of the eleven
10535    /// pages left under 95 % order agreement on the gate set.
10536    ///
10537    /// Returned in reading order, so the test says what the right answer is.
10538    fn narrow_gutter_page() -> Vec<TextSpan> {
10539        narrow_gutter_page_with_gutter(13.2)
10540    }
10541
10542    /// The same page with the gutter set by the caller, so a test can ask for a
10543    /// gap that is not a gutter.
10544    fn narrow_gutter_page_with_gutter(gutter: f64) -> Vec<TextSpan> {
10545        const LEFT_X: f64 = 54.0;
10546        const COLUMN_WIDTH: f64 = 240.0;
10547        const FONT_SIZE: f64 = 9.0;
10548        const TOP: f64 = 700.0;
10549        const LEADING: f64 = 11.0;
10550        const LINES: usize = 20;
10551
10552        let right_x = LEFT_X + COLUMN_WIDTH + gutter;
10553        let line = |text: &str, x: f64, y: f64| TextSpan {
10554            text: text.into(),
10555            x,
10556            y,
10557            width: COLUMN_WIDTH,
10558            height: FONT_SIZE,
10559            font_size: FONT_SIZE,
10560            ..TextSpan::default()
10561        };
10562
10563        let mut out = vec![TextSpan {
10564            text: "Trace-based Just-in-Time Type Specialization".into(),
10565            x: LEFT_X,
10566            y: 740.0,
10567            width: 490.0,
10568            height: 14.0,
10569            font_size: 14.0,
10570            ..TextSpan::default()
10571        }];
10572        // PROSE-LENGTH LINES, for the same reason `two_column_page` gives them:
10573        // `estimated_width()` is font_size * 0.5 * chars, so a real line of
10574        // sixty characters overshoots its measured width and the overshoot
10575        // swallows the gutter -- which is how a page ends up with no measurable
10576        // word gaps at all and the adaptive threshold falls back to 20 pt.
10577        for i in 0..LINES {
10578            out.push(line(
10579                &format!("left line {i:02} of the two-column academic body of this paper"),
10580                LEFT_X,
10581                TOP - (i as f64) * LEADING,
10582            ));
10583        }
10584        for i in 0..(LINES - 1) {
10585            out.push(line(
10586                &format!("right line {i:02} of the two-column academic body of this paper"),
10587                right_x,
10588                TOP - (i as f64) * LEADING,
10589            ));
10590        }
10591        out
10592    }
10593
10594    /// Red the moment a gutter has to be a fraction of the region again.
10595    ///
10596    /// Asked of `try_vertical_cut` itself, so that it names the cut and not the
10597    /// page: the reading order below is the consequence and this is the cause.
10598    #[test]
10599    fn a_thirteen_point_gutter_in_nine_point_type_is_a_gutter() {
10600        let spans = narrow_gutter_page();
10601        let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
10602        let stats = PageStats::from_spans(&body);
10603
10604        let (groups, gap) = try_vertical_cut(&body, &stats, &[])
10605            .expect("no column cut found on a 13.2 pt gutter in 9 pt type");
10606
10607        assert_eq!(groups.len(), 2);
10608        assert!(
10609            (gap - 13.2).abs() < 0.5,
10610            "the cut was taken on a gap of {gap}, not on the gutter"
10611        );
10612        assert!(
10613            groups[0].iter().all(|s| s.text.starts_with("left")),
10614            "the left group holds text from the right column"
10615        );
10616        assert!(
10617            groups[1].iter().all(|s| s.text.starts_with("right")),
10618            "the right group holds text from the left column"
10619        );
10620    }
10621
10622    /// And the whole page, in reading order, through the path a document takes.
10623    /// Red with the rail back as well: the leaf's own band-by-band gutter
10624    /// detector does not rescue a page whose lines overshoot their measured
10625    /// width, which is every page set in a proportional face.
10626    #[test]
10627    fn the_narrow_gutter_page_reads_column_by_column() {
10628        let spans = narrow_gutter_page();
10629        let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
10630
10631        let produced: Vec<String> = group_spans_into_blocks(spans, &[])
10632            .into_iter()
10633            .flat_map(|b| b.spans.into_iter().map(|s| s.text))
10634            .collect();
10635
10636        assert_eq!(produced, expected);
10637    }
10638
10639    /// And the floor still does its work: the same page whose columns are six
10640    /// points apart is not two columns, because six points is a word space in
10641    /// nine point type. Red if the floor is dropped along with the rail.
10642    #[test]
10643    fn a_gap_narrower_than_the_type_is_not_a_gutter() {
10644        let spans = narrow_gutter_page_with_gutter(6.0);
10645        let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
10646        let stats = PageStats::from_spans(&body);
10647
10648        assert!(
10649            try_vertical_cut(&body, &stats, &[]).is_none(),
10650            "a six point gap in nine point type was taken for a column gutter"
10651        );
10652    }
10653
10654    fn nine(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
10655        TextSpan {
10656            height: 9.0,
10657            font_size: 9.0,
10658            ..span(text, x, y, width)
10659        }
10660    }
10661
10662    /// Two columns whose text is 23 pt apart, with the right column's bullets
10663    /// hung 8 pt from the left column's measure and the space after each
10664    /// drawn as a run of its own (NYSE_MSA, #574). Counted as ink, they leave
10665    /// no x free of every span.
10666    fn columns_with_hung_markers() -> Vec<TextSpan> {
10667        let mut out = Vec::new();
10668        for i in 0..12 {
10669            let y = 700.0 - 12.0 * i as f64;
10670            out.push(nine(
10671                &format!("left line {i:02} of the running text of this annual report"),
10672                72.0,
10673                y,
10674                225.0,
10675            ));
10676            if i % 4 == 0 {
10677                out.push(nine("t", 305.0, y, 3.3));
10678                out.push(nine("  ", 308.3, y, 5.0));
10679            }
10680            out.push(nine(
10681                &format!("right line {i:02} of the running text of this annual report"),
10682                320.0,
10683                y,
10684                225.0,
10685            ));
10686        }
10687        out
10688    }
10689
10690    #[test]
10691    fn a_gutter_is_found_past_the_markers_hung_into_it() {
10692        let body = columns_with_hung_markers();
10693        let stats = PageStats::from_spans(&body);
10694        let (groups, _) = try_vertical_cut(&body, &stats, &[])
10695            .expect("no cut past the hung markers and their space runs");
10696        assert_eq!(groups.len(), 2);
10697        assert!(
10698            groups[0].iter().all(|s| s.text.starts_with("left")),
10699            "a hung marker went to the left column"
10700        );
10701    }
10702
10703    /// The looking-past is for columns of text. NYSE_JWN's exhibit index has a
10704    /// right column of `Filed herewith electronically` on every row and one
10705    /// space drawn in the strip on its head row; looked past, the strip is
10706    /// white, and cutting there read every filing method after every exhibit.
10707    #[test]
10708    fn a_column_of_cells_found_past_a_space_is_not_cut_off() {
10709        let mut body = vec![
10710            nine("Exhibit", 192.0, 728.0, 28.0),
10711            nine(" ", 326.0, 728.0, 2.0),
10712            nine("Method of Filing", 413.0, 728.0, 63.0),
10713        ];
10714        for i in 0..10 {
10715            let y = 713.0 - 18.0 * i as f64;
10716            if i % 3 == 2 {
10717                // `101.SCH XBRL Taxonomy ...`: number and description one run,
10718                // across the white that would otherwise part them.
10719                body.push(nine(
10720                    "101.SCH XBRL Taxonomy Extension Schema Document",
10721                    52.0,
10722                    y,
10723                    213.0,
10724                ));
10725            } else {
10726                body.push(nine(&format!("{i}.1"), 52.0, y, 16.0));
10727                body.push(nine(
10728                    "Certification of Chief Financial Officer required",
10729                    92.0,
10730                    y,
10731                    226.0,
10732                ));
10733            }
10734            body.push(nine("Filed herewith electronically", 330.0, y, 100.0));
10735        }
10736        let stats = PageStats::from_spans(&body);
10737        assert!(
10738            try_vertical_cut(&body, &stats, &[]).is_none(),
10739            "the cell column of a table was cut off as a column of text"
10740        );
10741    }
10742
10743    /// The last glyph of a line is not a marker hung into the gutter (codex,
10744    /// #2041). A left column whose lines close on `)` in a run of their own,
10745    /// 9 pt from the right column: looked past, the `)` would open a 15 pt
10746    /// strip and take a gutter the page does not have.
10747    #[test]
10748    fn a_closing_glyph_is_not_a_hung_marker() {
10749        let mut body = Vec::new();
10750        for i in 0..10 {
10751            let y = 700.0 - 12.0 * i as f64;
10752            // Ragged, so the closing glyphs stand at no one x.
10753            let width = [221.0, 204.0, 213.0, 196.0, 218.0][i % 5];
10754            body.push(nine(
10755                &format!("left line {i:02} of the running text of this annual report (see"),
10756                72.0,
10757                y,
10758                width,
10759            ));
10760            body.push(nine(")", 72.0 + width + 1.0, y, 3.0));
10761            body.push(nine(
10762                &format!("right line {i:02} of the running text of this annual report"),
10763                305.0,
10764                y,
10765                225.0,
10766            ));
10767        }
10768        let stats = PageStats::from_spans(&body);
10769        assert!(
10770            try_vertical_cut(&body, &stats, &[]).is_none(),
10771            "a line's closing glyph was looked past as a hung marker"
10772        );
10773    }
10774
10775    /// A statement whose cells are parted by tab runs is not two columns of
10776    /// text (DocLayNet validation, #574). Every gap between its amounts holds
10777    /// a `\t` drawn as a run of its own; looked past as word spaces they left
10778    /// every column gap clear, and rows of figures as long as a line of prose
10779    /// passed for running text -- the statement was cut down its middle. A
10780    /// column of text is counted in letters.
10781    #[test]
10782    fn a_statement_parted_by_tab_runs_is_not_cut() {
10783        let mut body = Vec::new();
10784        for i in 0..10 {
10785            let y = 700.0 - 12.0 * i as f64;
10786            body.push(nine(
10787                &format!("Corporate, other and eliminations ({i:02})"),
10788                15.0,
10789                y,
10790                106.0,
10791            ));
10792            let mut x = 125.0;
10793            for cell in [
10794                "$ 438,082",
10795                "$ 9,836,950",
10796                "$ 3,304,152",
10797                "$ 5,919,819",
10798                "$ 429,405",
10799                "$ 3,692,551",
10800                "$ 840,333",
10801                "$ 1,049,328",
10802                "$ 6,086,009",
10803            ] {
10804                body.push(nine(cell, x, y, 38.0));
10805                body.push(nine("\t", x + 44.0, y, 2.4));
10806                x += 51.0;
10807            }
10808        }
10809        let stats = PageStats::from_spans(&body);
10810        assert!(
10811            try_vertical_cut(&body, &stats, &[]).is_none(),
10812            "a statement parted by tab runs was cut as two columns of text"
10813        );
10814    }
10815
10816    /// A `–` for nil down a column of amounts is a cell, not a list's marker
10817    /// (DocLayNet validation, #574): read as a list the statement's figures
10818    /// lost their columns.
10819    #[test]
10820    fn a_column_of_nil_dashes_is_not_a_list() {
10821        let mut spans = Vec::new();
10822        for i in 0..5 {
10823            let y = 700.0 - 14.0 * i as f64;
10824            spans.push(nine("\u{2013}", 300.0, y, 5.0));
10825            spans.push(nine(&format!("22,795,60{i}"), 360.0, y, 40.0));
10826            spans.push(nine(&format!("22,788,10{i}"), 430.0, y, 40.0));
10827        }
10828        assert!(
10829            !is_bullet_or_list_marker("22,795,604"),
10830            "the fixture's figures read as markers"
10831        );
10832        let bands = group_spans_into_bands(spans);
10833        let indices: Vec<usize> = (0..bands.len()).collect();
10834        let boundaries = vec![330.0, 415.0];
10835        let gaps = indices.len();
10836        // A column of dashes beside figures is still columnar.
10837        assert!(
10838            region_is_columnar(&bands, &indices, &boundaries, gaps),
10839            "a column of nil dashes beside amounts was taken for a list"
10840        );
10841    }
10842
10843    /// Rows of amounts count as rows with no letters (codex, #2045): left out
10844    /// of the sample, one long header on each side would stand for a whole
10845    /// statement of figures and pass it off as two columns of text.
10846    #[test]
10847    fn rows_of_amounts_keep_a_statement_from_reading_as_text() {
10848        let mut body = vec![
10849            nine(
10850                "Consolidated statement of financial position for the year",
10851                15.0,
10852                712.0,
10853                240.0,
10854            ),
10855            nine(
10856                "Consolidated statement of comprehensive income for the year",
10857                300.0,
10858                712.0,
10859                240.0,
10860            ),
10861        ];
10862        for i in 0..9 {
10863            let y = 700.0 - 12.0 * i as f64;
10864            let mut x = 15.0;
10865            for cell in [
10866                "438,082",
10867                "9,836,950",
10868                "3,304,152",
10869                "5,919,819",
10870                "429,405",
10871                "3,692,551",
10872                "840,333",
10873                "1,049,328",
10874                "6,086,009",
10875                "1,360,471",
10876            ] {
10877                body.push(nine(cell, x, y, 44.0));
10878                body.push(nine("\t", x + 48.0, y, 2.4));
10879                x += 54.0;
10880            }
10881        }
10882        let stats = PageStats::from_spans(&body);
10883        assert!(
10884            try_vertical_cut(&body, &stats, &[]).is_none(),
10885            "a statement of figures under two long headers was cut as two columns of text"
10886        );
10887    }
10888
10889    /// An item may open with a quotation mark or a figure (codex, #2045):
10890    /// `“Revenue increased”` beside a column of bullets is still an item, and
10891    /// the bullets are still a list's.
10892    #[test]
10893    fn an_item_that_opens_with_punctuation_keeps_its_bullet() {
10894        let sized = |text: &str, x: f64, y: f64, width: f64, size: f64| TextSpan {
10895            height: size,
10896            font_size: size,
10897            ..span(text, x, y, width)
10898        };
10899        let mut spans = vec![sized(
10900            "The following items were reported during the course of the Financial Year:",
10901            86.8,
10902            720.0,
10903            313.3,
10904            10.2,
10905        )];
10906        for (k, (item, width)) in [
10907            ("\u{201c}Revenue increased\u{201d} across the group;", 190.0),
10908            ("(Formerly the Northern Division) was sold;", 180.0),
10909            ("2024 results were restated for the change.", 175.0),
10910            (
10911                "\u{201c}Margins improved\u{201d} in the second half.",
10912                170.0,
10913            ),
10914        ]
10915        .iter()
10916        .enumerate()
10917        {
10918            let y = 685.6 - 34.4 * k as f64;
10919            spans.push(sized("\u{2022}", 104.9, y - 4.5, 13.0, 19.0));
10920            spans.push(sized(item, 131.5, y, *width, 10.2));
10921        }
10922        for k in 0..4 {
10923            spans.push(sized(
10924                "Other than detailed in the Chairman's Report set out at pages 1 and 2 of this report",
10925                86.8,
10926                548.0 - 17.2 * k as f64,
10927                394.8,
10928                10.2,
10929            ));
10930        }
10931        let blocks = group_spans_into_blocks(spans, &[]);
10932        let bare = blocks
10933            .iter()
10934            .filter(|b| b.text().trim() == "\u{2022}")
10935            .count();
10936        assert_eq!(
10937            bare,
10938            0,
10939            "items opening with punctuation lost their bullets: {:?}",
10940            blocks.iter().map(|b| b.text()).collect::<Vec<_>>()
10941        );
10942    }
10943
10944    /// A column of bullets is a list, not a column (#574).
10945    ///
10946    /// ASX_MRM's spans as the engine reads them: a 19 pt bullet 13 pt before
10947    /// each 10 pt item, its baseline 4.5 pt under the item's, and body lines
10948    /// round it. Every band of the list has a gap at one x, and read as two
10949    /// columns the list came back as four bullets and then four items.
10950    #[test]
10951    fn a_column_of_bullets_is_read_with_its_items() {
10952        let sized = |text: &str, x: f64, y: f64, width: f64, size: f64| TextSpan {
10953            height: size,
10954            font_size: size,
10955            ..span(text, x, y, width)
10956        };
10957        let mut spans = vec![
10958            sized(
10959                "Mermaid's principal activities during the course of the Financial Year were:",
10960                86.8,
10961                720.0,
10962                313.3,
10963                10.2,
10964            ),
10965            sized("PRINCIPAL ACTIVITIES", 416.8, 718.6, 123.3, 9.1),
10966        ];
10967        for (k, (item, width)) in [
10968            ("Operating crewed vessel charters;", 138.4),
10969            ("Vessel manning, management and logistics;", 179.8),
10970            ("Operating supply base facilities; and", 149.1),
10971            ("Equipment hire.", 67.1),
10972        ]
10973        .iter()
10974        .enumerate()
10975        {
10976            let y = 685.6 - 34.4 * k as f64;
10977            spans.push(sized("\u{2022}", 104.9, y - 4.5, 13.0, 19.0));
10978            spans.push(sized(item, 131.5, y, *width, 10.2));
10979        }
10980        for k in 0..4 {
10981            spans.push(sized(
10982                "Other than detailed in the Chairman's Report set out at pages 1 and 2 of this report",
10983                86.8,
10984                548.0 - 17.2 * k as f64,
10985                394.8,
10986                10.2,
10987            ));
10988        }
10989        let blocks = group_spans_into_blocks(spans, &[]);
10990        let bare = blocks
10991            .iter()
10992            .filter(|b| b.text().trim() == "\u{2022}")
10993            .count();
10994        assert_eq!(
10995            bare,
10996            0,
10997            "the bullets were read as a column of their own: {:?}",
10998            blocks.iter().map(|b| b.text()).collect::<Vec<_>>()
10999        );
11000    }
11001
11002    /// A line number peeled off the line it was drawn into leaves a line whose
11003    /// transform still locates its own first glyph (codex, #1991).
11004    #[test]
11005    fn a_peeled_line_keeps_its_origin_on_its_first_glyph() {
11006        let glued = TextSpan {
11007            text: "10 example, aluminum and steel".into(),
11008            x: 298.8,
11009            y: 100.0,
11010            width: 229.4,
11011            height: 8.5,
11012            font_size: 8.5,
11013            transform: Some([8.5, 0.0, 0.0, 8.5, 298.8, 100.0]),
11014            char_bounds: std::iter::once([298.8, 100.0, 302.0, 108.5])
11015                .chain(std::iter::once([302.0, 100.0, 305.2, 108.5]))
11016                .chain((0..27).map(|k| {
11017                    let x = 310.9 + k as f64 * 4.0;
11018                    [x, 100.0, x + 4.0, 108.5]
11019                }))
11020                .collect(),
11021            ..TextSpan::default()
11022        };
11023        let mut spans = vec![glued];
11024        for i in 0..3 {
11025            spans.push(TextSpan {
11026                text: "a line of the right column".into(),
11027                x: 310.9,
11028                y: 90.0 - i as f64 * 10.0,
11029                width: 200.0,
11030                height: 8.5,
11031                font_size: 8.5,
11032                ..TextSpan::default()
11033            });
11034        }
11035        let (figure, rest) = peel_one(&spans[0], 0, &spans).expect("the figure is peeled");
11036        assert_eq!(figure.text, "10");
11037        assert_eq!(rest.text, "example, aluminum and steel");
11038        assert!((rest.x - 310.9).abs() < 1e-9);
11039        let t = rest.transform.expect("a transform");
11040        assert!(
11041            (t[4] - rest.x).abs() < 1e-9 && (t[5] - 100.0).abs() < 1e-9,
11042            "the rest's transform still points at the figure: {t:?}"
11043        );
11044    }
11045
11046    /// A column the margin scan finds is a column of line numbers only when
11047    /// every member it found is a figure (codex, #1991): `i`, `1`, `ii` down a
11048    /// list is a list's markers, and one digit among them numbers no page.
11049    #[test]
11050    fn a_margin_column_of_mixed_labels_is_not_figures() {
11051        let band = |y: f64, label: &str| {
11052            let mut b = TextBand::new(TextSpan {
11053                text: "a line of the left column running its measure".into(),
11054                x: 77.0,
11055                y,
11056                width: 200.0,
11057                height: 9.0,
11058                font_size: 9.0,
11059                ..TextSpan::default()
11060            });
11061            b.spans.push(TextSpan {
11062                text: label.into(),
11063                x: 288.0,
11064                y,
11065                width: 6.0,
11066                height: 9.0,
11067                font_size: 9.0,
11068                ..TextSpan::default()
11069            });
11070            b.spans.push(TextSpan {
11071                text: "a line of the right column running its measure".into(),
11072                x: 300.0,
11073                y,
11074                width: 200.0,
11075                height: 9.0,
11076                font_size: 9.0,
11077                ..TextSpan::default()
11078            });
11079            b
11080        };
11081        let column = |labels: &[&str]| {
11082            let bands: Vec<TextBand> = labels
11083                .iter()
11084                .enumerate()
11085                .map(|(i, l)| band(700.0 - i as f64 * 11.0, l))
11086                .collect();
11087            a_margin_column(&bands, 423.0, 10.0, 2.5)
11088        };
11089        let (skipped, figures) = column(&["5", "10", "15", "20"]);
11090        assert!(figures && skipped.iter().all(|b| b.len() == 1));
11091        let (_, figures) = column(&["i", "1", "ii", "iii"]);
11092        assert!(!figures, "a mixed column was read as a column of figures");
11093    }
11094
11095    /// Beside a bank of figures, the gutter's column is still the margin when it
11096    /// COUNTS the lines, and not when it is a table's row numbers (#574).
11097    #[test]
11098    fn a_counting_column_beside_a_bank_of_figures_is_the_margin() {
11099        let span = |text: &str, x: f64, width: f64, y: f64| TextSpan {
11100            text: text.into(),
11101            x,
11102            y,
11103            width,
11104            height: 9.0,
11105            font_size: 9.0,
11106            ..TextSpan::default()
11107        };
11108        let band = |y: f64, figure: &str, label: &str| {
11109            let mut b = TextBand::new(span("a line of the", 77.0, 60.0, y));
11110            b.spans.push(span(figure, 150.0, 12.0, y));
11111            b.spans
11112                .push(span("left column running its measure", 170.0, 107.0, y));
11113            b.spans.push(span(label, 288.0, 6.0, y));
11114            b.spans.push(span(
11115                "a line of the right column running its measure",
11116                300.0,
11117                200.0,
11118                y,
11119            ));
11120            b
11121        };
11122        let column = |labels: &[&str]| {
11123            let bands: Vec<TextBand> = ["0.0", "4.0", "64.9", "3.8"]
11124                .iter()
11125                .zip(labels)
11126                .enumerate()
11127                .map(|(i, (f, l))| band(700.0 - i as f64 * 11.0, f, l))
11128                .collect();
11129            a_margin_column(&bands, 423.0, 10.0, 2.5)
11130        };
11131        let (skipped, figures) = column(&["5", "10", "15", "20"]);
11132        assert!(
11133            figures && skipped.iter().all(|b| b.len() == 1 && b.contains(&3)),
11134            "the line numbers beside a bank were not the margin: {skipped:?}"
11135        );
11136        let (skipped, _) = column(&["1", "2", "3", "4"]);
11137        assert!(
11138            skipped.iter().all(HashSet::is_empty),
11139            "a table's row numbers were taken for line numbers: {skipped:?}"
11140        );
11141        let (skipped, _) = column(&["2018", "2020", "2022", "2024"]);
11142        assert!(
11143            skipped.iter().all(HashSet::is_empty),
11144            "a column of years was taken for line numbers: {skipped:?}"
11145        );
11146    }
11147
11148    /// A span the gap scan skipped is not a cell boundary of a bank (#582).
11149    ///
11150    /// The caller that skips a thin column standing IN a gutter -- printed line
11151    /// numbers down the middle of a statute, which occupy every x of the gutter
11152    /// and make the cut unfindable -- has to say so here too. Counted as ink, a
11153    /// column beside the gutter puts a wide strip of white between itself and
11154    /// the rest of its side on every band, which is exactly the shape
11155    /// `the_cut_is_a_column_of_a_table` refuses a cut for: the guard would
11156    /// refuse the cut that skipping the column was meant to allow.
11157    #[test]
11158    fn a_span_the_scan_skipped_is_not_a_cell_boundary() {
11159        // Four bands. Each carries a line of the left column, a line number
11160        // sitting in the gutter, and a line of the right column.
11161        let mut spans = Vec::new();
11162        for band in 0..4 {
11163            let y = 700.0 - band as f64 * 14.0;
11164            spans.push(span("the committee considered the whole", 72.0, y, 150.0));
11165            spans.push(span("12", 240.0, y, 10.0));
11166            spans.push(span("of the proposals put before it and", 300.0, y, 150.0));
11167        }
11168        let stats = PageStats::from_spans(&spans);
11169        let bands = group_spans_into_bands_with_stats(spans.clone(), &stats);
11170        let cut_x = 270.0;
11171        let gap = 50.0;
11172
11173        // With the line numbers counted as ink, the left side of the cut holds
11174        // two runs a band -- the text and the number -- with 18 points of white
11175        // between them, and each side reads as a bank of cells.
11176        let counted = one_side_of_a_cut(&bands, cut_x, true, cut_x - 72.0, 18.0, &[])
11177            .expect("the left side has ink on every band");
11178        assert_eq!(
11179            (counted.inked, counted.broken),
11180            (4, 4),
11181            "the line numbers are not being read as a second run of ink"
11182        );
11183
11184        // Skipped, the side is one run of ink a band and breaks nowhere, so the
11185        // guard has nothing to refuse the cut for.
11186        // One set per band, which is the whole point: the index of the line
11187        // number is not the same number on every band.
11188        let skipped: Vec<HashSet<usize>> = bands
11189            .iter()
11190            .map(|band| {
11191                band.spans
11192                    .iter()
11193                    .enumerate()
11194                    .filter(|(_, s)| s.text.trim() == "12")
11195                    .map(|(i, _)| i)
11196                    .collect()
11197            })
11198            .collect();
11199        let without = one_side_of_a_cut(&bands, cut_x, true, cut_x - 72.0, 18.0, &skipped)
11200            .expect("the left side still has ink on every band");
11201        assert_eq!(
11202            (without.inked, without.broken),
11203            (4, 0),
11204            "a span the scan skipped is still being measured as a cell boundary"
11205        );
11206        assert!(
11207            !the_cut_is_a_column_of_a_table(&bands, cut_x, gap, &skipped),
11208            "the cut was refused for a column the scan had already skipped"
11209        );
11210    }
11211
11212    /// The gutter is only there in the MEASURED edges. `right()` returns
11213    /// x + width.max(estimated_width()), and on tracemonkey's abstract that
11214    /// estimate overshoots the measured edge by 61pt -- wider than the gutter
11215    /// it then covers, so the scan finds no corridor and the guards never run.
11216    #[test]
11217    fn a_column_gap_is_found_on_measured_edges_not_estimated_ones() {
11218        let spans = two_column_page();
11219        let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
11220        let stats = PageStats::from_spans(&body);
11221
11222        let overshoots = body.iter().any(|s| s.right() > s.measured_right() + 1.0);
11223        assert!(
11224            overshoots,
11225            "fixture no longer exercises the overshoot; right() must exceed measured_right()              for this test to mean anything"
11226        );
11227
11228        let cut = try_vertical_cut(&body, &stats, &[]);
11229        assert!(
11230            cut.is_some(),
11231            "no column cut found on a clean two-column body"
11232        );
11233    }
11234
11235    fn block_texts(spans: Vec<TextSpan>) -> Vec<String> {
11236        block_texts_with_rules(spans, &[])
11237    }
11238
11239    /// The same, with the horizontal rules the page drew (#545).
11240    fn block_texts_with_rules(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<String> {
11241        group_spans_into_blocks(spans, rules)
11242            .into_iter()
11243            .map(|block| block.text())
11244            .collect()
11245    }
11246
11247    /// The same, but only the leaf of the XY-cut: the bands as
11248    /// `group_spans_into_blocks_legacy_from_bands` sees them.
11249    ///
11250    /// Some of the shapes below are refused by the leaf and taken by the column
11251    /// cut above it instead, which proves nothing about the leaf. This says what
11252    /// the leaf does with them.
11253    fn leaf_texts(spans: Vec<TextSpan>) -> Vec<String> {
11254        let bands = group_spans_into_bands(spans);
11255        group_spans_into_blocks_legacy_from_bands(bands, &[])
11256            .iter()
11257            .map(TextBlock::text)
11258            .collect()
11259    }
11260
11261    #[test]
11262    fn empty_device_produces_empty_text() {
11263        let dev = TextExtractionDevice::new();
11264        assert!(dev.into_text().is_empty());
11265    }
11266
11267    #[test]
11268    fn single_column_stays_row_major() {
11269        let texts = block_texts(vec![
11270            span("Single Column Line 1", 40.0, 700.0, 140.0),
11271            span("Single Column Line 2", 40.0, 684.0, 140.0),
11272            span("Single Column Line 3", 40.0, 668.0, 140.0),
11273        ]);
11274
11275        assert_eq!(
11276            texts,
11277            vec![
11278                "Single Column Line 1",
11279                "Single Column Line 2",
11280                "Single Column Line 3",
11281            ]
11282        );
11283    }
11284
11285    /// A header, three bands each of which draws two slices with a gutter
11286    /// between them, and a footer. Read as columns unless the page says
11287    /// otherwise, which is what the three tests after this one are about.
11288    fn two_column_region_spans() -> Vec<TextSpan> {
11289        vec![
11290            span("Header", 200.0, 740.0, 80.0),
11291            span("Left column line one", 40.0, 700.0, 115.0),
11292            span("Right column line one", 320.0, 700.0, 120.0),
11293            span("Left column line two", 40.0, 684.0, 115.0),
11294            span("Right column line two", 320.0, 684.0, 120.0),
11295            span("Left column line three", 40.0, 668.0, 125.0),
11296            span("Right column line three", 320.0, 668.0, 130.0),
11297            span("Footer", 200.0, 620.0, 80.0),
11298        ]
11299    }
11300
11301    #[test]
11302    fn two_column_region_reads_column_major() {
11303        let texts = block_texts(two_column_region_spans());
11304
11305        assert_eq!(
11306            texts,
11307            vec![
11308                "Header",
11309                "Left column line one",
11310                "Left column line two",
11311                "Left column line three",
11312                "Right column line one",
11313                "Right column line two",
11314                "Right column line three",
11315                "Footer",
11316            ]
11317        );
11318    }
11319
11320    /// The rules the page draws between its rows, for the fixture above: a
11321    /// horizontal line across the whole table, in every gap between two rows.
11322    fn rules_between_the_rows() -> Vec<HorizontalRule> {
11323        [692.0, 676.0]
11324            .into_iter()
11325            .map(|y| HorizontalRule {
11326                left: 35.0,
11327                right: 445.0,
11328                y,
11329            })
11330            .collect()
11331    }
11332
11333    /// The same three bands that read column by column above, with a ruling
11334    /// line between each pair of them, read across instead (#545). This is the
11335    /// Isartor appendix: two columns of text that are the two cells of a row,
11336    /// and nothing but the page's own graphics says so.
11337    #[test]
11338    fn a_ruled_two_column_region_is_read_row_by_row() {
11339        let texts = block_texts_with_rules(two_column_region_spans(), &rules_between_the_rows());
11340
11341        assert_eq!(
11342            texts,
11343            vec![
11344                "Header",
11345                "Left column line one Right column line one",
11346                "Left column line two Right column line two",
11347                "Left column line three Right column line three",
11348                "Footer",
11349            ]
11350        );
11351    }
11352
11353    /// A rule under one cell is not a rule between two rows. Widening the test
11354    /// to any line that happens to sit in the gap turns every underlined cell
11355    /// into a table, so the rule has to cross the region it claims to divide.
11356    #[test]
11357    fn a_rule_that_crosses_only_one_column_leaves_the_columns_alone() {
11358        let short: Vec<HorizontalRule> = rules_between_the_rows()
11359            .into_iter()
11360            .map(|rule| HorizontalRule {
11361                right: 160.0,
11362                ..rule
11363            })
11364            .collect();
11365
11366        let texts = block_texts_with_rules(two_column_region_spans(), &short);
11367
11368        assert_eq!(
11369            texts,
11370            vec![
11371                "Header",
11372                "Left column line one",
11373                "Left column line two",
11374                "Left column line three",
11375                "Right column line one",
11376                "Right column line two",
11377                "Right column line three",
11378                "Footer",
11379            ]
11380        );
11381    }
11382
11383    /// One rule is a headline's underline, a footnote's separator, the line
11384    /// above a page number -- every document has them, and none of them makes
11385    /// the text below a table.
11386    ///
11387    /// Asked of the predicate rather than of the page, because a region of
11388    /// three bands with one rule in it is refused by the column cut above the
11389    /// leaf for its own reasons, and a page-level assertion would be green
11390    /// whatever this rule said.
11391    #[test]
11392    fn a_single_rule_does_not_make_a_region_a_table() {
11393        let bands = group_spans_into_bands(two_column_region_spans());
11394        let rows: Vec<usize> = bands
11395            .iter()
11396            .enumerate()
11397            .filter(|(_, band)| band.y < 720.0 && band.y > 640.0)
11398            .map(|(idx, _)| idx)
11399            .collect();
11400        assert_eq!(
11401            rows.len(),
11402            3,
11403            "the fixture's three rows are the region here"
11404        );
11405
11406        let both = rules_between_the_rows();
11407        assert!(
11408            region_is_ruled(&bands, &rows, &both),
11409            "a rule in every gap of the region is a ruled table"
11410        );
11411        assert!(
11412            !region_is_ruled(&bands, &rows, &both[..1]),
11413            "one rule is not a table"
11414        );
11415    }
11416
11417    /// One path may hold every rule a table draws, so a cap asked once per
11418    /// `draw_path` bounds nothing. The vector it would grow is walked per band
11419    /// by `region_is_ruled`, which is why the bound is on the vector and not on
11420    /// the call.
11421    #[test]
11422    fn one_path_of_many_rules_still_stops_at_the_cap() {
11423        let mut path = BezPath::new();
11424        for row in 0..(MAX_RECORDED_RULES * 2) {
11425            let y = 700.0 - row as f64;
11426            path.move_to((40.0, y));
11427            path.line_to((440.0, y));
11428            path.close_path();
11429        }
11430
11431        let mut device = TextExtractionDevice::new();
11432        device.record_rule(&path, Affine::IDENTITY);
11433
11434        assert_eq!(device.rules.len(), MAX_RECORDED_RULES);
11435    }
11436
11437    /// A Form XObject's `/BBox` reaches this device as a clip, with the
11438    /// geometry inside it unclipped. A line ruled across one cell of such a
11439    /// form therefore arrives as a line across the page, and two of those are
11440    /// enough to refuse a column cut that should have been taken.
11441    #[test]
11442    fn a_rule_is_recorded_at_the_width_the_clip_leaves_it() {
11443        let mut wide = BezPath::new();
11444        wide.move_to((40.0, 600.0));
11445        wide.line_to((440.0, 600.0));
11446
11447        let mut device = TextExtractionDevice::new();
11448        device.push_clip_path(&ClipPath {
11449            path: kurbo::Rect::new(40.0, 500.0, 160.0, 700.0).to_path(0.1),
11450            fill: pdf_render::pdf_interpret::FillRule::NonZero,
11451        });
11452        device.record_rule(&wide, Affine::IDENTITY);
11453
11454        assert_eq!(device.rules.len(), 1, "the visible part is still a rule");
11455        assert!(
11456            device.rules[0].right < 161.0,
11457            "the rule was recorded past its clip: {:?}",
11458            device.rules[0]
11459        );
11460
11461        let mut above = BezPath::new();
11462        above.move_to((40.0, 800.0));
11463        above.line_to((440.0, 800.0));
11464        device.record_rule(&above, Affine::IDENTITY);
11465        assert_eq!(
11466            device.rules.len(),
11467            1,
11468            "a rule the clip cuts away entirely was recorded: {:?}",
11469            device.rules
11470        );
11471
11472        device.pop_clip_path();
11473        device.record_rule(&wide, Affine::IDENTITY);
11474        assert_eq!(
11475            device.rules[1].right, 440.0,
11476            "the clip outlived the pop: {:?}",
11477            device.rules[1]
11478        );
11479    }
11480
11481    /// A table rules most of its gaps and a page of prose rules a couple: the
11482    /// share is what separates them, and it is measured rather than assumed.
11483    /// The Isartor appendix rules 26 of its 28 gaps; the two it leaves are the
11484    /// rows whose cell wraps, which is why the share is a half and not all.
11485    #[test]
11486    fn a_region_with_a_rule_in_a_minority_of_its_gaps_is_not_a_table() {
11487        let mut spans = Vec::new();
11488        for row in 0..6 {
11489            let y = 700.0 - row as f64 * 16.0;
11490            spans.push(span("Left column line", 40.0, y, 115.0));
11491            spans.push(span("Right column line", 320.0, y, 120.0));
11492        }
11493        let bands = group_spans_into_bands(spans);
11494        let rows: Vec<usize> = (0..bands.len()).collect();
11495        assert_eq!(bands.len(), 6, "six rows, so five gaps between them");
11496
11497        let rule_at = |y: f64| HorizontalRule {
11498            left: 35.0,
11499            right: 445.0,
11500            y,
11501        };
11502        let two = vec![rule_at(692.0), rule_at(676.0)];
11503        let three = vec![rule_at(692.0), rule_at(676.0), rule_at(660.0)];
11504
11505        assert!(
11506            !region_is_ruled(&bands, &rows, &two),
11507            "two of five is prose"
11508        );
11509        assert!(
11510            region_is_ruled(&bands, &rows, &three),
11511            "three of five is a table"
11512        );
11513    }
11514
11515    #[test]
11516    fn mixed_single_and_multi_column_regions_preserve_shared_bands() {
11517        let texts = block_texts(vec![
11518            span("Intro paragraph", 40.0, 740.0, 180.0),
11519            span("L1 words here", 40.0, 700.0, 110.0),
11520            span("R1 words here", 320.0, 700.0, 110.0),
11521            span("L2 words here", 40.0, 684.0, 110.0),
11522            span("R2 words here", 320.0, 684.0, 110.0),
11523            span("L3 words here", 40.0, 668.0, 110.0),
11524            span("R3 words here", 320.0, 668.0, 110.0),
11525            span("Outro paragraph", 40.0, 620.0, 180.0),
11526        ]);
11527
11528        assert_eq!(
11529            texts,
11530            vec![
11531                "Intro paragraph",
11532                "L1 words here",
11533                "L2 words here",
11534                "L3 words here",
11535                "R1 words here",
11536                "R2 words here",
11537                "R3 words here",
11538                "Outro paragraph",
11539            ]
11540        );
11541    }
11542
11543    /// A patent's front page: fields numbered by INID codes down the left,
11544    /// mostly short, with one long field line; references down the right on
11545    /// the same baselines. The long line is the column's measure, not a banner
11546    /// over both columns, so the page is read column by column (#574).
11547    #[test]
11548    fn a_patent_front_page_is_read_column_by_column() {
11549        // The measurements of 6942822's front page: a 14 pt gutter, the
11550        // column's long field line reaching into it, the rest ~150 pt.
11551        let fields = [
11552            ("(21) Appl. No.: 10/290,458", 150.0),
11553            ("(22) Filed: Nov. 8, 2002", 140.0),
11554            ("(51) Int. Cl. H01B 1/12", 160.0),
11555            ("(52) U.S. Cl. 252/500; 528/210", 150.0),
11556            ("(58) Field of Search .......... 252/500; 528/422", 219.0),
11557            ("(56) References Cited", 140.0),
11558        ];
11559        let mut spans = Vec::new();
11560        for (k, (field, width)) in fields.iter().enumerate() {
11561            let y = 700.0 - k as f64 * 16.0;
11562            spans.push(span(field, 77.0, y, *width));
11563            // The long field stands on a band of its own, as on the page:
11564            // the reference beside it ended a line higher.
11565            if k != 4 {
11566                spans.push(span(
11567                    "Journal of Polymer Science, vol. 38, pp. 3390",
11568                    310.0,
11569                    y,
11570                    220.0,
11571                ));
11572            }
11573        }
11574        let texts = block_texts(spans);
11575        assert!(
11576            texts
11577                .iter()
11578                .all(|t| !(t.contains(")") && t.contains("Journal"))),
11579            "a field was joined to the reference beside it: {texts:?}"
11580        );
11581    }
11582
11583    #[test]
11584    fn short_table_like_rows_fall_back_to_row_major() {
11585        let texts = block_texts(vec![
11586            span("Name", 40.0, 700.0, 30.0),
11587            span("Age", 320.0, 700.0, 20.0),
11588            span("Alice", 40.0, 684.0, 35.0),
11589            span("30", 320.0, 684.0, 15.0),
11590            span("Bob", 40.0, 668.0, 24.0),
11591            span("25", 320.0, 668.0, 15.0),
11592        ]);
11593
11594        assert_eq!(texts, vec!["Name Age", "Alice 30", "Bob 25"]);
11595    }
11596
11597    /// A three-column table row whose middle cell wraps, at the block level:
11598    /// the reordering happens in the leaf of the XY-cut, and this is the leaf's
11599    /// own view of `8.2.5.26-t05-pass-d.pdf`'s second row (#434).
11600    #[test]
11601    fn a_wrapped_cell_is_emitted_with_its_own_cell() {
11602        let texts = block_texts(vec![
11603            span("Index", 60.0, 700.0, 30.0),
11604            span("Failure condition", 200.0, 700.0, 100.0),
11605            span("Section", 400.0, 700.0, 42.0),
11606            span("15-003", 60.0, 680.0, 36.0),
11607            span("In a table not organized", 200.0, 680.0, 144.0),
11608            span("UA1:7.5-2", 400.0, 680.0, 54.0),
11609            span("a cell without a Scope", 200.0, 668.0, 132.0),
11610        ]);
11611
11612        assert_eq!(
11613            texts,
11614            vec![
11615                "Index Failure condition Section",
11616                "15-003",
11617                "In a table not organized",
11618                "a cell without a Scope",
11619                "UA1:7.5-2",
11620            ]
11621        );
11622    }
11623
11624    /// The failure case the fix names: when every cell of the row has a line
11625    /// under it, nothing says which of those lines continues the row and which
11626    /// one begins the next -- the same shape a two-column region takes when one
11627    /// column runs on alone. Such a row keeps today's row-major order rather
11628    /// than being guessed at.
11629    /// The leading is deliberately tight, so that all three lines pass the
11630    /// distance test in their own cell and it is the last guard -- every cell
11631    /// of the row having a line under it -- that has to refuse the row. At that
11632    /// leading the column cut above the leaf takes the page, so this one asks
11633    /// the leaf directly.
11634    #[test]
11635    fn a_row_whose_every_cell_wraps_stays_row_major() {
11636        let texts = leaf_texts(vec![
11637            span("Index", 60.0, 700.0, 30.0),
11638            span("Failure condition", 200.0, 700.0, 100.0),
11639            span("Section", 400.0, 700.0, 42.0),
11640            span("15-003", 60.0, 680.0, 36.0),
11641            span("In a table not organized", 200.0, 680.0, 144.0),
11642            span("UA1:7.5-2", 400.0, 680.0, 54.0),
11643            span("is the index", 60.0, 674.0, 72.0),
11644            span("a cell without a Scope", 200.0, 668.0, 132.0),
11645            span("of the suite", 400.0, 662.0, 72.0),
11646        ]);
11647
11648        assert_eq!(
11649            texts,
11650            vec![
11651                "Index Failure condition Section",
11652                "15-003 In a table not organized UA1:7.5-2",
11653                "is the index",
11654                "a cell without a Scope",
11655                "of the suite",
11656            ]
11657        );
11658    }
11659
11660    /// The next row of a table with an empty cell also has fewer spans than the
11661    /// row above it and also starts on the same edges. It is the next row all
11662    /// the same, and what says so is that it fills two cells where a wrapped
11663    /// line fills one. The header of this table is padded, so the body's rows
11664    /// sit closer together than the pitch under the header and the distance
11665    /// alone would let this one pass.
11666    #[test]
11667    fn a_row_with_an_empty_cell_is_a_row_and_not_a_wrap() {
11668        let texts = block_texts(vec![
11669            span("Index", 60.0, 700.0, 30.0),
11670            span("Type", 200.0, 700.0, 24.0),
11671            span("How", 400.0, 700.0, 18.0),
11672            span("15-003", 60.0, 680.0, 36.0),
11673            span("Object", 200.0, 680.0, 36.0),
11674            span("Machine", 400.0, 680.0, 42.0),
11675            span("15-004", 60.0, 666.0, 36.0),
11676            span("Rope", 400.0, 666.0, 24.0),
11677        ]);
11678
11679        assert_eq!(
11680            texts,
11681            vec!["Index Type How", "15-003 Object Machine", "15-004 Rope"]
11682        );
11683    }
11684
11685    /// A row that fills a single cell, one line under a wrapped one. Measured
11686    /// against the wrapped line it is a line's distance away; measured against
11687    /// the cell it would join -- which has no line under it yet, so the row
11688    /// itself -- it is two, and it is a row.
11689    #[test]
11690    fn a_single_cell_row_under_a_wrapped_line_is_still_a_row() {
11691        let texts = block_texts(vec![
11692            span("Index", 60.0, 700.0, 30.0),
11693            span("Type", 200.0, 700.0, 24.0),
11694            span("How", 400.0, 700.0, 18.0),
11695            span("15-003", 60.0, 680.0, 36.0),
11696            span("In a table not organized", 200.0, 680.0, 144.0),
11697            span("UA1:7.5-2", 400.0, 680.0, 54.0),
11698            span("a cell without a Scope", 200.0, 668.0, 132.0),
11699            span("15-004", 60.0, 656.0, 36.0),
11700        ]);
11701
11702        assert_eq!(
11703            texts,
11704            vec![
11705                "Index Type How",
11706                "15-003",
11707                "In a table not organized",
11708                "a cell without a Scope",
11709                "UA1:7.5-2",
11710                "15-004",
11711            ]
11712        );
11713    }
11714
11715    /// The same, with a row that fills a single cell: nothing but the pitch
11716    /// separates it from a wrapped line, and the pitch is what says it.
11717    #[test]
11718    fn a_line_a_whole_row_pitch_down_is_a_row_and_not_a_wrap() {
11719        let texts = block_texts(vec![
11720            span("Index", 60.0, 700.0, 30.0),
11721            span("Type", 200.0, 700.0, 24.0),
11722            span("How", 400.0, 700.0, 18.0),
11723            span("15-003", 60.0, 680.0, 36.0),
11724            span("Object", 200.0, 680.0, 36.0),
11725            span("Machine", 400.0, 680.0, 42.0),
11726            span("Rope", 200.0, 660.0, 24.0),
11727        ]);
11728
11729        assert_eq!(
11730            texts,
11731            vec!["Index Type How", "15-003 Object Machine", "Rope"]
11732        );
11733    }
11734
11735    #[test]
11736    fn three_column_regions_are_supported() {
11737        let texts = block_texts(vec![
11738            span("Column one line one", 40.0, 700.0, 105.0),
11739            span("Column two line one", 220.0, 700.0, 105.0),
11740            span("Column three line one", 400.0, 700.0, 120.0),
11741            span("Column one line two", 40.0, 684.0, 105.0),
11742            span("Column two line two", 220.0, 684.0, 105.0),
11743            span("Column three line two", 400.0, 684.0, 120.0),
11744            span("Column one line three", 40.0, 668.0, 120.0),
11745            span("Column two line three", 220.0, 668.0, 120.0),
11746            span("Column three line three", 400.0, 668.0, 135.0),
11747        ]);
11748
11749        assert_eq!(
11750            texts,
11751            vec![
11752                "Column one line one",
11753                "Column one line two",
11754                "Column one line three",
11755                "Column two line one",
11756                "Column two line two",
11757                "Column two line three",
11758                "Column three line one",
11759                "Column three line two",
11760                "Column three line three",
11761            ]
11762        );
11763    }
11764
11765    #[test]
11766    fn text_block_concatenation_spaced() {
11767        let block = TextBlock {
11768            spans: vec![span("A", 0.0, 0.0, 6.0), span("B", 20.0, 0.0, 6.0)],
11769        };
11770        assert_eq!(block.text(), "A B");
11771    }
11772
11773    #[test]
11774    fn a_space_drawn_over_a_ligature_is_no_word_space() {
11775        // `Specifi` then ` cally`, the second run starting 0.3 em before the
11776        // first ends (#574): the page draws one word in two runs, and a real
11777        // word space never starts left of the letter before it.
11778        let block = TextBlock {
11779            spans: vec![
11780                span("Specifi", 0.0, 0.0, 60.0),
11781                span(" cally", 56.4, 0.0, 36.0),
11782            ],
11783        };
11784        assert_eq!(block.text(), "Specifically");
11785        assert_eq!(crate::ligatures::fold(&block.text()), "Specifically");
11786    }
11787
11788    #[test]
11789    fn a_space_drawn_over_an_initial_is_no_word_space() {
11790        // `T` then ` ransition`, the second run starting 0.6 em back: the
11791        // same signature without a ligature (#574).
11792        let block = TextBlock {
11793            spans: vec![span("T", 0.0, 0.0, 8.0), span(" ransition", 0.8, 0.0, 60.0)],
11794        };
11795        assert_eq!(block.text(), "Transition");
11796    }
11797
11798    #[test]
11799    fn a_real_word_space_stays() {
11800        // `There` then ` is`, the second run starting 0.2 em AFTER the first
11801        // ends: a gap in reading direction is a word space (#574).
11802        let block = TextBlock {
11803            spans: vec![span("There", 0.0, 0.0, 30.0), span(" is", 32.4, 0.0, 15.0)],
11804        };
11805        assert_eq!(block.text(), "There is");
11806    }
11807
11808    #[test]
11809    fn an_overlapping_run_that_starts_with_a_capital_or_digit_keeps_its_space() {
11810        // Formulas and tables overlap runs that open with a capital or a
11811        // digit; only a lower-case letter joins (#574).
11812        let digit = TextBlock {
11813            spans: vec![span("v", 0.0, 0.0, 8.0), span(" 2", 4.4, 0.0, 10.0)],
11814        };
11815        assert_eq!(digit.text(), "v 2");
11816        let capital = TextBlock {
11817            spans: vec![span("v", 0.0, 0.0, 8.0), span(" N", 4.4, 0.0, 10.0)],
11818        };
11819        assert_eq!(capital.text(), "v N");
11820    }
11821
11822    #[test]
11823    fn adaptive_column_gap_fallback_for_no_gaps() {
11824        // Single-span bands produce no measurable gaps → fallback
11825        let bands = vec![
11826            TextBand::new(span("Hello", 40.0, 700.0, 80.0)),
11827            TextBand::new(span("World", 40.0, 684.0, 80.0)),
11828        ];
11829        let threshold = compute_adaptive_column_gap(&bands);
11830        assert!((threshold - COLUMN_GAP_THRESHOLD_FALLBACK).abs() < 0.01);
11831    }
11832
11833    #[test]
11834    fn adaptive_column_gap_uses_median() {
11835        // Three bands with word gaps of ~4pt each → median ≈ 4, threshold = 12
11836        let mut bands = Vec::new();
11837        for y in [700.0, 684.0, 668.0] {
11838            let mut band = TextBand::new(span("word1", 40.0, y, 30.0));
11839            band.spans.push(span("word2", 74.0, y, 30.0)); // gap = 4
11840            band.spans.push(span("word3", 108.0, y, 30.0)); // gap = 4
11841            bands.push(band);
11842        }
11843        let threshold = compute_adaptive_column_gap(&bands);
11844        // median gap = 4, × 3 = 12, clamped to [10, 40] → 12
11845        assert!(
11846            (10.0..=14.0).contains(&threshold),
11847            "expected ~12, got {threshold}"
11848        );
11849    }
11850
11851    #[test]
11852    fn adaptive_column_gap_clamps_to_min() {
11853        // Tight gaps (2pt) across many bands → median = 2, 3×2 = 6 → clamped to 10
11854        let mut bands = Vec::new();
11855        for y in [700.0, 684.0, 668.0, 652.0] {
11856            let mut band = TextBand::new(span("abc", 0.0, y, 18.0));
11857            // right of "abc" = max(18, 12*0.5*3=18) = 18; gap = 20-18 = 2
11858            band.spans.push(span("def", 20.0, y, 18.0));
11859            bands.push(band);
11860        }
11861        let threshold = compute_adaptive_column_gap(&bands);
11862        assert!(
11863            (threshold - COLUMN_GAP_THRESHOLD_MIN).abs() < 0.01,
11864            "expected {COLUMN_GAP_THRESHOLD_MIN}, got {threshold}"
11865        );
11866    }
11867
11868    #[test]
11869    fn adaptive_column_gap_all_large_gaps_uses_fraction_of_min() {
11870        // When all gaps are large (> MIN), threshold = 0.75 × min_gap.
11871        let mut band = TextBand::new(span("Left", 0.0, 700.0, 30.0));
11872        band.spans.push(span("Right", 80.0, 700.0, 30.0)); // gap = 50
11873        let bands = vec![band];
11874        let threshold = compute_adaptive_column_gap(&bands);
11875        assert!(
11876            (threshold - 37.5).abs() < 0.01,
11877            "expected 37.5 (0.75×50), got {threshold}"
11878        );
11879    }
11880
11881    #[test]
11882    fn normalize_trims_trailing_whitespace_per_line() {
11883        assert_eq!(
11884            normalize_text_output("hello   \nworld  \n"),
11885            "hello\nworld\n"
11886        );
11887    }
11888
11889    #[test]
11890    fn normalize_collapses_excess_newlines() {
11891        // >2 blank lines collapse to 2 (meaning 3 \n in a row: line, blank, blank)
11892        assert_eq!(
11893            normalize_text_output("hello\n\n\n\n\nworld\n"),
11894            "hello\n\n\nworld\n"
11895        );
11896    }
11897
11898    #[test]
11899    fn normalize_preserves_double_newline() {
11900        assert_eq!(
11901            normalize_text_output("paragraph one\n\nparagraph two\n"),
11902            "paragraph one\n\nparagraph two\n"
11903        );
11904    }
11905
11906    #[test]
11907    fn normalize_preserves_form_feed() {
11908        assert_eq!(
11909            normalize_text_output("page1\n\n\x0Cpage2\n"),
11910            "page1\n\n\x0Cpage2\n"
11911        );
11912    }
11913
11914    #[test]
11915    fn normalize_adds_trailing_newline() {
11916        assert_eq!(normalize_text_output("hello"), "hello\n");
11917    }
11918
11919    #[test]
11920    fn normalize_empty_input() {
11921        assert_eq!(normalize_text_output(""), "");
11922    }
11923
11924    #[test]
11925    fn normalize_only_whitespace() {
11926        assert_eq!(normalize_text_output("   \n  \n"), "");
11927    }
11928
11929    // --- Hyphen stitching tests ---
11930
11931    #[test]
11932    fn hyphen_stitch_joins_wrapped_word() {
11933        let lines = vec!["the aver-".into(), "age rainfall".into()];
11934        assert_eq!(stitch_hyphenated_lines(&lines), "the average rainfall");
11935    }
11936
11937    #[test]
11938    fn hyphen_stitch_handles_leading_whitespace() {
11939        let lines = vec!["pre-".into(), "   dict the outcome".into()];
11940        // "pre" is only 3 chars → meets >= 3 guard
11941        assert_eq!(stitch_hyphenated_lines(&lines), "predict the outcome");
11942    }
11943
11944    #[test]
11945    fn hyphen_stitch_capital_continuation_not_stitched() {
11946        let lines = vec!["Section three-".into(), "Summary here".into()];
11947        assert_eq!(
11948            stitch_hyphenated_lines(&lines),
11949            "Section three-\nSummary here"
11950        );
11951    }
11952
11953    #[test]
11954    fn hyphen_stitch_bullet_dash_not_stitched() {
11955        // "-" alone: char before hyphen is not alphabetic
11956        let lines = vec!["Items:".into(), "-".into(), "milk".into()];
11957        assert_eq!(stitch_hyphenated_lines(&lines), "Items:\n-\nmilk");
11958    }
11959
11960    #[test]
11961    fn hyphen_stitch_numeric_range_not_stitched() {
11962        // "42-" — char before hyphen is digit, not alphabetic
11963        let lines = vec!["page 42-".into(), "seventy".into()];
11964        assert_eq!(stitch_hyphenated_lines(&lines), "page 42-\nseventy");
11965    }
11966
11967    #[test]
11968    fn hyphen_stitch_short_prefix_not_stitched() {
11969        // "re-" only 2 alpha chars before hyphen → below 3-char guard
11970        let lines = vec!["re-".into(), "organize".into()];
11971        assert_eq!(stitch_hyphenated_lines(&lines), "re-\norganize");
11972    }
11973
11974    #[test]
11975    fn hyphen_stitch_short_continuation_not_stitched() {
11976        // Next line starts with "an" (2 chars) → below 3-char guard
11977        let lines = vec!["counter-".into(), "an example".into()];
11978        assert_eq!(stitch_hyphenated_lines(&lines), "counter-\nan example");
11979    }
11980
11981    #[test]
11982    fn hyphen_stitch_compound_word_midline_preserved() {
11983        // "real-time" is mid-line, not end-of-line — no stitching applies
11984        // because stitch only operates on line boundaries
11985        let lines = vec!["real-time system".into()];
11986        assert_eq!(stitch_hyphenated_lines(&lines), "real-time system");
11987    }
11988
11989    #[test]
11990    fn hyphen_stitch_single_line_unchanged() {
11991        let lines = vec!["only line".into()];
11992        assert_eq!(stitch_hyphenated_lines(&lines), "only line");
11993    }
11994
11995    #[test]
11996    fn hyphen_stitch_empty_input() {
11997        let lines: Vec<String> = vec![];
11998        assert_eq!(stitch_hyphenated_lines(&lines), "");
11999    }
12000
12001    // --- TEX1 multi-signal space consensus tests ---
12002
12003    fn make_device_with_median(median: f64) -> TextExtractionDevice {
12004        let mut dev = TextExtractionDevice::new();
12005        // Seed enough samples for the median to resolve to `median`.
12006        for _ in 0..MEDIAN_REFRESH {
12007            dev.glyph_widths.push(median);
12008        }
12009        dev.refresh_median_char_width();
12010        assert!((dev.cached_median_char_width - median).abs() < 1e-9);
12011        dev
12012    }
12013
12014    #[test]
12015    fn consensus_inserts_space_on_strong_tj_offset_alone() {
12016        // Gap is below the geometric threshold, but the TJ offset is large
12017        // enough that the consensus must still fire.
12018        let mut dev = make_device_with_median(6.0);
12019        dev.pending_tj_offset = -250.0; // full em-space, to the right
12020        assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
12021    }
12022
12023    #[test]
12024    fn a_pair_kern_of_any_size_is_not_a_word_space() {
12025        // `[(FINANCIAL RESUL)134.6(TS)] TJ`: the positive TJ pulls the `T`
12026        // back under the `L`, and the gap it leaves is below zero.
12027        let mut dev = make_device_with_median(6.0);
12028        dev.pending_tj_offset = 134.6;
12029        assert!(!dev.evaluate_space_consensus(-1.15, 8.5, "RESUL", "TS"));
12030    }
12031
12032    #[test]
12033    fn consensus_inserts_space_on_geometric_gap_alone() {
12034        // No TJ, no character transition, but a clearly wide geometric gap.
12035        let dev = make_device_with_median(6.0);
12036        // gap > 0.3 * 6.0 = 1.8 → fires gap signal (0.80), below threshold
12037        // on its own? 0.80 < 0.75 threshold? No, 0.80 > 0.75, so it fires.
12038        assert!(dev.evaluate_space_consensus(2.5, 12.0, "hello", "world"));
12039    }
12040
12041    #[test]
12042    fn consensus_no_space_on_kerning_gap() {
12043        // Small kerning-size gap with no other signals must not inject a
12044        // space (regression guard against false-positive spaces inside
12045        // tightly kerned words).
12046        let dev = make_device_with_median(6.0);
12047        assert!(!dev.evaluate_space_consensus(0.5, 12.0, "fi", "lm"));
12048    }
12049
12050    #[test]
12051    fn consensus_inserts_space_on_camel_case_plus_gap() {
12052        // CamelCase heuristic (0.60) alone doesn't reach threshold, but a
12053        // moderate gap (0.60 gap + 0.60 heuristic if gap fires) should.
12054        // Here gap = 2.5 > 1.8 → gap fires → total 0.80 + 0.60 = 1.40.
12055        let dev = make_device_with_median(6.0);
12056        assert!(dev.evaluate_space_consensus(2.5, 12.0, "helloWorld", "Inc"));
12057    }
12058
12059    #[test]
12060    fn consensus_inserts_space_on_digit_letter_transition_with_gap() {
12061        let dev = make_device_with_median(6.0);
12062        assert!(dev.evaluate_space_consensus(2.5, 12.0, "123", "abc"));
12063    }
12064
12065    #[test]
12066    fn consensus_heuristic_alone_is_insufficient() {
12067        // Heuristic (0.60) on its own is below the 0.75 threshold — the
12068        // design deliberately requires a second corroborating signal to
12069        // avoid gluing spaces into existing CamelCase identifiers that
12070        // have no geometric break.
12071        let dev = make_device_with_median(6.0);
12072        assert!(!dev.evaluate_space_consensus(0.5, 12.0, "camel", "Case"));
12073    }
12074
12075    #[test]
12076    fn consensus_falls_back_to_font_size_when_no_median() {
12077        // No samples → median is 0; geometric reference uses font-size.
12078        let dev = TextExtractionDevice::new();
12079        // gap 1.9 > 0.15 * 12.0 = 1.8 → gap signal fires
12080        assert!(dev.evaluate_space_consensus(1.9, 12.0, "a", "b"));
12081        // gap 1.5 < 1.8 → no signal
12082        assert!(!dev.evaluate_space_consensus(1.5, 12.0, "a", "b"));
12083    }
12084
12085    #[test]
12086    fn consensus_ignores_tiny_tj_offsets() {
12087        // TJ offsets below the threshold are kerning, not word breaks.
12088        let mut dev = make_device_with_median(6.0);
12089        dev.pending_tj_offset = 50.0;
12090        assert!(!dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
12091    }
12092
12093    #[test]
12094    fn consensus_accepts_negative_tj_offsets() {
12095        // A negative TJ offset moves the next glyph right, an explicit
12096        // inter-substring shift, and counts toward the consensus.
12097        let mut dev = make_device_with_median(6.0);
12098        dev.pending_tj_offset = -250.0;
12099        assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
12100    }
12101
12102    #[test]
12103    fn text_adjustment_accumulates_until_glyph() {
12104        let mut dev = TextExtractionDevice::new();
12105        dev.text_adjustment(120.0);
12106        dev.text_adjustment(140.0);
12107        assert!((dev.pending_tj_offset - 260.0).abs() < 1e-6);
12108    }
12109
12110    // --- TEX2 XY-Cut tests ---
12111
12112    #[test]
12113    fn xy_cut_header_body_footer_with_two_columns() {
12114        // Header and footer sit in the mid-x range that would
12115        // accidentally fall into a left-column bucket with a naive
12116        // vertical-first cut. The largest-gap-first rule plus the
12117        // alignment guard ensure header and footer bracket the
12118        // columnar body.
12119        let texts = block_texts(vec![
12120            span("HEADLINE TITLE", 180.0, 760.0, 120.0),
12121            span("Left col line A", 40.0, 700.0, 110.0),
12122            span("Right col line A", 320.0, 700.0, 115.0),
12123            span("Left col line B", 40.0, 684.0, 110.0),
12124            span("Right col line B", 320.0, 684.0, 115.0),
12125            span("Left col line C", 40.0, 668.0, 110.0),
12126            span("Right col line C", 320.0, 668.0, 115.0),
12127            span("FOOTER LINE TEXT", 180.0, 600.0, 120.0),
12128        ]);
12129        assert_eq!(texts.first().map(String::as_str), Some("HEADLINE TITLE"));
12130        assert_eq!(texts.last().map(String::as_str), Some("FOOTER LINE TEXT"));
12131        // Left column lines all come before right column lines.
12132        let left_c_idx = texts.iter().position(|s| s == "Left col line C").unwrap();
12133        let right_a_idx = texts.iter().position(|s| s == "Right col line A").unwrap();
12134        assert!(
12135            left_c_idx < right_a_idx,
12136            "expected column-major ordering in body: {texts:?}"
12137        );
12138    }
12139
12140    #[test]
12141    fn xy_cut_rejects_column_split_on_table_rows() {
12142        // The density guard must still reject the 280pt inter-cell gap
12143        // in a short-cell table, preserving row-major reading order.
12144        let texts = block_texts(vec![
12145            span("Name", 40.0, 700.0, 30.0),
12146            span("Age", 320.0, 700.0, 20.0),
12147            span("Alice", 40.0, 684.0, 35.0),
12148            span("30", 320.0, 684.0, 15.0),
12149        ]);
12150        assert_eq!(texts, vec!["Name Age", "Alice 30"]);
12151    }
12152
12153    #[test]
12154    fn xy_cut_rejects_column_split_when_one_band_is_full_width() {
12155        // The alignment guard catches a full-width paragraph that
12156        // would otherwise be forced into the left column of a 2-column
12157        // region below it.
12158        let texts = block_texts(vec![
12159            span(
12160                "Full width intro spanning both columns here",
12161                40.0,
12162                740.0,
12163                360.0,
12164            ),
12165            span("Left A", 40.0, 700.0, 50.0),
12166            span("Right A", 320.0, 700.0, 50.0),
12167            span("Left B", 40.0, 684.0, 50.0),
12168            span("Right B", 320.0, 684.0, 50.0),
12169        ]);
12170        assert!(
12171            texts[0].contains("Full width intro"),
12172            "expected full-width intro first: {texts:?}"
12173        );
12174    }
12175
12176    #[test]
12177    fn xy_cut_horizontal_split_for_zone_boundaries() {
12178        // Pure horizontal cut on a single-column page with a big
12179        // vertical gap between paragraphs — the cut fires and both
12180        // paragraphs stay in their own blocks.
12181        let texts = block_texts(vec![
12182            span("First paragraph body text", 40.0, 740.0, 200.0),
12183            span("Second paragraph body", 40.0, 680.0, 180.0),
12184        ]);
12185        assert_eq!(texts.len(), 2);
12186        assert!(texts[0].starts_with("First"));
12187        assert!(texts[1].starts_with("Second"));
12188    }
12189
12190    #[test]
12191    fn xy_cut_recursion_terminates_with_single_span() {
12192        let texts = block_texts(vec![span("Only one span on the page", 40.0, 700.0, 180.0)]);
12193        assert_eq!(texts, vec!["Only one span on the page"]);
12194    }
12195
12196    #[test]
12197    fn median_font_size_handles_mixed_sizes() {
12198        let spans = vec![
12199            TextSpan {
12200                text: "small".into(),
12201                width: 10.0,
12202                height: 8.0,
12203                font_size: 8.0,
12204                ..TextSpan::default()
12205            },
12206            TextSpan {
12207                text: "medium".into(),
12208                width: 10.0,
12209                height: 12.0,
12210                font_size: 12.0,
12211                ..TextSpan::default()
12212            },
12213            TextSpan {
12214                text: "large".into(),
12215                width: 10.0,
12216                height: 24.0,
12217                font_size: 24.0,
12218                ..TextSpan::default()
12219            },
12220        ];
12221        assert!((median_font_size(&spans) - 12.0).abs() < 1e-9);
12222    }
12223
12224    #[test]
12225    fn columns_band_aligned_accepts_aligned_columns() {
12226        let spans = vec![
12227            span("L1", 40.0, 700.0, 60.0),
12228            span("R1", 300.0, 700.0, 60.0),
12229            span("L2", 40.0, 684.0, 60.0),
12230            span("R2", 300.0, 684.0, 60.0),
12231        ];
12232        let stats = PageStats::from_spans(&spans);
12233        // cut_x between 100 and 300 → 200. Every band straddles the cut.
12234        assert!(columns_are_band_aligned(&spans, 200.0, 40.0, 360.0, &stats));
12235    }
12236
12237    #[test]
12238    fn columns_band_aligned_rejects_wide_single_side_band() {
12239        let spans = vec![
12240            span("Wide banner line across top", 40.0, 740.0, 280.0),
12241            span("L1", 40.0, 700.0, 60.0),
12242            span("R1", 300.0, 700.0, 60.0),
12243        ];
12244        let stats = PageStats::from_spans(&spans);
12245        // cut_x = 200. Banner only in left group (midpoint < 200). Width
12246        // exceeds 0.7 × left column width → rejected.
12247        assert!(!columns_are_band_aligned(
12248            &spans, 200.0, 40.0, 360.0, &stats
12249        ));
12250    }
12251
12252    #[test]
12253    fn page_stats_computes_median_values() {
12254        let spans = vec![
12255            span("one", 40.0, 700.0, 30.0),
12256            span("two", 40.0, 680.0, 30.0),
12257            span("three", 40.0, 660.0, 50.0),
12258        ];
12259        let stats = PageStats::from_spans(&spans);
12260        assert!((stats.median_font_size - 12.0).abs() < 1e-9);
12261        // char width = width / chars. one=30/3=10, two=30/3=10, three=50/5=10. median=10.
12262        assert!((stats.median_char_width - 10.0).abs() < 1e-9);
12263        // line spacing: bands at 700, 680, 660. gaps = 20, 20. median = 20.
12264        assert!((stats.median_line_spacing - 20.0).abs() < 1e-9);
12265    }
12266
12267    #[test]
12268    fn page_stats_handles_empty_input() {
12269        let stats = PageStats::from_spans(&[]);
12270        assert!((stats.median_font_size - 12.0).abs() < 1e-9);
12271        assert!((stats.median_char_width - 6.0).abs() < 1e-9);
12272        assert_eq!(stats.median_line_spacing, 0.0);
12273    }
12274
12275    #[test]
12276    fn narrow_gutter_detected_with_adaptive_threshold() {
12277        // Academic paper layout: 12pt gutter between columns.
12278        // With old fixed 20pt threshold, this was not detected as columnar.
12279        // With adaptive: median word gap ~4pt, threshold = 12pt → detects 12pt gutter.
12280        let mut spans = Vec::new();
12281        for y in [700.0, 684.0, 668.0] {
12282            // Left column: two words with 4pt gap, ending at x=145
12283            spans.push(span("Lorem ipsum", 40.0, y, 100.0));
12284            spans.push(span("dolor sit", 144.0, y, 80.0));
12285            // Right column starts at 236 (gap = 12pt from 224)
12286            spans.push(span("amet consec", 236.0, y, 100.0));
12287            spans.push(span("tetur adipi", 340.0, y, 80.0));
12288        }
12289        let texts = block_texts(spans);
12290        // Should detect 2-column layout and read column-major
12291        assert!(
12292            texts.len() >= 6,
12293            "expected column-major output, got {texts:?}"
12294        );
12295        // First three blocks should be left column lines
12296        assert!(
12297            texts[0].contains("Lorem"),
12298            "first block should be left column: {texts:?}"
12299        );
12300    }
12301
12302    /// One table whose rows are grouped under labels is not cut between the
12303    /// groups (#596).
12304    ///
12305    /// The shape is the tuning set's commonest table: a label column whose cell is
12306    /// filled on the first row of a group and empty under it, with a little more
12307    /// white between the groups than between the rows. The threshold of
12308    /// `try_horizontal_cut` is a multiple of the ROW pitch, so the gap between
12309    /// groups clears it and the table is cut into one region per group -- on
12310    /// `PMC3037919-p0007` that is twelve tables where the page holds one.
12311    ///
12312    /// Red without the guard: `try_horizontal_cut` returns a cut.
12313    #[test]
12314    fn one_table_is_not_cut_between_its_label_groups() {
12315        // Four columns, with gaps wide enough to separate runs, and a label column
12316        // that is empty on every row but the first of each group.
12317        let row = |label: &str, second: &str, third: &str, fourth: &str, y: f64| {
12318            let mut spans = Vec::new();
12319            if !label.is_empty() {
12320                spans.push(span(label, 72.0, y, 58.0));
12321            }
12322            spans.push(span(second, 160.0, y, 90.0));
12323            spans.push(span(third, 300.0, y, 30.0));
12324            spans.push(span(fourth, 360.0, y, 40.0));
12325            spans
12326        };
12327        let mut spans = Vec::new();
12328        spans.extend(row("Study Design", "Case control", "7", "25.93", 700.0));
12329        spans.extend(row("", "Cohort", "9", "33.33", 688.0));
12330        spans.extend(row("", "Cross-sectional", "11", "40.74", 676.0));
12331        // 24 points to the next group, against a 12 point row pitch.
12332        spans.extend(row("Gender studied", "Both", "21", "77.78", 652.0));
12333        spans.extend(row("", "Men", "3", "11.11", 640.0));
12334
12335        let stats = PageStats::from_spans(&spans);
12336        let bands = group_spans_into_bands(spans.clone());
12337        let cut_y = (676.0 + 652.0) * 0.5;
12338        let gap = 676.0 - 652.0;
12339
12340        // The premises, so the fixture cannot quietly stop being the defect: the
12341        // gap clears the unguarded threshold, and the leading floor of #580 does
12342        // not already refuse it.
12343        let unguarded = stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER;
12344        assert!(
12345            gap >= unguarded,
12346            "the gap {gap} does not clear the unguarded threshold {unguarded}, so \
12347             this fixture would pass without the guard"
12348        );
12349        let floor = leading_of_a_region(&spans, &stats) * HORIZONTAL_CUT_LEADING_FLOOR;
12350        assert!(
12351            gap >= floor,
12352            "the #580 floor {floor} already refuses this gap {gap}, so the guard \
12353             is not what this test measures"
12354        );
12355
12356        assert!(
12357            the_cut_is_inside_a_grid(&bands, cut_y, gap),
12358            "the bands either side of the cut were not read as one grid"
12359        );
12360        assert!(
12361            try_horizontal_cut(&spans, &stats).is_none(),
12362            "the table was cut between its label groups"
12363        );
12364    }
12365
12366    /// Three one-line zones, equally spaced, are not a leading (codex on #580,
12367    /// round two).
12368    ///
12369    /// Their two gaps are every gap the region has, so counting occurrences
12370    /// cannot tell them from three lines of a paragraph. What can is the left
12371    /// edge: lines set together are flush, and a masthead over a centred title
12372    /// over an indented byline are not.
12373    ///
12374    /// Red without the alignment filter: the zone gap becomes the leading, the
12375    /// floor rises above it and both of the region's cuts are refused.
12376    #[test]
12377    fn three_zones_equally_spaced_are_not_a_leading() {
12378        let stats = PageStats::from_spans(&[span("body", 72.0, 700.0, 100.0)]);
12379        let em = stats.median_font_size;
12380        // Equally spaced by 20pt, which is inside [0.8, 3.0] ems of a 12pt line.
12381        let zones = vec![
12382            span("MASTHEAD", 72.0, 700.0, 120.0),
12383            span("A centred title", 200.0, 680.0, 120.0),
12384            span("by a named author", 140.0, 660.0, 120.0),
12385        ];
12386        assert!(
12387            20.0 >= LEADING_MIN_EM * em && 20.0 <= LEADING_MAX_EM * em,
12388            "the fixture's gap is outside the bounds, so it never reaches the rule"
12389        );
12390        assert_eq!(
12391            leading_of_a_region(&zones, &stats),
12392            0.0,
12393            "three zones that are not flush were read as a leading"
12394        );
12395
12396        // And the same three steps DO count when the bands are flush, or the
12397        // filter would refuse every real paragraph as well.
12398        let lines = vec![
12399            span("the first line of a paragraph", 72.0, 700.0, 180.0),
12400            span("the second line of the same one", 72.0, 680.0, 180.0),
12401            span("and the third line of it", 72.0, 660.0, 150.0),
12402        ];
12403        assert_eq!(
12404            leading_of_a_region(&lines, &stats),
12405            20.0,
12406            "three flush lines did not yield their own leading"
12407        );
12408    }
12409
12410    /// A region of two bands has no leading, so the floor does not refuse its
12411    /// only cut (codex on #580).
12412    ///
12413    /// The shape has to be a two-band REGION on a page that is not two bands,
12414    /// because `PageStats` is page-wide: a page of two bands would put its only
12415    /// gap in `median_line_spacing` too and the threshold would refuse the cut
12416    /// with or without a floor. What the review describes is a page whose
12417    /// quartile is pulled down by dense content -- a table -- and a sparse region
12418    /// further down that the recursion reaches with two bands in it.
12419    ///
12420    /// Here the table sets the quartile to 6, so the threshold is 10.8; the
12421    /// widest gap on the page is the 82 points above the banner, so the first cut
12422    /// separates the table from everything below it and the region left over is
12423    /// the banner and one line of body, 25 points apart. Without the occurrence
12424    /// rule that 25 is "the leading" of a two-band region on a count of one, the
12425    /// floor rises to 26.25, and the only cut that region has is refused: the
12426    /// banner is glued to the body it is not part of.
12427    ///
12428    /// Red without the occurrence rule.
12429    #[test]
12430    fn two_bands_have_no_leading_so_the_only_cut_survives() {
12431        let mut spans = Vec::new();
12432        // Dense rows, to pull the page-wide quartile under the gap below.
12433        for (row, y) in [700.0, 694.0, 688.0, 682.0].iter().enumerate() {
12434            spans.push(span(["SWA", "BHM", "SBS", "VBS"][row], 72.0, *y, 30.0));
12435            spans.push(span("2011", 150.0, *y, 30.0));
12436        }
12437        spans.push(span("Federal Register banner", 72.0, 600.0, 260.0));
12438        spans.push(span(
12439            "the body of the notice begins here",
12440            72.0,
12441            575.0,
12442            200.0,
12443        ));
12444
12445        let stats = PageStats::from_spans(&spans);
12446        let region = vec![
12447            span("Federal Register banner", 72.0, 600.0, 260.0),
12448            span("the body of the notice begins here", 72.0, 575.0, 200.0),
12449        ];
12450        assert_eq!(
12451            leading_of_a_region(&region, &stats),
12452            0.0,
12453            "a single gap was read as the leading of a two-band region"
12454        );
12455        // And the fixture is the review's case: the gap is inside the bounds, so
12456        // only the occurrence rule keeps it from becoming a floor above itself.
12457        let gap = 600.0 - 575.0;
12458        assert!(
12459            gap >= LEADING_MIN_EM * stats.median_font_size
12460                && gap <= LEADING_MAX_EM * stats.median_font_size,
12461            "the fixture's gap is refused by the bounds, so it would not reach \
12462             the occurrence rule"
12463        );
12464        assert!(
12465            gap > stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER,
12466            "the cut would not fire even unfloored, so this fixture proves nothing"
12467        );
12468
12469        let texts = block_texts(spans);
12470        assert!(
12471            texts.iter().any(|t| t == "Federal Register banner"),
12472            "the banner was glued to the body: {texts:?}"
12473        );
12474    }
12475
12476    /// A paragraph is not cut between its own lines because something tighter
12477    /// than the body is on the page too (#580).
12478    ///
12479    /// The shape is the one the tuning set is full of: a bank of table rows set
12480    /// on 6 points over a body set on 14. `median_line_spacing` is the FIRST
12481    /// QUARTILE of the band steps, so it lands on the table's pitch (6) and the
12482    /// paragraph-break threshold on 10.8 -- under the body's leading. Every line
12483    /// break then qualifies as a zone break, the recursion cuts the paragraph
12484    /// into one region per line, and `blocks::paragraphs` cannot put it back
12485    /// because its stretches are bounded per region.
12486    ///
12487    /// Red without the floor: the four lines come back as four blocks.
12488    #[test]
12489    fn a_paragraph_is_not_cut_at_its_own_leading_because_a_table_is_tighter() {
12490        fn line(text: &str, x: f64, y: f64) -> TextSpan {
12491            // The measured width kept equal to `estimated_width()` (font_size *
12492            // 0.5 * chars), so the line fills the measure by both readings and
12493            // the merge is deciding about the gap and nothing else.
12494            span(text, x, y, 6.0 * text.chars().count() as f64)
12495        }
12496
12497        let mut spans = Vec::new();
12498        // A bank of figures on a 6 point pitch: three columns, four rows.
12499        for (row, y) in [700.0, 694.0, 688.0, 682.0].iter().enumerate() {
12500            spans.push(line(["SWA", "BHM", "SBS", "VBS"][row], 72.0, *y));
12501            spans.push(line("2011", 150.0, *y));
12502            spans.push(line(["11820", "2618", "3504", "2215"][row], 230.0, *y));
12503        }
12504        // One paragraph of prose on a 14 point leading, every line but the last
12505        // filling the measure and none of them ending a sentence.
12506        let prose = [
12507            "the occurrence of brachygnathia inferior and hernias",
12508            "showed statistically highly significant differences be",
12509            "tween breeds and the most common defect was recorded",
12510            "in the flocks of the region.",
12511        ];
12512        for (text, y) in prose.iter().zip([640.0, 626.0, 612.0, 598.0]) {
12513            spans.push(line(text, 72.0, y));
12514        }
12515
12516        let stats = PageStats::from_spans(&spans);
12517        let leading = leading_of_a_region(&spans, &stats);
12518        assert!(
12519            stats.median_line_spacing < leading,
12520            "the fixture is not the defect: quartile {} is not under the leading {leading}",
12521            stats.median_line_spacing
12522        );
12523        assert!(
12524            stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER < leading,
12525            "the unfloored threshold {} is not under the leading {leading}, so this \
12526             fixture would pass without the fix",
12527            stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
12528        );
12529
12530        // `paragraph_blocks` and not `block_texts`: the merge is what this is
12531        // about, and `block_texts` stops at the bands.
12532        let texts: Vec<String> = paragraph_blocks(spans, &[], 0, 0, TableStrategy::Both)
12533            .into_iter()
12534            .map(|block| block.text)
12535            .collect();
12536        let whole = prose.join(" ");
12537        assert!(
12538            texts.iter().any(|block| block == &whole),
12539            "the paragraph was cut at its own leading: {texts:?}"
12540        );
12541    }
12542
12543    #[test]
12544    fn xy_cut_leaf_falls_back_to_legacy_columns_for_header_plus_three_columns() {
12545        let texts = block_texts(vec![
12546            span("73022", 45.0, 750.0, 70.0),
12547            span("Federal Register banner", 125.6, 750.0, 260.0),
12548            span("Left column line one", 45.0, 725.0, 140.0),
12549            span("Middle column line one", 222.0, 725.0, 140.0),
12550            span("Right column line one", 399.0, 725.0, 120.0),
12551            span("Left column line two", 45.0, 715.0, 140.0),
12552            span("Middle column line two", 210.0, 715.0, 152.0),
12553            span("Right column line two", 388.0, 715.0, 132.0),
12554            span("Left column line three", 45.0, 705.0, 140.0),
12555            span("Middle column line three", 235.0, 705.0, 135.0),
12556            span("Right column line three", 408.0, 705.0, 118.0),
12557        ]);
12558
12559        assert_eq!(
12560            texts,
12561            vec![
12562                "73022 Federal Register banner",
12563                "Left column line one",
12564                "Left column line two",
12565                "Left column line three",
12566                "Middle column line one",
12567                "Middle column line two",
12568                "Middle column line three",
12569                "Right column line one",
12570                "Right column line two",
12571                "Right column line three",
12572            ]
12573        );
12574    }
12575
12576    #[test]
12577    fn overlapping_fake_bold_spans_collapse_to_single_copy() {
12578        let texts = block_texts(vec![
12579            span("1 This is fakebold text.", 25.9, 785.3, 320.0),
12580            span("1 This is fakebold text.", 26.2, 785.3, 320.0),
12581            span("1 This is fakebold text.", 26.4, 785.3, 320.0),
12582            span("1 This is fakebold text.", 26.7, 785.3, 320.0),
12583            span("2 This is a fakebold", 27.0, 714.8, 142.0),
12584            span(" fakebold", 169.8, 714.8, 70.0),
12585            span(" fakebold", 170.1, 714.8, 70.0),
12586            span(" fakebold word.", 170.4, 714.8, 110.0),
12587        ]);
12588
12589        assert_eq!(
12590            texts,
12591            vec!["1 This is fakebold text.", "2 This is a fakebold word.",]
12592        );
12593    }
12594
12595    /// A span drawn at `degrees`, with the transform a text matrix at that
12596    /// angle composes and the origin the glyphs start from.
12597    fn turned_span(text: &str, degrees: f64, x: f64, y: f64, width: f64) -> TextSpan {
12598        let (sin, cos) = degrees.to_radians().sin_cos();
12599        let scale = 12.0 / 1000.0;
12600        TextSpan {
12601            transform: Some([cos * scale, sin * scale, -sin * scale, cos * scale, x, y]),
12602            ..span(text, x, y, width)
12603        }
12604    }
12605
12606    /// Two lines of one rotated block share the place the page's order held
12607    /// them, and they must come back in the order they are read in. Each is
12608    /// inserted at the same index, and an insertion there pushes the last one
12609    /// down -- so inserting them front to back would emit the block backwards
12610    /// (#435, review round 2).
12611    #[test]
12612    fn two_lines_at_one_place_come_back_in_reading_order() {
12613        // A 90-degree run reads up the page, and its next line is a leading
12614        // further along +x: text space moves down, and down is +x once the
12615        // matrix has turned a quarter turn.
12616        let mut spans = vec![span("the page itself", 100.0, 700.0, 80.0)];
12617        for (index, text) in ["first", "second"].iter().enumerate() {
12618            let x = 60.0 + 14.0 * index as f64;
12619            spans.push(turned_span(text, 90.0, x, 200.0, 30.0));
12620        }
12621        let blocks = vec![TextBlock { spans }];
12622
12623        let out = reassemble_cross_direction_runs(blocks);
12624
12625        let lines: Vec<String> = out.iter().map(|block| block.text()).collect();
12626        assert_eq!(
12627            lines,
12628            vec!["first", "second", "the page itself"],
12629            "a rotated block came back with its lines the wrong way round"
12630        );
12631    }
12632
12633    /// The page's direction is weighed in glyphs, and a span holds more
12634    /// characters than glyphs whenever a glyph decodes to several of them or
12635    /// the space consensus put a space between two -- neither of which was
12636    /// drawn. Counting characters lets a minority direction outvote the page's
12637    /// own (#435, review round 2).
12638    #[test]
12639    fn the_pages_direction_is_weighed_in_glyphs_and_not_characters() {
12640        // Eight characters drawn as two glyphs: a pair of ligatures.
12641        let mut upright = span("fifffifl", 72.0, 700.0, 24.0);
12642        upright.char_bounds = vec![[0.0; 4]; 2];
12643        // Five characters drawn as five glyphs.
12644        let mut turned = turned_span("TURNS", 90.0, 300.0, 300.0, 30.0);
12645        turned.char_bounds = vec![[0.0; 4]; 5];
12646
12647        let direction = page_direction(&[TextBlock {
12648            spans: vec![upright, turned],
12649        }])
12650        .expect("a direction");
12651
12652        assert!(
12653            (direction.1).abs() > 0.5,
12654            "two ligature glyphs outvoted five drawn ones, so the count is of characters"
12655        );
12656    }
12657
12658    /// A run drawn at an angle stops where its last glyph stops, and that is
12659    /// further along its baseline than its page-space width says: a 30-degree
12660    /// run advances `cos 30` of its length in x. Measuring the gap to the next
12661    /// span from `x + width` leaves an eighth of the run as phantom gap, which
12662    /// at a style boundary in the middle of a word puts a space there (#435,
12663    /// review round 1).
12664    #[test]
12665    fn a_turned_run_ends_where_its_last_glyph_ends() {
12666        const DEGREES: f64 = 30.0;
12667        const ADVANCE: f64 = 7.2; // Courier at 12 pt
12668        let (sin, cos) = DEGREES.to_radians().sin_cos();
12669
12670        // "HELLO", five glyphs from (100, 400) along the 30-degree baseline,
12671        // as `draw_glyph` accumulates them: page-space bounds per glyph, and a
12672        // width that is the widest reach in page x.
12673        let mut hello = turned_span("HELLO", DEGREES, 100.0, 400.0, 0.0);
12674        for index in 0..5 {
12675            let x = 100.0 + cos * ADVANCE * index as f64;
12676            let y = 400.0 + sin * ADVANCE * index as f64;
12677            hello.char_bounds.push([x, y, x + ADVANCE, y + 12.0]);
12678            hello.width = hello.width.max(x + ADVANCE - 100.0);
12679        }
12680        // "WORLD" begins exactly where the fifth glyph's advance ended.
12681        let world = turned_span(
12682            "WORLD",
12683            DEGREES,
12684            100.0 + cos * ADVANCE * 5.0,
12685            400.0 + sin * ADVANCE * 5.0,
12686            ADVANCE * 5.0,
12687        );
12688
12689        let block = TextBlock {
12690            spans: vec![hello, world],
12691        };
12692        assert_eq!(
12693            block.text(),
12694            "HELLOWORLD",
12695            "a space was invented at a seam the document draws closed"
12696        );
12697    }
12698
12699    /// A page written entirely at an angle is a page of upright text seen
12700    /// turned, and its own bands read it correctly. Nothing is taken out of
12701    /// them: the direction most of the glyphs are drawn in IS the page's,
12702    /// whatever that direction is (#435).
12703    #[test]
12704    fn a_page_written_entirely_at_an_angle_keeps_its_own_bands() {
12705        let blocks = vec![
12706            TextBlock {
12707                spans: vec![
12708                    turned_span("first line", 90.0, 100.0, 100.0, 60.0),
12709                    turned_span("carried on", 90.0, 100.0, 160.0, 60.0),
12710                ],
12711            },
12712            TextBlock {
12713                spans: vec![turned_span("second line", 90.0, 120.0, 100.0, 66.0)],
12714            },
12715        ];
12716
12717        let out = reassemble_cross_direction_runs(blocks.clone());
12718
12719        let shape: Vec<Vec<&str>> = out
12720            .iter()
12721            .map(|b| b.spans.iter().map(|s| s.text.as_str()).collect())
12722            .collect();
12723        assert_eq!(
12724            shape,
12725            vec![vec!["first line", "carried on"], vec!["second line"]],
12726            "a page in one direction was rearranged by a rule that is about two"
12727        );
12728    }
12729
12730    /// Turned glyphs on one baseline come back as one span, ordered along the
12731    /// baseline with a word space where the gap is one (#574).
12732    #[test]
12733    fn turned_glyphs_on_one_baseline_merge_into_one_span() {
12734        // `AB CD` at 90 degrees: the glyphs climb the page one span each,
12735        // with a word gap between the words wide enough to part the run at
12736        // the cut, which is what merges it.
12737        let spans = vec![
12738            turned_span("A", 90.0, 100.0, 100.0, 7.0),
12739            turned_span("B", 90.0, 100.0, 107.0, 7.0),
12740            turned_span("C", 90.0, 100.0, 128.0, 7.0),
12741            turned_span("D", 90.0, 100.0, 135.0, 7.0),
12742        ];
12743        let out = merge_rotated_runs(spans);
12744        assert_eq!(out.len(), 1, "the run was not joined: {out:?}");
12745        assert_eq!(out[0].text, "AB CD");
12746        assert_eq!((out[0].x, out[0].y), (100.0, 100.0));
12747    }
12748
12749    /// Two runs that share a baseline but stand more than a word gap apart
12750    /// stay two (#574). Each piece earns its own merge: the tight pair stays
12751    /// as drawn under the lone-tight rule, exactly as if it stood alone.
12752    #[test]
12753    fn turned_runs_split_where_the_white_is_wider_than_any_word_gap() {
12754        let spans = vec![
12755            turned_span("A", 90.0, 100.0, 100.0, 7.0),
12756            turned_span("B", 90.0, 100.0, 107.0, 7.0),
12757            turned_span("C", 90.0, 100.0, 127.0, 7.0),
12758            turned_span("D", 90.0, 100.0, 180.0, 7.0),
12759            turned_span("E", 90.0, 100.0, 187.0, 7.0),
12760        ];
12761        let out = merge_rotated_runs(spans);
12762        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12763        assert_eq!(texts, vec!["AB C", "D", "E"]);
12764    }
12765
12766    /// A style change still ends a turned run, the way it ends a span in the
12767    /// device: a bold word inside one keeps its face (#574).
12768    #[test]
12769    fn a_bold_word_inside_a_turned_run_keeps_its_own_span() {
12770        let lawful = turned_span("A", 90.0, 100.0, 100.0, 7.0);
12771        let mut bold = turned_span("B", 90.0, 100.0, 107.0, 7.0);
12772        bold.is_bold = true;
12773        let out = merge_rotated_runs(vec![lawful, bold]);
12774        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12775        assert_eq!(texts, vec!["A", "B"]);
12776        assert!(!out[0].is_bold && out[1].is_bold);
12777    }
12778
12779    /// A skew below ten degrees is not quarter-turned: its spans come back as
12780    /// they went in (#574).
12781    #[test]
12782    fn a_skew_below_ten_degrees_is_left_alone() {
12783        let spans = vec![
12784            turned_span("A", 3.0, 100.0, 100.0, 7.0),
12785            turned_span("B", 3.0, 107.0, 100.5, 7.0),
12786        ];
12787        assert!(!spans.iter().any(span_is_quarter_turned));
12788        let out = merge_rotated_runs(spans);
12789        assert_eq!(out.len(), 2);
12790        assert_eq!(out[0].text, "A");
12791        assert_eq!(out[1].text, "B");
12792    }
12793
12794    /// Upright spans are never candidates, however they stack (#574).
12795    #[test]
12796    fn upright_spans_come_back_untouched() {
12797        let spans = vec![
12798            span("upper", 100.0, 200.0, 35.0),
12799            span("lower", 100.0, 100.0, 35.0),
12800        ];
12801        let out = merge_rotated_runs(spans);
12802        assert_eq!(out.len(), 2);
12803        assert_eq!(out[0].text, "upper");
12804        assert_eq!(out[1].text, "lower");
12805    }
12806
12807    /// A turned span with no partner on its baseline keeps its place and its
12808    /// shape (#574).
12809    #[test]
12810    fn a_turned_span_with_no_partner_is_left_exactly_as_it_was() {
12811        let spans = vec![
12812            span("body", 72.0, 700.0, 30.0),
12813            turned_span("!", 90.0, 500.0, 300.0, 7.0),
12814        ];
12815        let out = merge_rotated_runs(spans);
12816        assert_eq!(out.len(), 2);
12817        assert_eq!(
12818            (out[1].text.as_str(), out[1].x, out[1].y),
12819            ("!", 500.0, 300.0)
12820        );
12821    }
12822
12823    /// A turned span's right edge is measured, not estimated from its
12824    /// characters: the estimate runs along the baseline, which for a run
12825    /// stood up the page is not an x extent at all (#574).
12826    #[test]
12827    fn a_turned_spans_right_edge_is_measured() {
12828        let turned = turned_span("a whole head", 90.0, 100.0, 540.0, 7.0);
12829        assert_eq!(turned.right(), 107.0);
12830        let upright = span("a whole head", 100.0, 540.0, 72.0);
12831        assert_eq!(upright.right(), 172.0);
12832    }
12833
12834    /// A lone run with tight gaps is left as drawn: #435 already reads it
12835    /// forwards, and merging it only changes the shape downstream counts
12836    /// (#574).
12837    #[test]
12838    fn a_lone_tight_run_is_left_as_drawn() {
12839        let spans = vec![
12840            turned_span("A", 90.0, 100.0, 100.0, 7.0),
12841            turned_span("B", 90.0, 100.0, 107.0, 7.0),
12842        ];
12843        let out = merge_rotated_runs(spans);
12844        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12845        assert_eq!(texts, vec!["A", "B"]);
12846    }
12847
12848    /// Capitals never join across the small digits set between them: a gap
12849    /// another turned run's glyph starts in is not a word gap of this run,
12850    /// however word-wide it is (#574: an axis label's `Delta Omega 01 W`).
12851    #[test]
12852    fn capitals_do_not_join_across_digits_set_between_them() {
12853        let capitals = [
12854            turned_span("O", 90.0, 100.0, 100.0, 7.0),
12855            turned_span("W", 90.0, 100.0, 128.0, 7.0),
12856        ];
12857        let mut zero = turned_span("0", 90.0, 101.8, 110.0, 7.0);
12858        zero.font_name = Some("SmallDigits".to_string());
12859        let mut one = turned_span("1", 90.0, 101.8, 117.0, 7.0);
12860        one.font_name = Some("SmallDigits".to_string());
12861        // The capitals 21 points apart would merge on the gap alone; the
12862        // digits starting inside it part them instead.
12863        let out = merge_rotated_runs(vec![capitals[0].clone(), zero, one, capitals[1].clone()]);
12864        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12865        assert_eq!(texts, vec!["O", "0", "1", "W"]);
12866        // And the control: nothing between the capitals, the run joins.
12867        let out = merge_rotated_runs(capitals.to_vec());
12868        assert_eq!(out.len(), 1, "the control run did not join: {out:?}");
12869        assert_eq!(out[0].text, "O W");
12870    }
12871
12872    /// Parts of one run split by a style change are consecutive, not beside
12873    /// each other: their reading-first baselines stand an advance apart, so
12874    /// neither is the other's sibling and both stay as drawn (#574).
12875    #[test]
12876    fn style_split_parts_of_one_run_are_not_siblings() {
12877        let mut bold_a = turned_span("A", 90.0, 100.0, 100.0, 7.0);
12878        bold_a.is_bold = true;
12879        let mut bold_b = turned_span("B", 90.0, 100.0, 107.0, 7.0);
12880        bold_b.is_bold = true;
12881        let spans = vec![
12882            bold_a,
12883            bold_b,
12884            turned_span("C", 90.0, 100.0, 114.0, 7.0),
12885            turned_span("D", 90.0, 100.0, 121.0, 7.0),
12886        ];
12887        let out = merge_rotated_runs(spans);
12888        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12889        assert_eq!(texts, vec!["A", "B", "C", "D"]);
12890    }
12891
12892    /// A run with a gap on the paragraph-break scale merges: the cut would
12893    /// part it there, and the pieces come back in page order (#574).
12894    #[test]
12895    fn a_run_with_a_cut_scale_gap_merges() {
12896        let spans = vec![
12897            turned_span("A", 90.0, 100.0, 100.0, 7.0),
12898            turned_span("B", 90.0, 100.0, 130.0, 7.0),
12899        ];
12900        let out = merge_rotated_runs(spans);
12901        assert_eq!(out.len(), 1);
12902        assert_eq!(out[0].text, "A B");
12903    }
12904
12905    /// Two turned runs beside each other merge: the grid would row them
12906    /// together, one-glyph cells across (#574). Two stacked runs, whose
12907    /// reading-first baselines stand rows apart, stay as drawn.
12908    #[test]
12909    fn sibling_runs_beside_each_other_merge() {
12910        let beside = vec![
12911            turned_span("A", 90.0, 100.0, 100.0, 7.0),
12912            turned_span("B", 90.0, 100.0, 107.0, 7.0),
12913            turned_span("C", 90.0, 200.0, 100.0, 7.0),
12914            turned_span("D", 90.0, 200.0, 107.0, 7.0),
12915        ];
12916        let out = merge_rotated_runs(beside);
12917        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12918        assert_eq!(texts, vec!["AB", "CD"]);
12919
12920        let stacked = vec![
12921            turned_span("A", 90.0, 100.0, 100.0, 7.0),
12922            turned_span("B", 90.0, 100.0, 107.0, 7.0),
12923            turned_span("C", 90.0, 100.0, 200.0, 7.0),
12924            turned_span("D", 90.0, 100.0, 207.0, 7.0),
12925        ];
12926        let out = merge_rotated_runs(stacked);
12927        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12928        assert_eq!(texts, vec!["A", "B", "C", "D"]);
12929    }
12930
12931    /// The page's direction is the one most of its GLYPHS are drawn in, and a
12932    /// tie goes to the direction that was drawn first -- a rule the answer to
12933    /// which must not depend on which way an iterator happens to walk (#435).
12934    #[test]
12935    fn the_pages_direction_is_the_heaviest_and_a_tie_goes_to_the_first() {
12936        let upright = TextBlock {
12937            spans: vec![span("eight ch", 0.0, 700.0, 48.0)],
12938        };
12939        let turned = TextBlock {
12940            spans: vec![turned_span("ninety deg", 90.0, 300.0, 300.0, 60.0)],
12941        };
12942
12943        let heavier = page_direction(&[upright.clone(), turned.clone()]).expect("a direction");
12944        assert!(
12945            (heavier.1).abs() > 0.5,
12946            "ten glyphs at ninety degrees outweigh eight upright ones, and did not"
12947        );
12948
12949        let tie = TextBlock {
12950            spans: vec![span("ninety deg", 0.0, 700.0, 60.0)],
12951        };
12952        let first = page_direction(&[tie, turned]).expect("a direction");
12953        assert!(
12954            (first.1).abs() < 0.5,
12955            "a tie went to the direction that was not drawn first"
12956        );
12957    }
12958
12959    /// A page whose every line runs up it is read in the frame it was written
12960    /// in. Its bands lie across its lines, so without the turn the two lines
12961    /// share one band and come back as one (#546).
12962    #[test]
12963    fn a_turned_page_is_read_in_the_frame_it_was_written_in() {
12964        // A quarter-turned page reads up the page, and its next line is a
12965        // leading further along +x: text space moves down, and down is +x once
12966        // the matrix has turned a quarter turn.
12967        let spans: Vec<TextSpan> = ["the first line", "the second line"]
12968            .iter()
12969            .enumerate()
12970            .map(|(index, text)| turned_span(text, 90.0, 100.0 + 16.0 * index as f64, 100.0, 80.0))
12971            .collect();
12972
12973        let lines = block_texts(spans);
12974
12975        assert_eq!(
12976            lines,
12977            vec!["the first line", "the second line"],
12978            "the lines of a turned page were read in the bands of the frame it is drawn in"
12979        );
12980    }
12981
12982    /// The turn into the page's own frame and the turn back move no coordinate
12983    /// by a single bit: a quarter turn's sine and cosine are 0 and ±1, so what
12984    /// a caller reads off a span of a turned page is the page's own number and
12985    /// not a rounding of it (#546).
12986    #[test]
12987    fn the_turn_into_the_frame_and_back_leaves_every_number_as_it_was() {
12988        // Courier at 12 pt, and a run of six glyphs up the page.
12989        const ADVANCE: f64 = 7.2;
12990        const X: f64 = 123.75;
12991        const Y: f64 = 456.5;
12992        // The width is what `draw_glyph` accumulates: the furthest a glyph
12993        // reaches in page x, which for a quarter turn is one advance however
12994        // long the run is. Written as that subtraction and not as the advance,
12995        // because `x + advance - x` is not `advance` in binary and the number
12996        // the caller gets back is the number the device measured.
12997        let mut original = turned_span("TURNED", 90.0, X, Y, (X + ADVANCE) - X);
12998        for index in 0..6 {
12999            let y = Y + ADVANCE * index as f64;
13000            original.char_bounds.push([X, y, X + ADVANCE, y + 12.0]);
13001        }
13002
13003        for turn in [PageTurn::Quarter, PageTurn::Half, PageTurn::ThreeQuarter] {
13004            let spans = turn.into_frame(vec![original.clone()]);
13005            let blocks = turn.back(vec![TextBlock { spans }]);
13006            let span = &blocks[0].spans[0];
13007
13008            assert_eq!(
13009                (span.x, span.y),
13010                (original.x, original.y),
13011                "{turn:?} moved the span's origin"
13012            );
13013            assert_eq!(
13014                span.transform, original.transform,
13015                "{turn:?} left the baseline turned"
13016            );
13017            assert_eq!(
13018                span.char_bounds, original.char_bounds,
13019                "{turn:?} moved a glyph bound"
13020            );
13021            assert_eq!(
13022                span.width, original.width,
13023                "{turn:?} left a frame width behind"
13024            );
13025        }
13026    }
13027
13028    /// Only a page written in a quarter turn is turned: an upright one, a page
13029    /// drawn entirely at a free angle, and a page whose turned text is the
13030    /// minority all keep the bands they have (#546).
13031    #[test]
13032    fn a_page_is_turned_by_a_quarter_turn_or_by_nothing() {
13033        let cases = [
13034            (0.0, PageTurn::None),
13035            (90.0, PageTurn::Quarter),
13036            (180.0, PageTurn::Half),
13037            (270.0, PageTurn::ThreeQuarter),
13038            (45.0, PageTurn::None),
13039            (-52.0, PageTurn::None),
13040        ];
13041        for (degrees, expected) in cases {
13042            let spans = vec![turned_span("a line of it", degrees, 100.0, 100.0, 72.0)];
13043            assert_eq!(
13044                PageTurn::of(&spans),
13045                expected,
13046                "a page written at {degrees} degrees"
13047            );
13048        }
13049
13050        // The watermark of #435: turned, and outnumbered by the page it is on.
13051        let page = vec![
13052            span(
13053                "the body of the page is written upright",
13054                72.0,
13055                700.0,
13056                230.0,
13057            ),
13058            turned_span("SANDBOX", 90.0, 300.0, 200.0, 42.0),
13059        ];
13060        assert_eq!(
13061            PageTurn::of(&page),
13062            PageTurn::None,
13063            "a turned watermark turned the page it is drawn on"
13064        );
13065    }
13066
13067    /// A half-turned page keeps the ruling lines the page drew, because a half
13068    /// turn maps a horizontal line to a horizontal line; a quarter-turned page
13069    /// keeps none, because what the device recorded there runs the other way
13070    /// across the page (#546, review round 2).
13071    #[test]
13072    fn only_a_half_turn_keeps_the_pages_ruling_lines() {
13073        let rule = HorizontalRule {
13074            left: 72.0,
13075            right: 540.0,
13076            y: 400.0,
13077        };
13078
13079        let drawn = [rule];
13080        let upright = PageTurn::None.rules_in_frame(&drawn);
13081        assert_eq!(
13082            (
13083                upright.len(),
13084                upright[0].left,
13085                upright[0].right,
13086                upright[0].y
13087            ),
13088            (1, rule.left, rule.right, rule.y),
13089            "an upright page's rules were touched at all"
13090        );
13091
13092        let turned = PageTurn::Half.rules_in_frame(&drawn);
13093        let rotation = PageTurn::Half.rotation();
13094        let (left, y) = turn_point(rule.right, rule.y, rotation);
13095        let (right, _) = turn_point(rule.left, rule.y, rotation);
13096        assert_eq!(
13097            (turned.len(), turned[0].left, turned[0].right, turned[0].y),
13098            (1, left, right, y),
13099            "a half turn lost the page's rules, or left them where they were drawn"
13100        );
13101        assert!(
13102            turned[0].left < turned[0].right,
13103            "the ends of a mirrored rule did not swap, so it reads as empty"
13104        );
13105
13106        for turn in [PageTurn::Quarter, PageTurn::ThreeQuarter] {
13107            assert!(
13108                turn.rules_in_frame(&drawn).is_empty(),
13109                "{turn:?} kept a rule that runs across its reading direction"
13110            );
13111        }
13112    }
13113
13114    /// A page has to be WRITTEN in a direction before it is read in it: with
13115    /// its glyphs split four ways, as pdfbox `AcroFormsRotation` splits them
13116    /// over four blocks of form fields at the four rotations, the heaviest
13117    /// direction is a quarter of the page, and turning by it moves every block
13118    /// on a page that was never turned at all (#546).
13119    #[test]
13120    fn a_page_with_no_majority_direction_is_not_turned() {
13121        let quarter_each = vec![
13122            span("rotation zero", 72.0, 700.0, 78.0),
13123            turned_span("rotation ninety", 90.0, 200.0, 200.0, 90.0),
13124            turned_span("rotation oneeighty", 180.0, 400.0, 400.0, 108.0),
13125            turned_span("rotation twoseventy", 270.0, 500.0, 600.0, 114.0),
13126        ];
13127        assert_eq!(
13128            PageTurn::of(&quarter_each),
13129            PageTurn::None,
13130            "a plurality of a quarter of the glyphs turned the whole page"
13131        );
13132
13133        // The same page with the turned block carrying most of the glyphs is
13134        // a page written in that direction, and is read in it.
13135        let mut majority = quarter_each;
13136        majority[1] = turned_span(
13137            "a line of ninety degree text that is most of this page by a street",
13138            90.0,
13139            200.0,
13140            200.0,
13141            400.0,
13142        );
13143        assert_eq!(
13144            PageTurn::of(&majority),
13145            PageTurn::Quarter,
13146            "a direction carrying most of the page did not turn it"
13147        );
13148    }
13149
13150    /// A span drawn by a horizontal reflection, `[-1, 0, 0, 1, e, f]`. Its
13151    /// baseline runs in `(-1, 0)`, which is where a half turn puts one too --
13152    /// and its vertical axis is upright, which is where a half turn does not.
13153    fn mirrored_span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
13154        let scale = 12.0 / 1000.0;
13155        TextSpan {
13156            transform: Some([-scale, 0.0, 0.0, scale, x, y]),
13157            ..span(text, x, y, width)
13158        }
13159    }
13160
13161    /// A page drawn mirrored is not a page drawn upside down, and the baseline
13162    /// vector cannot tell them apart: both run in `(-1, 0)`. Turning it half a
13163    /// turn negates the vertical axis as well, so a block whose lines descend
13164    /// the page is read bottom to top -- an order invented from a page that was
13165    /// never turned. The determinant separates the two. (codex, review round 2)
13166    #[test]
13167    fn a_mirrored_page_is_not_a_half_turned_page() {
13168        let mirrored = vec![
13169            mirrored_span("the first line of a mirrored page", 400.0, 700.0, 300.0),
13170            mirrored_span("and the second line below it", 400.0, 686.0, 260.0),
13171        ];
13172        assert_eq!(
13173            PageTurn::of(&mirrored),
13174            PageTurn::None,
13175            "a reflection was read as a half turn, and turning it reverses the page"
13176        );
13177
13178        // The control, so the assertion above cannot pass by refusing every
13179        // page: the same two lines actually drawn upside down ARE a half turn.
13180        let half_turned: Vec<TextSpan> = vec![
13181            turned_span(
13182                "the first line of a turned page",
13183                180.0,
13184                400.0,
13185                700.0,
13186                300.0,
13187            ),
13188            turned_span("and the second line below it", 180.0, 400.0, 686.0, 260.0),
13189        ];
13190        assert_eq!(
13191            PageTurn::of(&half_turned),
13192            PageTurn::Half,
13193            "a page genuinely drawn at 180 degrees is no longer turned into its frame"
13194        );
13195
13196        // And a page that is mostly mirrored with an upright minority stays
13197        // untouched rather than being read in the minority's direction: the
13198        // share is of the whole page, not of the rotations on it.
13199        let mut mostly_mirrored = mirrored;
13200        mostly_mirrored.push(span("a short upright caption", 72.0, 100.0, 40.0));
13201        assert_eq!(
13202            PageTurn::of(&mostly_mirrored),
13203            PageTurn::None,
13204            "an upright minority carried the vote on a mirrored page"
13205        );
13206    }
13207
13208    /// The seam allowance of `second_run_carries_on_from_the_first` is one
13209    /// GLYPH of mean advance, and a glyph may decode to more than one
13210    /// character. Six characters drawn as five glyphs -- `office` with a `fi`
13211    /// ligature -- have a 6 pt allowance, where dividing the same advance by
13212    /// the six characters would leave 5 pt and reject a 5.5 pt seam that a
13213    /// re-drawn word arrives with (#432, review round 2).
13214    #[test]
13215    fn the_seam_allowance_counts_glyphs_and_not_characters() {
13216        let prev = span("first", 0.0, 700.0, 30.0);
13217        let mut curr = span("office", 24.5, 700.0, 30.0);
13218        curr.char_bounds = vec![[0.0; 4]; 5];
13219
13220        assert!(
13221            second_run_carries_on_from_the_first(&prev, &curr),
13222            "a ligature shrank the seam allowance to the character count"
13223        );
13224
13225        curr.char_bounds.clear();
13226        assert!(
13227            !second_run_carries_on_from_the_first(&prev, &curr),
13228            "the character count is the fallback, so it has to be the stricter one here"
13229        );
13230    }
13231
13232    // ---- G1: read-only metadata field tests ----
13233
13234    #[test]
13235    fn g1_default_text_span_has_empty_metadata() {
13236        let s = TextSpan::default();
13237        assert_eq!(s.font_name, None);
13238        assert!(!s.is_bold);
13239        assert!(!s.is_italic);
13240        assert_eq!(s.color, None);
13241    }
13242
13243    #[test]
13244    fn g1_strip_subset_prefix_handles_six_char_prefix() {
13245        assert_eq!(strip_subset_prefix("AAAAAA+Helvetica"), "Helvetica");
13246        // Non-6-char prefix → keep verbatim.
13247        assert_eq!(strip_subset_prefix("ABC+Helvetica"), "ABC+Helvetica");
13248        // No `+` → unchanged.
13249        assert_eq!(strip_subset_prefix("Helvetica-Bold"), "Helvetica-Bold");
13250    }
13251
13252    #[test]
13253    fn g1_name_style_hints_match_pdf_interpret_rules() {
13254        assert_eq!(name_style_hints("Helvetica-Bold"), (true, false));
13255        assert_eq!(name_style_hints("Times-Italic"), (false, true));
13256        assert_eq!(name_style_hints("MyFont-BoldOblique"), (true, true));
13257        assert_eq!(name_style_hints("Helvetica"), (false, false));
13258        // Semibold / Demi / Heavy / Black variants → bold.
13259        assert_eq!(name_style_hints("Roboto-DemiBold"), (true, false));
13260        assert_eq!(name_style_hints("Roboto-Black"), (true, false));
13261        // Oblique / slant variants → italic.
13262        assert_eq!(name_style_hints("Roboto-Oblique"), (false, true));
13263        assert_eq!(name_style_hints("MyFont-Slanted"), (false, true));
13264    }
13265
13266    // ---- G2: widthSource + char_bounds on TextSpan ----
13267
13268    #[test]
13269    fn g2_default_text_span_has_estimate_width_source() {
13270        let s = TextSpan::default();
13271        assert_eq!(s.width_source, WidthSource::Estimate);
13272        assert!(s.char_bounds.is_empty());
13273    }
13274
13275    /// Verify that a single-glyph span has exactly one char_bound entry
13276    /// and that the bound matches the span's x / width.
13277    #[test]
13278    fn g2_single_glyph_span_has_one_char_bound() {
13279        let s = TextSpan {
13280            text: "A".into(),
13281            x: 10.0,
13282            y: 100.0,
13283            width: 7.22,
13284            height: 10.0,
13285            font_size: 10.0,
13286            width_source: WidthSource::Metric,
13287            char_bounds: vec![[10.0, 100.0, 17.22, 110.0]],
13288            ..Default::default()
13289        };
13290
13291        assert_eq!(s.char_bounds.len(), 1);
13292        let [x0, y0, x1, y1] = s.char_bounds[0];
13293        assert!((x0 - 10.0).abs() < 0.001);
13294        assert!((x1 - 17.22).abs() < 0.001);
13295        assert!((y1 - y0 - s.font_size).abs() < 0.001);
13296    }
13297
13298    /// When merging glyphs into a span the width_source degrades to Estimate
13299    /// if any glyph was estimated.
13300    #[test]
13301    fn g2_merged_span_degrades_width_source_on_estimate() {
13302        let mut s = TextSpan {
13303            width_source: WidthSource::Metric,
13304            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13305            ..Default::default()
13306        };
13307
13308        // Simulate what draw_glyph does on merge: push bound + downgrade.
13309        s.char_bounds.push([7.0, 0.0, 12.0, 10.0]);
13310        s.width_source = WidthSource::Estimate; // second glyph had no advance
13311
13312        assert_eq!(s.width_source, WidthSource::Estimate);
13313        assert_eq!(s.char_bounds.len(), 2);
13314    }
13315
13316    /// Verify WidthSource enum serialises to the two expected string literals.
13317    #[test]
13318    fn g2_width_source_variants_are_correct() {
13319        assert_eq!(format!("{:?}", WidthSource::Metric), "Metric");
13320        assert_eq!(format!("{:?}", WidthSource::Estimate), "Estimate");
13321        assert_ne!(WidthSource::Metric, WidthSource::Estimate);
13322        assert_eq!(WidthSource::default(), WidthSource::Estimate);
13323    }
13324
13325    // ---- M3: RichGeometry / tight glyph bounds tests ----
13326
13327    #[test]
13328    fn m3_default_text_span_has_basic_mode() {
13329        let s = TextSpan::default();
13330        assert_eq!(s.geometry_mode, GeometryMode::Basic);
13331        assert_eq!(s.bounds_source, BoundsSource::Advance);
13332        assert!(s.tight_char_bounds.is_empty());
13333        assert!(s.glyph_advances.is_empty());
13334    }
13335
13336    #[test]
13337    fn m3_geometry_mode_default_is_basic() {
13338        assert_eq!(GeometryMode::default(), GeometryMode::Basic);
13339    }
13340
13341    #[test]
13342    fn m3_bounds_source_default_is_advance() {
13343        assert_eq!(BoundsSource::default(), BoundsSource::Advance);
13344    }
13345
13346    #[test]
13347    fn m3_geometry_mode_variants_are_distinct() {
13348        assert_ne!(GeometryMode::Basic, GeometryMode::RichGeometry);
13349        assert_eq!(format!("{:?}", GeometryMode::Basic), "Basic");
13350        assert_eq!(format!("{:?}", GeometryMode::RichGeometry), "RichGeometry");
13351    }
13352
13353    #[test]
13354    fn m3_bounds_source_variants_are_distinct() {
13355        assert_ne!(BoundsSource::Advance, BoundsSource::Tight);
13356        assert_ne!(BoundsSource::Tight, BoundsSource::Estimate);
13357        assert_eq!(format!("{:?}", BoundsSource::Advance), "Advance");
13358        assert_eq!(format!("{:?}", BoundsSource::Tight), "Tight");
13359        assert_eq!(format!("{:?}", BoundsSource::Estimate), "Estimate");
13360    }
13361
13362    #[test]
13363    fn m3_text_span_rich_geometry_preserves_existing_fields() {
13364        let s = TextSpan {
13365            text: "Hello".into(),
13366            x: 10.0,
13367            y: 100.0,
13368            width: 30.0,
13369            height: 12.0,
13370            font_size: 12.0,
13371            font_name: Some("Helvetica".into()),
13372            is_bold: true,
13373            is_italic: false,
13374            color: Some([0, 0, 0, 255]),
13375            width_source: WidthSource::Metric,
13376            char_bounds: vec![[10.0, 100.0, 40.0, 112.0]],
13377            geometry_mode: GeometryMode::RichGeometry,
13378            bounds_source: BoundsSource::Tight,
13379            tight_char_bounds: vec![[8.5, 98.5, 41.5, 114.0]],
13380            glyph_advances: vec![30.0],
13381            glyph_bounds_sources: vec![BoundsSource::Tight],
13382            ..Default::default()
13383        };
13384
13385        // Existing fields preserved.
13386        assert_eq!(s.text, "Hello");
13387        assert!((s.x - 10.0).abs() < 0.001);
13388        assert!((s.y - 100.0).abs() < 0.001);
13389        assert_eq!(s.width_source, WidthSource::Metric);
13390        assert_eq!(s.char_bounds.len(), 1);
13391
13392        // M3 fields populated.
13393        assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
13394        assert_eq!(s.bounds_source, BoundsSource::Tight);
13395        assert_eq!(s.tight_char_bounds.len(), 1);
13396        assert_eq!(s.glyph_advances.len(), 1);
13397        assert!((s.glyph_advances[0] - 30.0).abs() < 0.001);
13398    }
13399
13400    #[test]
13401    fn m3_text_span_basic_mode_does_not_populate_tight_fields() {
13402        let s = TextSpan {
13403            text: "A".into(),
13404            x: 0.0,
13405            y: 0.0,
13406            width: 7.0,
13407            height: 10.0,
13408            font_size: 10.0,
13409            geometry_mode: GeometryMode::Basic,
13410            bounds_source: BoundsSource::Advance,
13411            ..Default::default()
13412        };
13413
13414        assert!(s.tight_char_bounds.is_empty());
13415        assert!(s.glyph_advances.is_empty());
13416    }
13417
13418    #[test]
13419    fn m3_tight_char_bounds_are_optional_in_basic_mode() {
13420        // A span from Basic mode should have empty tight fields.
13421        let s = TextSpan {
13422            text: "test".into(),
13423            x: 0.0,
13424            y: 0.0,
13425            width: 40.0,
13426            height: 12.0,
13427            font_size: 12.0,
13428            geometry_mode: GeometryMode::Basic,
13429            ..Default::default()
13430        };
13431        assert!(s.tight_char_bounds.is_empty());
13432        assert!(s.glyph_advances.is_empty());
13433    }
13434
13435    #[test]
13436    fn m3_test_span_constructor_preserves_all_fields() {
13437        // Verify the span constructor used in existing tests still works.
13438        let s = span("hello", 10.0, 100.0, 30.0);
13439        assert_eq!(s.geometry_mode, GeometryMode::Basic);
13440        assert_eq!(s.bounds_source, BoundsSource::Advance);
13441        assert!(s.tight_char_bounds.is_empty());
13442    }
13443
13444    #[test]
13445    fn m3_width_source_unchanged_by_m3() {
13446        // WidthSource should not be affected by RichGeometry.
13447        assert_eq!(WidthSource::Metric, WidthSource::Metric);
13448        assert_eq!(WidthSource::Estimate, WidthSource::Estimate);
13449        assert_eq!(WidthSource::default(), WidthSource::Estimate);
13450    }
13451
13452    #[test]
13453    fn m3_transform_bbox_corners_identity() {
13454        let bbox = [0.0, 0.0, 10.0, 12.0];
13455        let identity = kurbo::Affine::IDENTITY;
13456        let result = transform_bbox_corners(&bbox, &identity);
13457        assert!((result[0] - 0.0).abs() < 0.001);
13458        assert!((result[1] - 0.0).abs() < 0.001);
13459        assert!((result[2] - 10.0).abs() < 0.001);
13460        assert!((result[3] - 12.0).abs() < 0.001);
13461    }
13462
13463    #[test]
13464    fn m3_transform_bbox_corners_translation() {
13465        let bbox = [0.0, 0.0, 10.0, 12.0];
13466        let t = kurbo::Affine::translate((50.0, 100.0));
13467        let result = transform_bbox_corners(&bbox, &t);
13468        assert!((result[0] - 50.0).abs() < 0.001);
13469        assert!((result[1] - 100.0).abs() < 0.001);
13470        assert!((result[2] - 60.0).abs() < 0.001);
13471        assert!((result[3] - 112.0).abs() < 0.001);
13472    }
13473
13474    #[test]
13475    fn m3_transform_bbox_corners_scale() {
13476        let bbox = [0.0, 0.0, 10.0, 12.0];
13477        let s = kurbo::Affine::scale(2.0);
13478        let result = transform_bbox_corners(&bbox, &s);
13479        assert!((result[0] - 0.0).abs() < 0.001);
13480        assert!((result[1] - 0.0).abs() < 0.001);
13481        assert!((result[2] - 20.0).abs() < 0.001);
13482        assert!((result[3] - 24.0).abs() < 0.001);
13483    }
13484
13485    #[test]
13486    fn m3_transform_bbox_corners_with_negative_bbox() {
13487        // Glyphs with descenders have negative y0 in font space.
13488        let bbox = [-5.0, -200.0, 15.0, 800.0];
13489        let s = kurbo::Affine::scale(0.012); // 12pt font at 1000-upem
13490        let result = transform_bbox_corners(&bbox, &s);
13491        assert!((result[0] - (-0.06)).abs() < 0.01);
13492        assert!((result[1] - (-2.4)).abs() < 0.01);
13493        assert!((result[2] - 0.18).abs() < 0.01);
13494        assert!((result[3] - 9.6).abs() < 0.01);
13495    }
13496
13497    #[test]
13498    fn m3_tight_char_bounds_descender_detection() {
13499        // A glyph with a descender should have tight_char_bounds.y0 < origin.y0.
13500        let s = TextSpan {
13501            text: "g".into(),
13502            x: 100.0,
13503            y: 200.0, // baseline
13504            width: 8.0,
13505            height: 12.0,
13506            font_size: 12.0,
13507            geometry_mode: GeometryMode::RichGeometry,
13508            bounds_source: BoundsSource::Tight,
13509            tight_char_bounds: vec![[99.0, 197.5, 109.0, 210.0]],
13510            char_bounds: vec![[100.0, 200.0, 108.0, 212.0]],
13511            glyph_advances: vec![8.0],
13512            ..Default::default()
13513        };
13514
13515        // Tight y0 (197.5) < span y (200.0) — descender extends below baseline.
13516        assert!(
13517            s.tight_char_bounds[0][1] < s.y,
13518            "descender should extend below baseline"
13519        );
13520    }
13521
13522    #[test]
13523    fn m3_tight_char_bounds_ascender_detection() {
13524        // A glyph with an ascender should have tight_char_bounds.y1 > advance y1.
13525        let s = TextSpan {
13526            text: "f".into(),
13527            x: 100.0,
13528            y: 200.0,
13529            width: 7.0,
13530            height: 12.0,
13531            font_size: 12.0,
13532            geometry_mode: GeometryMode::RichGeometry,
13533            bounds_source: BoundsSource::Tight,
13534            tight_char_bounds: vec![[95.0, 200.0, 108.0, 214.0]],
13535            char_bounds: vec![[100.0, 200.0, 107.0, 212.0]],
13536            glyph_advances: vec![7.0],
13537            ..Default::default()
13538        };
13539
13540        // Tight y1 (214.0) > advance y1 (200.0 + 12.0 = 212.0).
13541        let advance_y1 = s.y + s.height;
13542        assert!(
13543            s.tight_char_bounds[0][3] > advance_y1,
13544            "ascender should extend above advance bounds"
13545        );
13546    }
13547
13548    #[test]
13549    fn m3_tight_char_bounds_differ_from_advance() {
13550        // Verify tight bounds produce a different bbox than advance bounds.
13551        let s = TextSpan {
13552            text: "A".into(),
13553            x: 10.0,
13554            y: 100.0,
13555            width: 7.22,
13556            height: 10.0,
13557            font_size: 10.0,
13558            geometry_mode: GeometryMode::RichGeometry,
13559            bounds_source: BoundsSource::Tight,
13560            char_bounds: vec![[10.0, 100.0, 17.22, 110.0]], // advance
13561            tight_char_bounds: vec![[9.5, 100.2, 17.5, 109.5]], // tight (different)
13562            glyph_advances: vec![7.22],
13563            ..Default::default()
13564        };
13565
13566        let ac = s.char_bounds[0];
13567        let tc = s.tight_char_bounds[0];
13568        let differs = (ac[0] - tc[0]).abs() > 0.01
13569            || (ac[1] - tc[1]).abs() > 0.01
13570            || (ac[2] - tc[2]).abs() > 0.01
13571            || (ac[3] - tc[3]).abs() > 0.01;
13572        assert!(differs, "tight bounds should differ from advance bounds");
13573    }
13574
13575    #[test]
13576    fn m3_device_new_uses_basic_mode() {
13577        let dev = TextExtractionDevice::new();
13578        assert_eq!(dev.geometry_mode, GeometryMode::Basic);
13579        assert!(dev.deferred_rich_glyphs.is_empty());
13580    }
13581
13582    #[test]
13583    fn m3_device_with_mode_rich_geometry() {
13584        let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13585        assert_eq!(dev.geometry_mode, GeometryMode::RichGeometry);
13586        assert!(dev.deferred_rich_glyphs.is_empty());
13587    }
13588
13589    /// A synthetic rectangular glyph outline in raw design space, plus its
13590    /// bbox. The renderer (`renderer.rs::fill_glyph`) fills this raw outline
13591    /// with `composed = transform * glyph_transform` and NO upem pre-scaling,
13592    /// so the page-space bbox of a tight glyph MUST equal
13593    /// `(composed * outline).bounding_box()`. These tests use that as the
13594    /// ground-truth oracle, which is what guards against scaling regressions.
13595    #[cfg(test)]
13596    fn synthetic_glyph_rect() -> (BezPath, kurbo::Rect) {
13597        let mut path = BezPath::new();
13598        path.move_to((100.0, 0.0));
13599        path.line_to((600.0, 0.0));
13600        path.line_to((600.0, 700.0));
13601        path.line_to((100.0, 700.0));
13602        path.close_path();
13603        let bbox = path.bounding_box();
13604        (path, bbox)
13605    }
13606
13607    #[test]
13608    fn m3_basic_mode_skips_tight_bounds_computation() {
13609        // Basic mode must NOT compute tight bounds, even when a span is present.
13610        let mut dev = TextExtractionDevice::new();
13611        dev.spans.push(TextSpan {
13612            text: "A".into(),
13613            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13614            ..Default::default()
13615        });
13616        let spans = dev.into_spans();
13617        assert_eq!(spans.len(), 1);
13618        assert!(spans[0].tight_char_bounds.is_empty());
13619        assert!(spans[0].glyph_bounds_sources.is_empty());
13620        assert_eq!(spans[0].bounds_source, BoundsSource::Advance);
13621    }
13622
13623    #[test]
13624    fn m3_rich_geometry_tight_bounds_match_rendered_outline() {
13625        // Axis-aligned scale 0.01: the page-space tight bbox must equal the
13626        // bbox of the transformed outline — i.e. design [100,600]x[0,700]
13627        // scaled by 0.01 → [1.0, 0.0, 6.0, 7.0]. A double-scale regression
13628        // (e.g. an extra font_size/1000) would yield ~[0.01, 0, 0.06, 0.07].
13629        let (path, bbox) = synthetic_glyph_rect();
13630        let composed = Affine::scale(0.01);
13631        let oracle = (composed * path.clone()).bounding_box();
13632
13633        let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13634        dev.spans.push(TextSpan {
13635            text: "A".into(),
13636            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13637            geometry_mode: GeometryMode::RichGeometry,
13638            ..Default::default()
13639        });
13640        dev.deferred_rich_glyphs.push(DeferredGlyph {
13641            coeffs: composed.as_coeffs(),
13642            font_size: 10.0,
13643            glyph_width: 7.0,
13644            needs_exact: false,
13645            outline: Some(path),
13646            font_bbox: Some(bbox),
13647        });
13648
13649        let spans = dev.into_spans();
13650        assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
13651        assert_eq!(spans[0].bounds_source, BoundsSource::Tight);
13652        let tb = spans[0].tight_char_bounds[0];
13653        assert!(
13654            (tb[0] - oracle.x0).abs() < 1e-6,
13655            "x0: got {} want {}",
13656            tb[0],
13657            oracle.x0
13658        );
13659        assert!(
13660            (tb[1] - oracle.y0).abs() < 1e-6,
13661            "y0: got {} want {}",
13662            tb[1],
13663            oracle.y0
13664        );
13665        assert!(
13666            (tb[2] - oracle.x1).abs() < 1e-6,
13667            "x1: got {} want {}",
13668            tb[2],
13669            oracle.x1
13670        );
13671        assert!(
13672            (tb[3] - oracle.y1).abs() < 1e-6,
13673            "y1: got {} want {}",
13674            tb[3],
13675            oracle.y1
13676        );
13677        // Sanity: tight height (~7) is commensurate with the advance font_size
13678        // (10), not ~100x smaller — the regression this test exists to catch.
13679        assert!(
13680            tb[3] - tb[1] > 1.0,
13681            "tight height must be commensurate with font size"
13682        );
13683    }
13684
13685    #[test]
13686    fn m3_rich_geometry_tight_bounds_rotation_is_exact() {
13687        // 90° rotation routes through the needs_exact (transform-then-bound)
13688        // path. Oracle = bbox of the rotated outline.
13689        let (path, bbox) = synthetic_glyph_rect();
13690        let composed = Affine::scale(0.01) * Affine::rotate(std::f64::consts::FRAC_PI_2);
13691        let oracle = (composed * path.clone()).bounding_box();
13692
13693        let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13694        dev.spans.push(TextSpan {
13695            text: "A".into(),
13696            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13697            geometry_mode: GeometryMode::RichGeometry,
13698            ..Default::default()
13699        });
13700        dev.deferred_rich_glyphs.push(DeferredGlyph {
13701            coeffs: composed.as_coeffs(),
13702            font_size: 10.0,
13703            glyph_width: 7.0,
13704            needs_exact: true,
13705            outline: Some(path),
13706            font_bbox: Some(bbox),
13707        });
13708
13709        let spans = dev.into_spans();
13710        assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
13711        let tb = spans[0].tight_char_bounds[0];
13712        assert!(
13713            (tb[0] - oracle.x0).abs() < 1e-6,
13714            "x0: got {} want {}",
13715            tb[0],
13716            oracle.x0
13717        );
13718        assert!(
13719            (tb[1] - oracle.y0).abs() < 1e-6,
13720            "y0: got {} want {}",
13721            tb[1],
13722            oracle.y0
13723        );
13724        assert!(
13725            (tb[2] - oracle.x1).abs() < 1e-6,
13726            "x1: got {} want {}",
13727            tb[2],
13728            oracle.x1
13729        );
13730        assert!(
13731            (tb[3] - oracle.y1).abs() < 1e-6,
13732            "y1: got {} want {}",
13733            tb[3],
13734            oracle.y1
13735        );
13736    }
13737
13738    #[test]
13739    fn m3_empty_device_rich_geometry_no_panic() {
13740        let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13741        let text = dev.into_text();
13742        assert!(text.is_empty());
13743    }
13744
13745    #[test]
13746    fn m3_bounds_source_estimate_fallback() {
13747        // Verify that BoundsSource::Estimate can be set on a span.
13748        let s = TextSpan {
13749            geometry_mode: GeometryMode::RichGeometry,
13750            bounds_source: BoundsSource::Estimate,
13751            ..Default::default()
13752        };
13753        assert_eq!(s.bounds_source, BoundsSource::Estimate);
13754        assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
13755        // Fallback still has empty tight fields (no glyphs).
13756        assert!(s.tight_char_bounds.is_empty());
13757    }
13758
13759    /// One paragraph block of a single line, as the merge leaves a line of a
13760    /// double-spaced run: `top` is the top of its box, the box one em deep.
13761    fn line_block(text: &str, left: f64, top: f64, width: f64, size: f64) -> Block {
13762        let span = TextSpan {
13763            text: text.into(),
13764            x: left,
13765            y: top - size * 0.8,
13766            width,
13767            height: size,
13768            font_size: size,
13769            ..TextSpan::default()
13770        };
13771        Block {
13772            kind: BlockKind::Paragraph,
13773            page: 0,
13774            bbox: [left, top - size, left + width, top],
13775            text: text.into(),
13776            spans: vec![span],
13777            index: 0,
13778            continues_previous: false,
13779            confidence: None,
13780            grid: None,
13781            table_origin: TableOrigin::Other,
13782            trace_learned: None,
13783        }
13784    }
13785
13786    /// `texts` as single lines at `pitch`, all `size` on the same left edge
13787    /// and measure.
13788    fn run_of(texts: &[&str], pitch: f64, size: f64) -> Vec<Block> {
13789        texts
13790            .iter()
13791            .enumerate()
13792            .map(|(k, text)| line_block(text, 72.0, 700.0 - pitch * k as f64, 468.0, size))
13793            .collect()
13794    }
13795
13796    #[test]
13797    fn a_double_spaced_paragraph_is_one_paragraph() {
13798        fn line(text: &str, y: f64) -> TextSpan {
13799            // One measure for every line, so the join is deciding about the
13800            // gaps and nothing else.
13801            span(text, 72.0, y, 360.0)
13802        }
13803        let mut spans = Vec::new();
13804        // Single-spaced body: three lines on a 14 point pitch, one paragraph.
13805        for (text, y) in [
13806            "the committee met in ordinary session on tuesday morning",
13807            "and heard the report of the working group on procedure",
13808            "before adjourning for the midday recess.",
13809        ]
13810        .iter()
13811        .zip([700.0, 686.0, 672.0])
13812        {
13813            spans.push(line(text, y));
13814        }
13815        // Six full-width lines on a 24 point pitch: one paragraph set double,
13816        // with a word broken across its second line end.
13817        let run = [
13818            "the occurrence of brachygnathia inferior and hernias showed",
13819            "statistically highly significant differences between breeds and the line-",
13820            "breaks in the middle of a word join back together without a",
13821            "space while the other lines of the passage join with one plain",
13822            "space each so that the whole passage reads as the typesetter",
13823            "set it before the measure broke it into lines.",
13824        ];
13825        for (text, y) in run.iter().zip([630.0, 606.0, 582.0, 558.0, 534.0, 510.0]) {
13826            spans.push(line(text, y));
13827        }
13828        // The join runs after heading classification in the extract paths
13829        // (#636); here every block is still a paragraph, so it runs directly.
13830        let blocks =
13831            join_double_spaced_runs(paragraph_blocks(spans, &[], 0, 0, TableStrategy::Both), 0);
13832        assert_eq!(
13833            blocks.len(),
13834            2,
13835            "the run joined and the body stayed: {:?}",
13836            blocks.iter().map(|block| &block.text).collect::<Vec<_>>()
13837        );
13838        assert_eq!(blocks[0].index, 0);
13839        assert_eq!(blocks[1].index, 1);
13840        assert_eq!(
13841            blocks[1].text,
13842            "the occurrence of brachygnathia inferior and hernias showed statistically \
13843             highly significant differences between breeds and the linebreaks in the \
13844             middle of a word join back together without a space while the other lines \
13845             of the passage join with one plain space each so that the whole passage \
13846             reads as the typesetter set it before the measure broke it into lines."
13847        );
13848        assert_eq!(blocks[1].spans.len(), 6);
13849        assert!(
13850            blocks[1].bbox[1] < 510.0 && blocks[1].bbox[3] > 630.0,
13851            "the box covers the run: {:?}",
13852            blocks[1].bbox
13853        );
13854    }
13855
13856    #[test]
13857    fn a_one_line_list_on_wide_leading_stays_items() {
13858        let blocks = run_of(
13859            &[
13860                "• ripe red apples from the orchard",
13861                "• green pears from the walled garden",
13862                "• dark plums from the far paddock",
13863                "• yellow quinces from the old trees",
13864                "• blue sloes from the hedgerows",
13865            ],
13866            24.0,
13867            12.0,
13868        );
13869        let joined = join_double_spaced_runs(blocks, 0);
13870        assert_eq!(joined.len(), 5);
13871    }
13872
13873    #[test]
13874    fn an_address_block_stays_lines() {
13875        let blocks = run_of(
13876            &[
13877                "Jonathan Pryce",
13878                "12 Red Lion Square",
13879                "London WC1R 4QH",
13880                "United Kingdom",
13881            ],
13882            22.0,
13883            12.0,
13884        );
13885        let joined = join_double_spaced_runs(blocks, 0);
13886        assert_eq!(joined.len(), 4);
13887    }
13888
13889    #[test]
13890    fn label_and_value_lines_stay_apart() {
13891        let blocks = run_of(
13892            &["Ausfertigungsdatum: 20.12.1963", "Vollzitat:"],
13893            24.0,
13894            12.0,
13895        );
13896        let joined = join_double_spaced_runs(blocks, 3);
13897        assert_eq!(joined.len(), 2);
13898        assert_eq!(joined[0].index, 3);
13899        assert_eq!(joined[1].index, 4);
13900    }
13901
13902    #[test]
13903    fn contents_lines_stay_apart() {
13904        let blocks = run_of(
13905            &[
13906                "25 Independent Auditors' Reports",
13907                "26 Consolidated Statements",
13908            ],
13909            24.0,
13910            12.0,
13911        );
13912        let joined = join_double_spaced_runs(blocks, 0);
13913        assert_eq!(joined.len(), 2);
13914    }
13915
13916    #[test]
13917    fn two_paragraphs_on_one_pitch_stay_two() {
13918        let blocks = run_of(
13919            &[
13920                "the surveyors counted every head twice over the long winter.",
13921                "The year before the new regulations came into force was mild",
13922            ],
13923            24.0,
13924            12.0,
13925        );
13926        let joined = join_double_spaced_runs(blocks, 0);
13927        assert_eq!(joined.len(), 2);
13928    }
13929
13930    #[test]
13931    fn section_lines_stay_apart() {
13932        let blocks = run_of(
13933            &[
13934                "§ 2 Begriffe und Definitionen der in diesem Vertrag verwendeten Terminologie",
13935                "§ 3 Anwendungsbereich und Geltung dieser Bestimmungen im gesamten Inland",
13936            ],
13937            24.0,
13938            12.0,
13939        );
13940        let joined = join_double_spaced_runs(blocks, 0);
13941        assert_eq!(joined.len(), 2);
13942    }
13943
13944    #[test]
13945    fn article_lines_stay_apart() {
13946        let blocks = run_of(
13947            &[
13948                "Art. 2 Begriffe und Definitionen der in diesem Vertrag verwendeten Terminologie",
13949                "Art. 3 Anwendungsbereich und Geltung dieser Bestimmungen im gesamten Inland",
13950            ],
13951            24.0,
13952            12.0,
13953        );
13954        let joined = join_double_spaced_runs(blocks, 0);
13955        assert_eq!(joined.len(), 2);
13956    }
13957
13958    #[test]
13959    fn headings_never_join() {
13960        let mut blocks = run_of(
13961            &[
13962                "the occurrence of brachygnathia inferior and hernias showed",
13963                "statistically highly significant differences between breeds and the passage",
13964            ],
13965            24.0,
13966            12.0,
13967        );
13968        blocks[1].kind = BlockKind::Heading { level: 2 };
13969        let joined = join_double_spaced_runs(blocks, 0);
13970        assert_eq!(joined.len(), 2);
13971    }
13972
13973    #[test]
13974    fn m3_multiple_glyphs_tight_bounds_consistent_count() {
13975        // Simulate a span with 3 glyphs — tight_char_bounds and glyph_advances
13976        // should have the same length.
13977        let s = TextSpan {
13978            text: "abc".into(),
13979            x: 0.0,
13980            y: 0.0,
13981            width: 25.0,
13982            height: 12.0,
13983            font_size: 12.0,
13984            geometry_mode: GeometryMode::RichGeometry,
13985            bounds_source: BoundsSource::Tight,
13986            char_bounds: vec![[0., 0., 8., 12.], [8., 0., 16., 12.], [16., 0., 25., 12.]],
13987            tight_char_bounds: vec![
13988                [-1., -2., 9., 14.],
13989                [7., -2., 17., 14.],
13990                [15., -2., 26., 14.],
13991            ],
13992            glyph_advances: vec![8.0, 8.0, 9.0],
13993            ..Default::default()
13994        };
13995
13996        assert_eq!(s.tight_char_bounds.len(), 3);
13997        assert_eq!(s.glyph_advances.len(), 3);
13998        assert_eq!(s.char_bounds.len(), 3);
13999        // Every glyph has advance >= 0.
14000        for &adv in &s.glyph_advances {
14001            assert!(adv >= 0.0, "glyph advance must be non-negative");
14002        }
14003    }
14004
14005    fn styled_span(text: &str, x: f64, y: f64, width: f64, bold: bool) -> TextSpan {
14006        let mut out = span(text, x, y, width);
14007        out.is_bold = bold;
14008        out
14009    }
14010
14011    fn block_of(spans: Vec<TextSpan>) -> Block {
14012        let mut block = Block {
14013            kind: crate::blocks::BlockKind::Paragraph,
14014            page: 0,
14015            bbox: [0.0; 4],
14016            text: String::new(),
14017            spans,
14018            index: 0,
14019            continues_previous: false,
14020            confidence: None,
14021            grid: None,
14022            table_origin: TableOrigin::Other,
14023            trace_learned: None,
14024        };
14025        let lines = lines_of_block(&block);
14026        let texts: Vec<String> = lines.iter().map(|line| line_text(&block, line)).collect();
14027        block.text = crate::blocks::join_lines(&texts);
14028        crate::blocks::recompute_bbox(&mut block);
14029        block
14030    }
14031
14032    #[test]
14033    fn an_inid_field_keeps_its_continuation() {
14034        let upper = block_of(vec![
14035            styled_span("(73)", 72.0, 700.0, 30.0, true),
14036            styled_span(
14037                "Assignee: Schlumberger Technology",
14038                110.0,
14039                700.0,
14040                400.0,
14041                true,
14042            ),
14043        ]);
14044        let lower = block_of(vec![styled_span(
14045            "Corporation, Sugar Land, TX (US)",
14046            110.0,
14047            688.0,
14048            380.0,
14049            false,
14050        )]);
14051        let joined = join_marker_continuations(vec![upper, lower], 0);
14052        assert_eq!(
14053            joined.len(),
14054            1,
14055            "the field was cut off its continuation: {:?}",
14056            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14057        );
14058        assert!(
14059            joined[0]
14060                .text
14061                .contains("Schlumberger Technology Corporation"),
14062            "the join lost the middle: {:?}",
14063            joined[0].text
14064        );
14065        assert_eq!(joined[0].spans.len(), 3);
14066    }
14067
14068    #[test]
14069    fn a_bulleted_item_keeps_its_hung_line() {
14070        let upper = block_of(vec![styled_span(
14071            "\u{2022} Making investments in research and development and in",
14072            72.0,
14073            700.0,
14074            440.0,
14075            true,
14076        )]);
14077        let lower = block_of(vec![styled_span(
14078            "anticipate future waves of innovation across the industry",
14079            100.0,
14080            688.0,
14081            400.0,
14082            false,
14083        )]);
14084        let joined = join_marker_continuations(vec![upper, lower], 0);
14085        assert_eq!(
14086            joined.len(),
14087            1,
14088            "the item was cut off its hung line: {:?}",
14089            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14090        );
14091    }
14092
14093    #[test]
14094    fn a_field_label_ending_in_a_colon_stays_apart() {
14095        let upper = block_of(vec![styled_span(
14096            "(56) References cited:",
14097            72.0,
14098            700.0,
14099            440.0,
14100            true,
14101        )]);
14102        let lower = block_of(vec![styled_span(
14103            "A list of patents and publications considered",
14104            100.0,
14105            688.0,
14106            380.0,
14107            false,
14108        )]);
14109        let joined = join_marker_continuations(vec![upper, lower], 3);
14110        assert_eq!(
14111            joined.len(),
14112            2,
14113            "a label ending in a colon was joined to its value: {:?}",
14114            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14115        );
14116        assert_eq!(joined[0].index, 3);
14117        assert_eq!(joined[1].index, 4);
14118    }
14119
14120    #[test]
14121    fn a_short_marker_line_is_not_joined() {
14122        let upper = block_of(vec![styled_span(
14123            "(12) United States Patent",
14124            72.0,
14125            700.0,
14126            150.0,
14127            true,
14128        )]);
14129        let lower = block_of(vec![styled_span(
14130            "Palaniappan et al.",
14131            100.0,
14132            688.0,
14133            200.0,
14134            false,
14135        )]);
14136        let joined = join_marker_continuations(vec![upper, lower], 0);
14137        assert_eq!(
14138            joined.len(),
14139            2,
14140            "a short marker line was joined to the line under it: {:?}",
14141            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14142        );
14143    }
14144
14145    #[test]
14146    fn the_next_marker_starts_a_new_block() {
14147        let upper = block_of(vec![styled_span(
14148            "(73) Assignee: Schlumberger Technology",
14149            72.0,
14150            700.0,
14151            440.0,
14152            true,
14153        )]);
14154        // Hung under the upper line on purpose, so that only the marker
14155        // clause keeps the two apart.
14156        let lower = block_of(vec![styled_span(
14157            "(21) Appl. No.: 09/789,473",
14158            100.0,
14159            688.0,
14160            380.0,
14161            false,
14162        )]);
14163        let joined = join_marker_continuations(vec![upper, lower], 0);
14164        assert_eq!(
14165            joined.len(),
14166            2,
14167            "the next marker was joined to the item above it: {:?}",
14168            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14169        );
14170    }
14171
14172    #[test]
14173    fn a_wrapped_marker_item_keeps_its_hanging_continuation() {
14174        // The marker is on the FIRST line and the wrap on the LAST: three
14175        // lines before the weight cut parts the item from its continuation.
14176        // Read off the last line alone, the item opens with no marker and
14177        // stays cut.
14178        let upper = block_of(vec![
14179            styled_span(
14180                "\u{2022} Making investments in research and",
14181                72.0,
14182                700.0,
14183                440.0,
14184                true,
14185            ),
14186            styled_span(
14187                "development and in new facilities built",
14188                72.0,
14189                688.0,
14190                440.0,
14191                true,
14192            ),
14193            styled_span(
14194                "to meet demand across the coming years to",
14195                72.0,
14196                676.0,
14197                440.0,
14198                true,
14199            ),
14200        ]);
14201        let lower = block_of(vec![styled_span(
14202            "anticipate future waves of innovation across the industry",
14203            100.0,
14204            664.0,
14205            400.0,
14206            false,
14207        )]);
14208        let joined = join_marker_continuations(vec![upper, lower], 0);
14209        assert_eq!(
14210            joined.len(),
14211            1,
14212            "the wrapped item was cut off its hung line: {:?}",
14213            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14214        );
14215        assert!(
14216            joined[0].text.contains("coming years to anticipate"),
14217            "the join lost the middle: {:?}",
14218            joined[0].text
14219        );
14220        assert_eq!(joined[0].spans.len(), 4);
14221    }
14222
14223    #[test]
14224    fn a_short_heading_line_over_a_full_paragraph_is_not_cut() {
14225        // The short-heading cut is deliberately not shipped with this guard:
14226        // a short line over full lines stays one block through this pass.
14227        let block = block_of(vec![
14228            span("Contingent liabilities", 72.0, 700.0, 150.0),
14229            span(
14230                "Goodwill represents the excess of the consideration transferred",
14231                72.0,
14232                688.0,
14233                440.0,
14234            ),
14235            span(
14236                "over the fair value of the identifiable net assets acquired",
14237                72.0,
14238                676.0,
14239                440.0,
14240            ),
14241            span(
14242                "and is tested for impairment at least once a year.",
14243                72.0,
14244                664.0,
14245                440.0,
14246            ),
14247        ]);
14248        let out = join_marker_continuations(vec![block], 0);
14249        assert_eq!(
14250            out.len(),
14251            1,
14252            "a short heading line was cut off its paragraph: {:?}",
14253            out.iter().map(|b| &b.text).collect::<Vec<_>>()
14254        );
14255        assert_eq!(out[0].spans.len(), 4);
14256        assert_eq!(out[0].index, 0);
14257    }
14258
14259    #[test]
14260    fn a_marker_line_whose_next_line_does_not_hang_stays_apart() {
14261        let upper = block_of(vec![styled_span(
14262            "(73) Assignee: Schlumberger Technology",
14263            72.0,
14264            700.0,
14265            440.0,
14266            true,
14267        )]);
14268        // Flush with the marker line's left edge: a new block, not a hang.
14269        let lower = block_of(vec![styled_span(
14270            "Corporation, Sugar Land, TX (US)",
14271            72.0,
14272            688.0,
14273            380.0,
14274            false,
14275        )]);
14276        let joined = join_marker_continuations(vec![upper, lower], 0);
14277        assert_eq!(
14278            joined.len(),
14279            2,
14280            "a marker line was joined to a line that does not hang under it: {:?}",
14281            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14282        );
14283    }
14284}