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::{HashMap, 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 text block placed on its page, as [`PdfDocument::text_with_layout`]
483/// returns it: the text, where it sits and which page it is on.
484///
485/// [`PdfDocument::text_with_layout`]: crate::PdfDocument::text_with_layout
486#[derive(Debug, Clone, PartialEq)]
487pub struct LayoutBlock {
488    /// Block text, in reading order.
489    pub text: String,
490    /// Enclosing box `[x_min, y_min, x_max, y_max]` in PDF points.
491    pub bbox: [f64; 4],
492    /// Page number, 1-based.
493    pub page: usize,
494}
495
496/// A block of text (grouped by reading order).
497#[derive(Debug, Clone)]
498pub struct TextBlock {
499    /// Spans within this block, sorted by position.
500    pub spans: Vec<TextSpan>,
501}
502
503impl TextBlock {
504    /// Enclosing box of the block's spans, `[x_min, y_min, x_max, y_max]` in
505    /// PDF points. A block with no spans has the box `[0, 0, 0, 0]`.
506    pub fn bbox(&self) -> [f64; 4] {
507        let mut b = [
508            f64::INFINITY,
509            f64::INFINITY,
510            f64::NEG_INFINITY,
511            f64::NEG_INFINITY,
512        ];
513        for span in &self.spans {
514            b[0] = b[0].min(span.x);
515            b[1] = b[1].min(span.y);
516            b[2] = b[2].max(span.x + span.width);
517            b[3] = b[3].max(span.y + span.height);
518        }
519        if b[0].is_finite() {
520            b
521        } else {
522            [0.0; 4]
523        }
524    }
525
526    /// Concatenate all spans into a single string, in reading order.
527    ///
528    /// Spans that are close together are joined without a separator;
529    /// a space is inserted when the gap between spans exceeds half
530    /// the average character width.
531    ///
532    /// ANN[r17/TEX5] The spans are in the order the page draws them, left to
533    /// right, and for Arabic or Hebrew that is the reverse of the order they
534    /// are read in (#436, class R-H of #389). The assembled line therefore goes
535    /// through [`crate::bidi::logical_order`] before it is returned: a line
536    /// with no right-to-left character comes back untouched, and one that has
537    /// them comes back with each of its runs the way a reader would type it.
538    /// The spans themselves are left alone -- they carry the geometry, and the
539    /// geometry is visual.
540    pub fn text(&self) -> String {
541        crate::bidi::logical_order(&self.visual_text()).into_owned()
542    }
543
544    /// [`text`](Self::text) with the cell-path word rules where
545    /// `cell_path_words` is true: a word space set at exactly a quarter em
546    /// reads as a space, exactly as without the option otherwise.
547    pub(crate) fn text_with_cell_path_words(&self, cell_path_words: bool) -> String {
548        crate::bidi::logical_order(&self.visual_text_with(cell_path_words)).into_owned()
549    }
550
551    /// The line as the page draws it, left to right. The order every geometric
552    /// decision above this point was made in.
553    fn visual_text(&self) -> String {
554        self.visual_text_with(false)
555    }
556
557    /// [`visual_text`](Self::visual_text) with the cell-path word rules where
558    /// `cell_path_words` is true.
559    fn visual_text_with(&self, cell_path_words: bool) -> String {
560        if self.spans.is_empty() {
561            return String::new();
562        }
563        let spans: &[TextSpan] = &self.spans;
564        // What goes between each pair, decided pair by pair first and then
565        // looked at as a row: letter-spacing is a property of the row.
566        enum Join {
567            Overlap(String),
568            Gap(f64, bool),
569        }
570        let mut joins: Vec<Join> = Vec::with_capacity(spans.len());
571        // ANN[r17/TEX5] Where the line got to, which is not always the span
572        // before this one: a combining mark is drawn on the glyph before it and
573        // moves the pen nowhere, so its right edge sits an advance short and
574        // the letter after it looks a word apart. A Hebrew word with points
575        // came out `שָׁל וֹם` -- one word, two "words" (#436). A span that is
576        // nothing but marks does not say where the pen is, so the last one that
577        // does answers instead.
578        let mut anchor = &spans[0];
579        for pair in spans.windows(2) {
580            let prev = &pair[0];
581            let curr = &pair[1];
582            if !crate::bidi::is_only_marks(&prev.text) {
583                anchor = prev;
584            }
585            // The gap is measured along the baseline the anchor was drawn on:
586            // for upright text that is `curr.x - anchor.measured_right()` to
587            // the last bit, for a rotated run it is the distance the reader
588            // sees between the two (#435).
589            let direction = span_direction(anchor);
590            let (curr_along, _) = baseline_coordinates(curr, direction);
591            let gap = curr_along - baseline_end(anchor, direction);
592            // W1 (#663): the text join beside the shared word-join rule. A gap
593            // under 0.15 em is under 0.25 em, so `pieces_join` implies the rule
594            // below by arithmetic: this asserts without changing a byte, and
595            // shows the rewrite where the shared call goes.
596            debug_assert!(
597                !crate::words_join::pieces_join(
598                    gap,
599                    (baseline_coordinates(curr, direction).1
600                        - baseline_coordinates(anchor, direction).1)
601                        .abs(),
602                    prev.font_size
603                ) || gap <= prev.font_size * 0.25,
604                "the shared join implies the text join"
605            );
606            if gap <= prev.font_size * 0.12 && second_run_carries_on_from_the_first(prev, curr) {
607                if let Some(trimmed) = trim_overlapping_word_prefix(&prev.text, &curr.text) {
608                    joins.push(Join::Overlap(trimmed));
609                    continue;
610                }
611            }
612            if space_drawn_over_previous_run(prev, curr, gap) {
613                joins.push(Join::Overlap(curr.text.trim_start_matches(' ').to_string()));
614                continue;
615            }
616            let space = if cell_path_words {
617                // At the boundary, a space: a word space set at exactly a
618                // quarter em is still a word space (`financial officer`).
619                gap >= prev.font_size * 0.25
620            } else {
621                gap > prev.font_size * 0.25
622            };
623            joins.push(Join::Gap(gap, space));
624        }
625        untrack_spaced_spans(spans, &mut joins, |j| match j {
626            Join::Gap(gap, space) => Some((*gap, space)),
627            Join::Overlap(_) => None,
628        });
629        let mut result = spans[0].text.clone();
630        for (curr, join) in spans[1..].iter().zip(joins) {
631            match join {
632                Join::Overlap(trimmed) => result.push_str(&trimmed),
633                Join::Gap(_, space) => {
634                    if space {
635                        result.push(' ');
636                    }
637                    result.push_str(&curr.text);
638                }
639            }
640        }
641        result
642    }
643}
644
645/// Letter-spacing drawn as a row of one-letter spans (#1973).
646///
647/// #582 reads tracking inside a span. When the tracking is wide -- a space
648/// glyph after every capital, as in `P O W E L L  I N D U S T R I E S` set at
649/// five points -- every letter is a span of its own, the space glyphs never
650/// reach the text, and each gap between the spans is over the word-space
651/// threshold: the line came out one letter per word. On the evidence of the
652/// 24-09 round that is the largest class of text we write that the page does
653/// not hold (0.74 % of our words on DocLayNet, against Docling's 0.07 %).
654///
655/// A run of four one-letter spans or more (a kerned pair such as `AT` drawn as
656/// one does not end it) is read as letter-spacing only when THE PAGE SAYS
657/// WHERE ITS WORDS PART: a space glyph drawn as a span of its own between them
658/// (`P O W E L L` + a space span + `I N D U S T R I E S`), or gaps that fall in
659/// two -- the letters of a word one distance apart and the words clearly
660/// further, the widest gap at least 1.6 times the narrowest. The letters of a
661/// word then lose their spaces and the words keep one. A row whose gaps all agree
662/// says nothing about where its words part, and is left as it is: letters set
663/// apart on purpose and letters tracked apart are the same page, and that is
664/// the choice `a_line_of_single_letter_words_reads_as_tracking_and_that_is_the_choice`
665/// already pins inside a span.
666fn untrack_spaced_spans<J>(
667    spans: &[TextSpan],
668    joins: &mut [J],
669    mut gap_of: impl FnMut(&mut J) -> Option<(f64, &mut bool)>,
670) {
671    let blank = |span: &TextSpan| span.text.trim().is_empty();
672    // Capitals, and the punctuation a line of them carries: letter-spaced
673    // display type is set in capitals, and a row of lower-case letters,
674    // digits or symbols one apart is a formula or a listing (`NK(S) ∩ T`,
675    // `# 0 1 2 3`), which this must not touch.
676    let small = |span: &TextSpan| {
677        let t = span.text.trim();
678        let n = t.chars().count();
679        (n == 1 || n == 2)
680            && t.chars()
681                .all(|c| c.is_uppercase() || matches!(c, ',' | '.' | '\'' | '’' | '&' | '-'))
682            && (n == 1 || t.chars().any(char::is_uppercase))
683    };
684    let mut start = 0;
685    while start < spans.len() {
686        if !small(&spans[start]) {
687            start += 1;
688            continue;
689        }
690        let mut end = start + 1;
691        while end < spans.len()
692            && (small(&spans[end]) || blank(&spans[end]))
693            && gap_of(&mut joins[end - 1]).is_some()
694        {
695            end += 1;
696        }
697        // A space drawn at the end of the run is not inside it, and neither
698        // is a last "word" of one letter after it: `F C F C` + a space + `S a
699        // m p l e` is a figure's label beside a word, not two tracked words.
700        loop {
701            while end > start + 1 && blank(&spans[end - 1]) {
702                end -= 1;
703            }
704            let tail = spans[start..end].iter().rposition(blank);
705            match tail {
706                Some(at)
707                    if spans[start + at + 1..end]
708                        .iter()
709                        .filter(|s| s.text.trim().chars().any(char::is_uppercase))
710                        .count()
711                        < 2 =>
712                {
713                    end = start + at;
714                }
715                _ => break,
716            }
717        }
718        let singles = spans[start..end]
719            .iter()
720            .filter(|s| {
721                let t = s.text.trim();
722                t.chars().count() == 1 && t.chars().all(char::is_uppercase)
723            })
724            .count();
725        // THE PAGE DREW ITS WORD SPACES: a space glyph set as a span of its
726        // own where the words part, which is the boundary. The gaps between the
727        // letters are tracking and lose their space; a gap much wider than the
728        // run's tracking keeps it -- the end of a line, or a word the page
729        // parted without a drawn space. Without a drawn space the row is left
730        // as it is: gaps that fall in two were tried as the boundary and read
731        // figure labels and OCR noise (`B C B C`, `I I I`) as words.
732        if singles >= 4 && spans[start..end].iter().any(blank) {
733            let mut tracking: Vec<f64> = (start..end - 1)
734                .filter(|&k| !blank(&spans[k]) && !blank(&spans[k + 1]))
735                .filter_map(|k| gap_of(&mut joins[k]).map(|(g, _)| g))
736                .collect();
737            tracking.sort_by(|a, b| a.total_cmp(b));
738            let median = tracking.get(tracking.len() / 2).copied().unwrap_or(0.0);
739            for k in start..end - 1 {
740                let beside_a_drawn_space = blank(&spans[k]) || blank(&spans[k + 1]);
741                if let Some((gap, space)) = gap_of(&mut joins[k]) {
742                    if beside_a_drawn_space || gap <= 1.5 * median.max(0.0) {
743                        *space = false;
744                    }
745                }
746            }
747        }
748        // Never stand still: a run that shrank to nothing moves on by one.
749        start = end.max(start + 1);
750    }
751}
752
753#[derive(Debug, Clone)]
754struct TextBand {
755    y: f64,
756    spans: Vec<TextSpan>,
757}
758
759impl TextBand {
760    fn new(span: TextSpan) -> Self {
761        Self {
762            y: span.y,
763            spans: vec![span],
764        }
765    }
766
767    fn sort_spans(&mut self) {
768        self.spans.sort_by(|a, b| {
769            a.x.partial_cmp(&b.x)
770                .unwrap_or(Ordering::Equal)
771                .then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
772        });
773        split_spans_covering_siblings(&mut self.spans);
774        self.spans.sort_by(|a, b| {
775            a.x.partial_cmp(&b.x)
776                .unwrap_or(Ordering::Equal)
777                .then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
778        });
779        collapse_overprinted_spans(&mut self.spans);
780    }
781
782    fn row_block(&self) -> TextBlock {
783        let mut spans = self.spans.clone();
784        spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
785        TextBlock { spans }
786    }
787
788    fn left(&self) -> f64 {
789        self.spans
790            .iter()
791            .map(|span| span.x)
792            .fold(f64::INFINITY, f64::min)
793    }
794
795    fn right(&self) -> f64 {
796        self.spans
797            .iter()
798            .map(TextSpan::right)
799            .fold(f64::NEG_INFINITY, f64::max)
800    }
801
802    fn width(&self) -> f64 {
803        (self.right() - self.left()).max(0.0)
804    }
805
806    fn gap_midpoints(&self, column_gap_threshold: f64) -> Vec<f64> {
807        self.gaps(column_gap_threshold)
808            .into_iter()
809            .map(|gap| (gap.start + gap.end) * 0.5)
810            .collect()
811    }
812
813    fn gaps(&self, column_gap_threshold: f64) -> Vec<BandGap> {
814        if self.spans.len() < 2 {
815            return Vec::new();
816        }
817
818        let mut spans = self.spans.clone();
819        spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
820
821        let mut gaps = Vec::new();
822        let mut prev_right = spans[0].right();
823        for span in spans.iter().skip(1) {
824            let gap = span.x - prev_right;
825            if gap >= column_gap_threshold {
826                gaps.push(BandGap {
827                    start: prev_right,
828                    end: span.x,
829                });
830            }
831            prev_right = prev_right.max(span.right());
832        }
833
834        gaps
835    }
836
837    fn split_by_boundaries(&self, boundaries: &[f64]) -> Vec<Vec<TextSpan>> {
838        let mut columns = vec![Vec::new(); boundaries.len() + 1];
839        for span in &self.spans {
840            // Through `right()` rather than the estimate inline, so a
841            // quarter-turned span is centred on the x it covers and not on the
842            // length of the run stood up on it (#574). The same value for
843            // upright text.
844            let center_x = (span.x + span.right()) * 0.5;
845            let column_idx = boundaries
846                .iter()
847                .position(|boundary| center_x < *boundary)
848                .unwrap_or(boundaries.len());
849            columns[column_idx].push(span.clone());
850        }
851
852        for spans in &mut columns {
853            spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
854        }
855
856        columns
857    }
858
859    fn fits_single_column(
860        &self,
861        boundaries: &[f64],
862        region_left: f64,
863        region_right: f64,
864    ) -> Option<usize> {
865        let mut column_idx: Option<usize> = None;
866        for span in &self.spans {
867            let left = span.x;
868            let right = span.right();
869            if boundaries
870                .iter()
871                .any(|boundary| left < *boundary && right > *boundary)
872            {
873                return None;
874            }
875
876            let center_x = left + (right - left) * 0.5;
877            let idx = boundaries
878                .iter()
879                .position(|boundary| center_x < *boundary)
880                .unwrap_or(boundaries.len());
881            match column_idx {
882                Some(existing) if existing != idx => return None,
883                Some(_) => {}
884                None => column_idx = Some(idx),
885            }
886        }
887        let idx = column_idx?;
888        let mut edges = Vec::with_capacity(boundaries.len() + 2);
889        edges.push(region_left);
890        edges.extend_from_slice(boundaries);
891        edges.push(region_right);
892
893        let column_width = (edges[idx + 1] - edges[idx]).max(0.0);
894        if column_width <= 0.0 || self.width() > column_width * 0.8 {
895            return None;
896        }
897
898        Some(idx)
899    }
900}
901
902#[derive(Debug, Clone, Copy)]
903struct BandGap {
904    start: f64,
905    end: f64,
906}
907
908/// The points a path segment is built from, in the order the segment draws
909/// them. Control points included: a cubic lies inside the hull of its four
910/// points, so a bound taken over all of them is never too small.
911fn element_points(element: &PathEl) -> impl Iterator<Item = Point> {
912    let mut points = [Point::ZERO; 3];
913    let count = match *element {
914        PathEl::MoveTo(p) | PathEl::LineTo(p) => {
915            points[0] = p;
916            1
917        }
918        PathEl::QuadTo(a, b) => {
919            points[0] = a;
920            points[1] = b;
921            2
922        }
923        PathEl::CurveTo(a, b, c) => {
924            points[0] = a;
925            points[1] = b;
926            points[2] = c;
927            3
928        }
929        PathEl::ClosePath => 0,
930    };
931    points.into_iter().take(count)
932}
933
934/// A horizontal rule the page draws, in the page space the spans are measured
935/// in (#545).
936///
937/// This is the one thing on the page that says "table" without guessing at it
938/// from the text's own geometry. A width heuristic cannot tell a table from a
939/// paper set in two columns -- both are slices of a band with a gutter between
940/// them -- and the discriminator tried on slice width fixed the Isartor table
941/// by breaking `tracemonkey`. The ruling lines are drawn by the document
942/// itself, and a page of prose does not draw one between every pair of lines.
943#[derive(Clone, Copy, Debug)]
944#[doc(hidden)]
945pub struct HorizontalRule {
946    pub left: f64,
947    pub right: f64,
948    pub y: f64,
949}
950
951/// One straight, axis-aligned stroke of a drawn grid, in page space (#574):
952/// a thin line in either direction, or one edge of a drawn rectangle.
953///
954/// KEPT APART FROM [`HorizontalRule`] on purpose. Every heuristic that reads
955/// the horizontal rules was tuned on what that list holds; a table's cell
956/// borders and shading boxes would flood it with short segments none of them
957/// was measured against. This list is read by the lattice alone.
958#[derive(Clone, Copy, Debug, PartialEq)]
959#[doc(hidden)]
960pub struct Ruling {
961    /// Left or only x.
962    pub x0: f64,
963    /// Bottom or only y.
964    pub y0: f64,
965    /// Right x; equal to `x0` for a vertical ruling.
966    pub x1: f64,
967    /// Top y; equal to `y0` for a horizontal ruling.
968    pub y1: f64,
969}
970
971/// A Device implementation that captures text from draw_glyph calls.
972///
973/// ANN[r17/TEX1][r17/TEX3] Space detection uses a multi-signal consensus
974/// rather than a single geometric threshold. Three signals vote:
975///   1. `pending_tj_offset`  — raw TJ backward shift surfaced by the
976///      interpreter (confidence 0.95). This is the definitive word-break
977///      signal used by pdftotext / MuPDF.
978///   2. geometric gap        — measured horizontal distance between the
979///      previous glyph's right edge and this glyph's origin (confidence
980///      0.80). Compared against the running median glyph width rather
981///      than a flat em-fraction so condensed/wide fonts are handled
982///      uniformly.
983///   3. character heuristic  — CamelCase transition or digit↔letter
984///      transition at the merge point (confidence 0.60). Catches cases
985///      where the writer relied on typography (e.g. table cells glued
986///      with zero gap: `Qty1Price$5`).
987///
988/// A space is inserted when the weighted sum meets SPACE_CONSENSUS_THRESHOLD.
989/// Span accumulation still merges adjacent glyphs into one TextSpan (TEX3)
990/// so downstream reading-order logic sees logical text runs, not individual
991/// character positions.
992///
993/// One entry on the marked-content stack for `/ActualText`. Sequences nest,
994/// so each `BDC`/`BMC` pushes and `EMC` pops; the innermost entry carrying
995/// a replacement wins for a glyph drawn inside it. The replacement is
996/// emitted once, on the first glyph of its sequence, and later glyphs of
997/// the same sequence are suppressed. A sequence that closes without a glyph
998/// having carried it reads once at its close instead, at the marks it drew.
999struct ActualTextEntry {
1000    actual_text: Option<String>,
1001    consumed: bool,
1002    /// A glyph that reads as drawn text was painted while this sequence was
1003    /// open, so the sequence already stands in the text and its close must
1004    /// not read the replacement as well.
1005    saw_glyph: bool,
1006    /// A sequence nested inside this one already answered for these marks --
1007    /// its replacement read on a glyph or at its close -- so this close
1008    /// stays quiet, and a replacement inside a replacement reads once.
1009    child_resolved: bool,
1010    /// The marks painted while this sequence was open, in page space: glyph
1011    /// boxes, path boxes and image boxes united. Where the replacement reads
1012    /// when no glyph carried it.
1013    extent: Option<[f64; 4]>,
1014}
1015
1016#[doc(hidden)]
1017pub struct TextExtractionDevice {
1018    spans: Vec<TextSpan>,
1019    last_y: f64,
1020    last_end_x: f64,
1021    /// The gaps between the last few glyph pairs of the span being built, so
1022    /// that a run the typesetter TRACKED can be told from a run with word
1023    /// spaces in it (#582). See `a_tracked_run`.
1024    recent_gaps: Vec<f64>,
1025    /// The last glyph drawn with a fill: where, and what it reads. Text in
1026    /// render mode 2 or 6 reaches the device twice per glyph, filled and then
1027    /// stroked, and the stroke is the same character on the same spot.
1028    last_fill: Option<([f64; 6], Option<BfString>)>,
1029    /// How far the last glyph's box was widened past its declared advance by
1030    /// the quarter-em floor of `glyph_width_and_source`; the gap after it is
1031    /// that much short of the one the page drew.
1032    last_widened: f64,
1033    /// The face the last span is MERGED on: the program's own name, which is
1034    /// what `font_name` was before it learned /BaseFont (#574). A span that
1035    /// carries the page's name for a face the program does not name keeps
1036    /// merging as it did -- a formula's CMMI into its CMR -- because a merge
1037    /// across a face change is where a word space is judged, and splitting
1038    /// there lost `where µ measures` on the arXiv pages.
1039    last_merge_face: Option<String>,
1040    /// Where in the text of the span being built the space consensus put a
1041    /// space, and the gap it judged: a drawn space is not in here.
1042    inserted_spaces: Vec<(usize, f64)>,
1043    /// Where in that text the current run of regular gaps began.
1044    run_start: usize,
1045    /// The last gap between two letters of the span being built.
1046    last_letter_gap: Option<f64>,
1047    /// TJ adjustment in raw 1/1000 em units since the last glyph was
1048    /// drawn. Positive values = backward shift (i.e., explicit horizontal
1049    /// space). Reset every time a glyph is drawn.
1050    pending_tj_offset: f32,
1051    /// Running sample of measured glyph widths used as the adaptive
1052    /// reference for the geometric gap signal. Cheap to maintain and
1053    /// avoids having to re-walk all spans per decision.
1054    glyph_widths: Vec<f64>,
1055    /// The style of every font seen so far, by font and resource name. The
1056    /// style is a property of the font and not of the glyph, and reading it
1057    /// off the font means searching its name for a dozen family names: done
1058    /// once per glyph it was 8% of extraction on a prose page.
1059    style_cache: HashMap<u128, Vec<(Option<String>, GlyphStyle)>>,
1060    /// The widths the last refresh had, in sorted order, so a refresh sorts
1061    /// only the widths that arrived since and merges them in, instead of
1062    /// sorting the whole sample again. Given up once a NaN width is seen:
1063    /// there is no sorted order to keep, and the refresh sorts as it always
1064    /// did.
1065    sorted_glyph_widths: Vec<f64>,
1066    nan_glyph_width_seen: bool,
1067    /// Cached median glyph width (kept fresh every `MEDIAN_REFRESH`
1068    /// insertions). Zero = not yet established, caller falls back to
1069    /// font-size scaling.
1070    cached_median_char_width: f64,
1071    /// Geometry extraction mode — Basic (advance bounds) or RichGeometry (tight bounds).
1072    geometry_mode: GeometryMode,
1073    /// Per-glyph data recorded during extraction for deferred tight-bounds
1074    /// computation. Only populated in RichGeometry mode. The index into this
1075    /// vec corresponds 1:1 with the glyph order across all spans.
1076    deferred_rich_glyphs: Vec<DeferredGlyph>,
1077    /// Hand back the ligature code points a font declared instead of the
1078    /// letters they stand for. Off, because a search box compares characters
1079    /// and U+FB03 is not `ffi` (#437); see [`crate::ligatures`].
1080    keep_ligatures: bool,
1081    /// Report the document's own font names and the descriptor's metrics on
1082    /// spans. Off, because the default output is what it was before the E34
1083    /// truth existed: substitute-face names and AFM metrics (#643).
1084    document_font_names: bool,
1085    /// The horizontal rules the page draws, in page space (#545).
1086    rules: Vec<HorizontalRule>,
1087    /// Every thin line and rectangle edge the page draws, for the lattice
1088    /// (#574).
1089    rulings: Vec<Ruling>,
1090    /// Every solid, non-white, axis-aligned rectangle the page fills, in page
1091    /// space and clipped: the shading behind a heading, a highlighted row, a
1092    /// box a note is set in (#574, the line network's vector inputs).
1093    fills: Vec<[f64; 4]>,
1094    /// Where the page places its images, in page space: the unit square each
1095    /// image XObject is drawn into, under its transform and clipped to what is
1096    /// visible (#636). A caption sits beside a picture, and the picture is
1097    /// nowhere else in what this device reads.
1098    images: Vec<[f64; 4]>,
1099    /// The box of every path the page fills or strokes that is not a thin
1100    /// line, in page space and clipped (#636): what a chart or a drawn figure
1101    /// is made of. Clustered into figures by [`Self::take_graphics`].
1102    path_boxes: Vec<[f64; 4]>,
1103    /// The clip boxes in force, innermost last, in page space (#545). Each
1104    /// entry is already intersected with the one below it, so the last is the
1105    /// box a path drawn now can reach.
1106    clip_stack: Vec<kurbo::Rect>,
1107    /// Whether glyphs merge into the runs the page draws, for the raw-order
1108    /// path (`ExtractOptions::raw_order`, #643). Off: every glyph stands on
1109    /// its own span, exactly as without the option. On: the gap is judged on
1110    /// the pen the last glyph left, so runs merge as drawn, and a run ends
1111    /// where the marked-content segment does.
1112    raw_runs: bool,
1113    /// The marked-content segment each span was drawn in, in span order; only
1114    /// filled when [`Self::raw_runs`] is on. `None` outside any segment (an
1115    /// untagged page never opens one).
1116    raw_bdc: Vec<Option<u32>>,
1117    /// The open marked-content segments, innermost last; only tracked when
1118    /// [`Self::raw_runs`] is on.
1119    raw_bdc_stack: Vec<u32>,
1120    /// The next marked-content segment number; only used when
1121    /// [`Self::raw_runs`] is on.
1122    raw_bdc_next: u32,
1123    /// Whether a segment opened or closed since the last glyph was drawn;
1124    /// only set when [`Self::raw_runs`] is on. The next glyph starts a fresh
1125    /// span, so a run never crosses the segment it was drawn in.
1126    raw_bdc_changed: bool,
1127    /// The page's declared name of the face the run being built is set in,
1128    /// subset prefix stripped; only tracked when [`Self::raw_runs`] is on.
1129    /// The program substitutes one face for several of the page's --
1130    /// `HelveticaNeueLTStd-BdOu` and `-Blk` both reach the spans as
1131    /// `Helvetica` -- so the merge face cannot tell `20` from `25`, and the
1132    /// page's own name is what parts them.
1133    last_raw_declared: Option<String>,
1134    /// Where the last glyph drawn ended, and the unit advance direction it
1135    /// read in; only tracked when [`Self::raw_runs`] is on. A turned glyph
1136    /// joins its run along the way it reads, so the pen is a point here,
1137    /// not the x the default path keeps.
1138    raw_pen: [f64; 2],
1139    raw_dir: [f64; 2],
1140    /// Whether `raw_pen` holds a glyph; only tracked when [`Self::raw_runs`]
1141    /// is on.
1142    raw_pen_valid: bool,
1143    /// The open marked-content sequences, innermost last. Tracked only while
1144    /// [`Self::actual_text`] applies replacements; ignored otherwise, so the
1145    /// default output is what it was before the replacements were read. The
1146    /// stack survives `Do` into a Form XObject because the same device reads
1147    /// the nested stream.
1148    actual_text_stack: Vec<ActualTextEntry>,
1149    /// Whether `/ActualText` replacements apply. Off, because the default
1150    /// output is what it was before the replacements were read. Set this
1151    /// when the caller asked for the replacements through
1152    /// `ExtractOptions::actual_text` (#663).
1153    actual_text: bool,
1154    /// The annotation appearances being drawn, innermost last, in page
1155    /// space: the rectangle each annotation shows its appearance in. A
1156    /// glyphless replacement inside one is the value the widget shows --
1157    /// the only place that value is painted -- so it reads even where
1158    /// replacements on the page stay off.
1159    annotation_appearances: Vec<kurbo::Rect>,
1160    /// Where the last glyph's own text went, so that it can be read again
1161    /// when the next glyph arrives. See [`LastGlyphText`].
1162    last_glyph_text: Option<LastGlyphText>,
1163    /// This device's number, which names its entry in [`LAST_GLYPH`].
1164    id: u64,
1165    /// Whether [`LAST_GLYPH`] names this device, so that it is written once
1166    /// rather than at every glyph (spec, "Performance").
1167    holds_last_glyph: bool,
1168    /// Clears this device's entry in [`LAST_GLYPH`] when the device goes,
1169    /// unwinding included.
1170    last_glyph_guard: ClearLastGlyph,
1171}
1172
1173/// The number the next [`TextExtractionDevice`] gets.
1174static NEXT_DEVICE: AtomicU64 = AtomicU64::new(0);
1175
1176/// Empties [`LAST_GLYPH`] when dropped if the entry is the device's whose
1177/// number it holds, so no glyph -- and no interpreter font behind it --
1178/// outlives the device that drew it, even when extraction panics (spec
1179/// review 14-10 02:00 (i)).
1180struct ClearLastGlyph(u64);
1181
1182impl ClearLastGlyph {
1183    /// Empties [`LAST_GLYPH`] if it names this device.
1184    fn clear(&self) {
1185        let _ = LAST_GLYPH.try_with(|slot| {
1186            if slot.get() == Some(self.0) {
1187                slot.set(None);
1188            }
1189        });
1190    }
1191}
1192
1193impl Drop for ClearLastGlyph {
1194    fn drop(&mut self) {
1195        self.clear();
1196    }
1197}
1198
1199/// Whether [`LAST_GLYPH`] holds a glyph on this thread.
1200#[cfg(test)]
1201pub(crate) fn last_glyph_is_held() -> bool {
1202    LAST_GLYPH.with(|slot| slot.get().is_some())
1203}
1204
1205thread_local! {
1206    /// The number of the device on this thread that holds a last glyph.
1207    ///
1208    /// The glyph itself is not kept: the device keeps its place on the page
1209    /// (a plain value, so the device stays `Send`), and the next glyph says
1210    /// what the glyph at that place reads as now when drawing it changed
1211    /// that (`OutlineGlyph::reread_previous`).
1212    static LAST_GLYPH: Cell<Option<u64>> = const { Cell::new(None) };
1213}
1214
1215/// Where the last glyph's text went, so that it can be read again.
1216///
1217/// The interpreter offers a combining mark from the AMS name rows (spec
1218/// `specs/text/circlecopyrt.md`, C4 and C5a) to the glyph on the other side
1219/// of it once it has seen both, and the glyph already handed over may then
1220/// read differently: a `c` drawn before its circle reads `c⃝`, and a slash
1221/// drawn before the `=` it strikes reads as nothing, the `=` then reading
1222/// `≠`. The device reads the last glyph again at the next one, but only when
1223/// its text still stands last in the last span, so that what it reads now
1224/// can go in place of what it read then; reading it is what takes the offer,
1225/// and a glyph that cannot be read again leaves the offer to be refused.
1226/// Only the text changes: the glyph's box stays, and the pen stays where the
1227/// first reading left it, except that a glyph that now reads as nothing
1228/// gives its box back.
1229struct LastGlyphText {
1230    /// Where the glyph stands on its page, set when it is remembered.
1231    place: Option<GlyphPlace>,
1232    /// The span the text went into, and where in its text it starts.
1233    span: usize,
1234    offset: usize,
1235    /// How long the text was, in bytes.
1236    len: usize,
1237    /// Whether the glyph read as nothing and so wrote no text and no box.
1238    boxless: bool,
1239    /// Whether the glyph opened the span.
1240    opened: bool,
1241    /// What the span held before this glyph: glyph boxes, width, and (for a
1242    /// glyph that opened it) the previous span's run end under raw runs.
1243    char_bounds: usize,
1244    width: f64,
1245    previous_complete_run: Option<bool>,
1246    /// The rich-geometry records before this glyph's.
1247    deferred: usize,
1248    /// How many spans there were after this glyph, and the last one's text
1249    /// length and box count: the glyph is still last while these hold.
1250    span_count: usize,
1251    tail_text: usize,
1252    tail_boxes: usize,
1253}
1254
1255/// Lightweight glyph data recorded during extraction for deferred tight-bounds
1256/// computation. Avoids computing bounds on the hot path.
1257#[derive(Clone)]
1258struct DeferredGlyph {
1259    /// Transform coefficients [a,b,c,d,e,f] from composed (CTM * glyph_transform)
1260    coeffs: [f64; 6],
1261    /// Font size in points
1262    font_size: f64,
1263    /// Advance width in page-space points
1264    glyph_width: f64,
1265    /// Whether this glyph needs transform-then-bound (rotation/shear)
1266    needs_exact: bool,
1267    /// The glyph outline path in raw glyph space. Retained ONLY for
1268    /// rotated/sheared glyphs (`needs_exact`), where a tight bound requires
1269    /// bounding the transformed path; `None` for axis-aligned glyphs (the
1270    /// `font_bbox` suffices) and for ink-less glyphs (spaces, Type3).
1271    outline: Option<BezPath>,
1272    /// Pre-computed bounding box of the outline in raw glyph space. `None` when
1273    /// the glyph has no ink (space, Type3, or empty outline) → Estimate.
1274    font_bbox: Option<kurbo::Rect>,
1275}
1276
1277const MEDIAN_REFRESH: usize = 32;
1278
1279/// How far apart two glyphs' type sizes may stand and still merge into one
1280/// raw run, as a fraction of the larger (#643). Within a run the size is one
1281/// Tm and never varies; across runs the page changes it wholesale (`MM` at
1282/// 7pt against its 4.95pt space is twenty-nine per cent).
1283const RAW_SIZE_TOLERANCE: f64 = 0.01;
1284
1285impl Default for TextExtractionDevice {
1286    fn default() -> Self {
1287        Self::new()
1288    }
1289}
1290
1291impl TextExtractionDevice {
1292    /// Create a new text extraction device with Basic geometry (default).
1293    pub fn new() -> Self {
1294        Self::with_mode(GeometryMode::Basic)
1295    }
1296
1297    /// Create a new text extraction device with the given geometry mode.
1298    pub fn with_mode(geometry_mode: GeometryMode) -> Self {
1299        let id = NEXT_DEVICE.fetch_add(1, AtomicOrdering::Relaxed);
1300        Self {
1301            spans: Vec::new(),
1302            last_y: f64::NEG_INFINITY,
1303            last_end_x: f64::NEG_INFINITY,
1304            recent_gaps: Vec::new(),
1305            last_fill: None,
1306            last_widened: 0.0,
1307            last_merge_face: None,
1308            inserted_spaces: Vec::new(),
1309            run_start: 0,
1310            last_letter_gap: None,
1311            pending_tj_offset: 0.0,
1312            glyph_widths: Vec::new(),
1313            style_cache: HashMap::new(),
1314            sorted_glyph_widths: Vec::new(),
1315            nan_glyph_width_seen: false,
1316            cached_median_char_width: 0.0,
1317            geometry_mode,
1318            deferred_rich_glyphs: Vec::new(),
1319            keep_ligatures: false,
1320            document_font_names: false,
1321            rules: Vec::new(),
1322            rulings: Vec::new(),
1323            fills: Vec::new(),
1324            images: Vec::new(),
1325            path_boxes: Vec::new(),
1326            clip_stack: Vec::new(),
1327            raw_runs: false,
1328            raw_bdc: Vec::new(),
1329            raw_bdc_stack: Vec::new(),
1330            raw_bdc_next: 0,
1331            raw_bdc_changed: false,
1332            last_raw_declared: None,
1333            raw_pen: [0.0, 0.0],
1334            raw_dir: [1.0, 0.0],
1335            raw_pen_valid: false,
1336            actual_text_stack: Vec::new(),
1337            actual_text: false,
1338            annotation_appearances: Vec::new(),
1339            last_glyph_text: None,
1340            id,
1341            holds_last_glyph: false,
1342            last_glyph_guard: ClearLastGlyph(id),
1343        }
1344    }
1345
1346    fn push_placed_span(&mut self, text: &str, rect: kurbo::Rect) {
1347        if text.is_empty() {
1348            return;
1349        }
1350
1351        let height = if rect.height().is_finite() && rect.height() > 0.0 {
1352            rect.height()
1353        } else {
1354            0.0
1355        };
1356        let width = if rect.width().is_finite() && rect.width() > 0.0 {
1357            rect.width()
1358        } else {
1359            0.0
1360        };
1361
1362        // A NEW SPAN IS A NEW RUN, so what the last one was tracked at says
1363        // nothing about this one (#582).
1364        self.recent_gaps.clear();
1365        self.inserted_spaces.clear();
1366        self.run_start = 0;
1367        self.last_letter_gap = None;
1368        self.last_merge_face = None;
1369        self.spans.push(TextSpan {
1370            text: text.to_string(),
1371            x: rect.x0,
1372            y: rect.y0,
1373            width,
1374            height,
1375            font_size: height,
1376            // Placed text is one synthetic span, never a content run (#643).
1377            complete_run: false,
1378            font_name: None,
1379            is_bold: false,
1380            is_italic: false,
1381            color: None,
1382            width_source: WidthSource::Estimate,
1383            char_bounds: Vec::new(),
1384            // The placement is a real statement about where these characters
1385            // are, in the same form a glyph's is -- scale on the diagonal,
1386            // position in the last pair -- so a caller that lays the text back
1387            // out has what it needs, and the coverage gate over these two
1388            // fields stays a gate rather than acquiring an exception.
1389            transform: Some([height / 1000.0, 0.0, 0.0, height / 1000.0, rect.x0, rect.y0]),
1390            font_weight: None,
1391            is_serif: None,
1392            is_monospace: None,
1393            // Filled: a field value is painted, not stroked and not hidden.
1394            render_mode: Some(0),
1395            // Placed, not recognized: no OCR layer wrote this.
1396            is_ocr_layer: false,
1397            font_metrics: None,
1398            geometry_mode: self.geometry_mode,
1399            bounds_source: BoundsSource::Estimate,
1400            tight_char_bounds: Vec::new(),
1401            glyph_advances: Vec::new(),
1402            glyph_bounds_sources: Vec::new(),
1403        });
1404
1405        // The pen follows, so that anything drawn after this reads its gap
1406        // against where the placed text ended rather than against a glyph
1407        // three widgets back.
1408        self.last_y = rect.y0;
1409        self.last_end_x = rect.x1;
1410        self.last_widened = 0.0;
1411        self.pending_tj_offset = 0.0;
1412    }
1413
1414    /// Hand back the ligature code points the font declared -- `ffi` rather than
1415    /// `ffi` -- from the TEXT outputs ([`Self::into_text`], [`Self::into_blocks`]).
1416    ///
1417    /// The default folds, because the extracted string is read by a search
1418    /// index, a RAG chunker or a person pasting a word into a search box, and
1419    /// none of them will match a ligature code point against the letters
1420    /// somebody types. Set this when the caller wants the file's own
1421    /// characters back: a typesetting tool, or a comparison against the
1422    /// content stream. The paragraph blocks ([`Self::into_paragraph_blocks`])
1423    /// are unfolded either way: they feed the decisions, and the fold happens
1424    /// after them (see [`crate::ligatures`]).
1425    #[must_use]
1426    pub fn keep_ligatures(mut self, keep: bool) -> Self {
1427        self.keep_ligatures = keep;
1428        self
1429    }
1430
1431    /// Merge glyphs into the runs the page draws, for the raw-order path
1432    /// (`ExtractOptions::raw_order`, #643).
1433    pub fn raw_runs(mut self, raw: bool) -> Self {
1434        self.raw_runs = raw;
1435        self
1436    }
1437
1438    /// Report the document's own font names and the descriptor's metrics on
1439    /// spans: the page's /BaseFont and /Ascent//Descent instead of the
1440    /// substitute face's. Off, because the default output is what it was
1441    /// before the E34 truth existed. Set this when the caller asked for the
1442    /// document truth through `ExtractOptions::document_font_names` (#643).
1443    #[must_use]
1444    pub fn document_font_names(mut self, report: bool) -> Self {
1445        self.document_font_names = report;
1446        self
1447    }
1448
1449    /// Apply `/ActualText` replacements to the spans. Off, because the
1450    /// default output is what it was before the replacements were read. Set
1451    /// this when the caller asked for the replacements through
1452    /// `ExtractOptions::actual_text` (#663).
1453    #[must_use]
1454    pub fn actual_text(mut self, apply: bool) -> Self {
1455        self.actual_text = apply;
1456        self
1457    }
1458
1459    /// Whether the device is inside an annotation appearance stream: a
1460    /// replacement there is the annotation's own statement about what it
1461    /// shows, so the marked-content stack is tracked even where replacements
1462    /// on the page stay off.
1463    fn in_annotation_appearance(&self) -> bool {
1464        !self.annotation_appearances.is_empty()
1465    }
1466
1467    /// Fold a painted box, in page space, into every open marked-content
1468    /// sequence: the marks a glyphless replacement reads at when it closes.
1469    fn note_drawn_box(&mut self, drawn: [f64; 4]) {
1470        for open in self.actual_text_stack.iter_mut() {
1471            open.extent = Some(match open.extent {
1472                Some(before) => union(&before, &drawn),
1473                None => drawn,
1474            });
1475        }
1476    }
1477
1478    /// Where a glyphless replacement reads: the marks its sequence drew, or
1479    /// the annotation showing the sequence when it drew nothing placeable.
1480    fn glyphless_placement(&self, extent: Option<[f64; 4]>) -> Option<kurbo::Rect> {
1481        if let Some([x0, y0, x1, y1]) = extent {
1482            if x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() {
1483                return Some(kurbo::Rect::new(x0, y0, x1, y1));
1484            }
1485        }
1486        self.annotation_appearances.last().copied()
1487    }
1488
1489    /// Read the replacement of a marked-content sequence that closes without
1490    /// a glyph having carried it: once, at the marks it drew. A sequence
1491    /// that already answered -- on a glyph, or through a nested sequence
1492    /// that did -- stays quiet, so a replacement inside a replacement reads
1493    /// once and the innermost answers.
1494    fn emit_glyphless_replacement(&mut self, entry: ActualTextEntry) {
1495        let answered = entry.consumed || entry.child_resolved;
1496        if answered || entry.saw_glyph || entry.actual_text.is_none() {
1497            // The sequence already stands in the text -- its replacement
1498            // read on a glyph, a nested sequence answered for it, or its
1499            // glyphs read as drawn -- or it carries no replacement. A nested
1500            // answer still quiets the sequence around it.
1501            if answered {
1502                if let Some(parent) = self.actual_text_stack.last_mut() {
1503                    parent.child_resolved = true;
1504                }
1505            }
1506            return;
1507        }
1508        let replacement = entry.actual_text.as_deref().unwrap_or("");
1509        if let Some(at) = self.glyphless_placement(entry.extent) {
1510            if !replacement.is_empty() {
1511                self.push_placed_span(replacement, at);
1512            }
1513        }
1514        if let Some(parent) = self.actual_text_stack.last_mut() {
1515            parent.child_resolved = true;
1516        }
1517    }
1518
1519    /// Drop the spans whose centre falls outside `rect` (`[x0, y0, x1, y1]`
1520    /// in user space, y up), before segmentation turns the rest into blocks.
1521    /// Called with the rectangle `ExtractOptions::clip_margins` leaves on the
1522    /// page: content the margins cover is never read.
1523    pub(crate) fn clip_spans(&mut self, rect: [f64; 4]) {
1524        self.spans.retain(|span| {
1525            let centre = (span.x + span.width * 0.5, span.y + span.height * 0.5);
1526            centre.0 >= rect[0] && centre.0 <= rect[2] && centre.1 >= rect[1] && centre.1 <= rect[3]
1527        });
1528    }
1529
1530    /// Refresh the cached median char width. Called lazily from
1531    /// `draw_glyph` to keep the hot path cheap.
1532    fn refresh_median_char_width(&mut self) {
1533        if self.glyph_widths.is_empty() {
1534            self.cached_median_char_width = 0.0;
1535            return;
1536        }
1537        let fresh = &self.glyph_widths[self.sorted_glyph_widths.len()..];
1538        if !self.nan_glyph_width_seen && fresh.iter().any(|w| w.is_nan()) {
1539            self.nan_glyph_width_seen = true;
1540            self.sorted_glyph_widths = Vec::new();
1541        }
1542        if self.nan_glyph_width_seen {
1543            let mut sorted = self.glyph_widths.clone();
1544            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1545            self.cached_median_char_width = sorted[sorted.len() / 2];
1546            return;
1547        }
1548        // Without a NaN, `partial_cmp` is a total order and the sorted
1549        // sample is the one order there is: sorting the new widths and
1550        // merging them into the sorted ones gives it, and `len / 2` indexes
1551        // the same value the full sort put there.
1552        let mut fresh = fresh.to_vec();
1553        fresh.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1554        let old = std::mem::take(&mut self.sorted_glyph_widths);
1555        let mut merged = Vec::with_capacity(old.len() + fresh.len());
1556        let (mut i, mut j) = (0, 0);
1557        while i < old.len() && j < fresh.len() {
1558            if fresh[j] < old[i] {
1559                merged.push(fresh[j]);
1560                j += 1;
1561            } else {
1562                merged.push(old[i]);
1563                i += 1;
1564            }
1565        }
1566        merged.extend_from_slice(&old[i..]);
1567        merged.extend_from_slice(&fresh[j..]);
1568        self.cached_median_char_width = merged[merged.len() / 2];
1569        self.sorted_glyph_widths = merged;
1570    }
1571
1572    /// The style of `glyph`, read off its font once and remembered.
1573    ///
1574    /// `derive_glyph_style` depends on the font, the resource name the font
1575    /// was set under and the device's own `document_font_names`, and on
1576    /// nothing else about the glyph, so one answer serves every glyph of a
1577    /// font. A Type 3 glyph has no font key and is read as before.
1578    fn glyph_style(&mut self, glyph: &Glyph<'_>) -> GlyphStyle {
1579        let Glyph::Outline(outline) = glyph else {
1580            return derive_glyph_style(glyph, self.document_font_names);
1581        };
1582        let resource = glyph.font_resource_name();
1583        let styles = self
1584            .style_cache
1585            .entry(outline.font_cache_key())
1586            .or_default();
1587        if let Some((_, style)) = styles.iter().find(|(name, _)| name.as_deref() == resource) {
1588            return style.clone();
1589        }
1590        let style = derive_glyph_style(glyph, self.document_font_names);
1591        styles.push((resource.map(str::to_owned), style.clone()));
1592        style
1593    }
1594
1595    /// Move the pen to the end of the glyph just drawn.
1596    ///
1597    /// ANN[r17/TEX5] A combining mark -- a Hebrew point, an Arabic harakat --
1598    /// is drawn on the glyph before it and leaves the pen where it was. Letting
1599    /// it move the pen back to its own origin makes the next letter look an
1600    /// advance away, and that gap then reads as a word break: `שָׁלוֹם` came
1601    /// apart into `שָׁל` and `וֹם` (#436). The declared advance cannot answer
1602    /// this, because `glyph_width_and_source` clamps a zero one up to a quarter
1603    /// of the font size before anything sees it.
1604    fn advance_pen(&mut self, text: &str, glyph_end_x: f64, widened: f64) {
1605        if !crate::bidi::is_only_marks(text) {
1606            self.last_end_x = glyph_end_x;
1607            self.last_widened = widened;
1608        }
1609    }
1610
1611    /// Move the raw pen past a glyph, for the turned-run judgment (#643).
1612    /// A no-op unless [`Self::raw_runs`] is on; marks never move it, exactly
1613    /// like [`Self::advance_pen`].
1614    fn advance_raw_pen(
1615        &mut self,
1616        text: &str,
1617        x: f64,
1618        y: f64,
1619        adv_x: f64,
1620        adv_y: f64,
1621        glyph_width: f64,
1622    ) {
1623        if !self.raw_runs || crate::bidi::is_only_marks(text) {
1624            return;
1625        }
1626        self.raw_pen = [x + adv_x * glyph_width, y + adv_y * glyph_width];
1627        self.raw_dir = [adv_x, adv_y];
1628        self.raw_pen_valid = true;
1629    }
1630
1631    /// Decide whether a space should be glued between two glyphs within
1632    /// the same span. Returns (insert_space, start_new_span).
1633    fn evaluate_space_consensus(
1634        &self,
1635        gap: f64,
1636        font_size: f64,
1637        prev_text: &str,
1638        next_text: &str,
1639    ) -> bool {
1640        let mut confidence = 0.0;
1641
1642        // Signal 1 — TJ offset (highest confidence). Raw units; a full
1643        // space is ~250. Anything over TJ_SPACE_THRESHOLD_UNITS counts.
1644        //
1645        // ONLY A NEGATIVE ONE, because only a negative TJ moves the next glyph
1646        // right, away from the last. A positive one is a pair kern pulling it
1647        // back: `[(FINANCIAL RESUL)134.6(TS)] TJ` in NYSE_MS_2001 overlaps
1648        // its `L` and `T` by a point, and counted by its size it wrote
1649        // `FINANCIAL RESUL TS` and `MANAGEMENT'S DISCUSSION AND ANAL YSIS`,
1650        // two headings the ground truth could not match (#638 rank 4).
1651        if self.pending_tj_offset <= -TJ_SPACE_THRESHOLD_UNITS {
1652            confidence += TJ_SIGNAL_WEIGHT;
1653        }
1654
1655        // Signal 2 — geometric gap. Prefer the adaptive median-char-width
1656        // reference; fall back to font-size when the median hasn't been
1657        // established yet (first few glyphs on a page).
1658        let gap_reference = if self.cached_median_char_width > 0.0 {
1659            self.cached_median_char_width * GAP_TO_MEDIAN_CHAR_FRACTION
1660        } else {
1661            font_size * GAP_TO_FONT_SIZE_FALLBACK_FRACTION
1662        };
1663        if gap > gap_reference {
1664            confidence += GAP_SIGNAL_WEIGHT;
1665        }
1666
1667        // Signal 3 — character-class transitions. Only checked when the
1668        // previous span ends with a character and the incoming text starts
1669        // with one; avoids double-counting with punctuation.
1670        if let (Some(prev_last), Some(next_first)) =
1671            (prev_text.chars().last(), next_text.chars().next())
1672        {
1673            let camel = prev_last.is_lowercase() && next_first.is_uppercase();
1674            let digit_to_letter = prev_last.is_ascii_digit() && next_first.is_alphabetic();
1675            let letter_to_digit = prev_last.is_alphabetic() && next_first.is_ascii_digit();
1676            if camel || digit_to_letter || letter_to_digit {
1677                confidence += HEURISTIC_SIGNAL_WEIGHT;
1678            }
1679        }
1680
1681        confidence >= SPACE_CONSENSUS_THRESHOLD
1682    }
1683
1684    /// Whether this gap is the typesetter's TRACKING rather than a word space
1685    /// (#582).
1686    ///
1687    /// A display line set in letter-spaced capitals puts the same distance
1688    /// between every pair of letters, and that distance is over every
1689    /// threshold a word space has: the TJ adjustment is a full one and the
1690    /// geometric gap is far past a third of a character. So the consensus
1691    /// fires on EVERY pair and `DIRECTORS' REPORT` comes out as
1692    /// `D I R E C TO R S ' R E P O R T` -- which is 47 of the 200 pages of
1693    /// run `structured-doclaynet-20260922T081244Z` writing text the ground
1694    /// truth does not have, and, on the heading axis, 65 of 154 missed
1695    /// headings (#583).
1696    ///
1697    /// WHAT SEPARATES THEM IS NOT THE LETTERS BUT THE REGULARITY. Tracking is
1698    /// a constant added to every advance, so a tracked run's gaps agree with
1699    /// one another; prose has word spaces at the ends of words and kerning
1700    /// everywhere else, and four consecutive gaps that agree within a quarter
1701    /// while every one of them is over the word-space threshold is not a
1702    /// sentence. Nothing here reads the text, so it holds for a language whose
1703    /// capitals this file has never seen.
1704    ///
1705    /// AND THE REAL WORD SPACE IS NOT LOST, because a tracked line still draws
1706    /// its word space as a space GLYPH: `DIRECTORS' REPORT` carries a 0x20 in
1707    /// the string the page shows, and that arrives as text rather than as a
1708    /// gap for this to judge.
1709    ///
1710    /// WHERE IT PAYS, AND WHY THAT IS A CHOICE AND NOT AN OVERSIGHT. A page
1711    /// that draws `A B C D E F` as separate glyphs with equal gaps and no
1712    /// space glyphs is, byte for byte, the same page as a letter-spaced
1713    /// display line: both are single characters at one constant advance, and
1714    /// nothing on the page says which the author meant. This takes the reading
1715    /// that is far more common and that the corpus measured -- letter-spacing,
1716    /// 47 of 200 pages -- and pays for it on a line of isolated single-letter
1717    /// words, which is rare. The price is pinned by
1718    /// `a_line_of_single_letter_words_reads_as_tracking_and_that_is_the_choice`
1719    /// so that a later reader argues with the note rather than with a silence.
1720    fn a_tracked_run(&self, gap: f64) -> bool {
1721        if gap <= 0.0 || self.recent_gaps.len() < TRACKING_MIN_PAIRS {
1722            return false;
1723        }
1724        self.recent_gaps
1725            .iter()
1726            .rev()
1727            .take(TRACKING_MIN_PAIRS)
1728            .all(|before| (before - gap).abs() <= TRACKING_TOLERANCE * gap)
1729    }
1730
1731    /// Take back the spaces already written into a run now recognised as
1732    /// tracked.
1733    ///
1734    /// The regularity needs [`TRACKING_MIN_PAIRS`] earlier gaps before it can be
1735    /// seen, so the first letters of the run have had their spaces inserted
1736    /// already: without this, `DIRECTORS` comes back as `D I R E CTORS`.
1737    ///
1738    /// ONLY THE SPACES THE CONSENSUS PUT IN, AT THE TRACKING (#623). The join
1739    /// used to take back every space between single characters at the end of
1740    /// the text, and a space the page DREW looks the same there: `IT ALL ADDS
1741    /// UP` (NYSE_WAL_2014) draws its word spaces as glyphs, and the run came out
1742    /// `ITALL`. It also stopped at the first piece longer than one letter, so a
1743    /// narrow `I` that took no space left `D IR ECTORS`. A space goes when the
1744    /// consensus inserted it at a gap the tracking explains, inside the run
1745    /// the tracking was seen in, between two letters; a word space set wider
1746    /// than the tracking, drawn or not, stays -- and so does the space of
1747    /// `suppose that`, which a formula's regular gaps further on matched
1748    /// (1201.4397).
1749    fn take_back_tracked_spaces(
1750        text: &mut String,
1751        inserted: &mut Vec<(usize, f64)>,
1752        run_start: usize,
1753        tracking: f64,
1754    ) {
1755        let between_letters = |at: usize| {
1756            text[..at]
1757                .chars()
1758                .next_back()
1759                .is_some_and(char::is_alphabetic)
1760                && text[at + 1..]
1761                    .chars()
1762                    .next()
1763                    .is_some_and(char::is_alphabetic)
1764        };
1765        let goes = |at: usize, gap: f64| {
1766            at >= run_start
1767                && at < text.len()
1768                && text.as_bytes()[at] == b' '
1769                && (gap - tracking).abs() <= TRACKING_TOLERANCE * tracking
1770                && between_letters(at)
1771        };
1772        if !inserted.iter().any(|&(at, gap)| goes(at, gap)) {
1773            return;
1774        }
1775        let goes: Vec<bool> = inserted.iter().map(|&(at, gap)| goes(at, gap)).collect();
1776        let bytes = text.as_bytes();
1777        let mut kept = String::with_capacity(text.len());
1778        let mut still: Vec<(usize, f64)> = Vec::with_capacity(inserted.len());
1779        let mut from = 0;
1780        for (&(at, gap), &goes) in inserted.iter().zip(&goes) {
1781            if at < from || at >= text.len() || bytes[at] != b' ' {
1782                continue;
1783            }
1784            kept.push_str(&text[from..at]);
1785            if goes {
1786                from = at + 1;
1787            } else {
1788                still.push((kept.len(), gap));
1789                kept.push(' ');
1790                from = at + 1;
1791            }
1792        }
1793        kept.push_str(&text[from..]);
1794        *text = kept;
1795        *inserted = still;
1796    }
1797
1798    /// Keep a drawn path when it is a horizontal rule (#545).
1799    ///
1800    /// The whole of the page's graphics arrives here and almost none of it is
1801    /// kept: what is, is ink that is a line -- flat enough not to be a box and
1802    /// long enough to separate two rows -- recorded in the page space the
1803    /// spans already live in, so the two can be compared without a second
1804    /// coordinate system to keep in step. The box is the transformed bounding
1805    /// box, which for a rotated path is the box around it and therefore too
1806    /// tall to be a rule: a rule on a turned page is not read as one, and that
1807    /// is the safe way round.
1808    ///
1809    /// A stroke is measured on its centreline, since that is the path the
1810    /// device is given and the weight is a property of the pen. A heavy rule is
1811    /// still a rule, and it still lies where its centreline lies.
1812    fn record_rule(&mut self, path: &BezPath, transform: Affine) {
1813        // The cap is asked at the door AND at every push. One path may hold
1814        // hundreds of subpaths, so a check that runs once per call is not a
1815        // bound on anything: it lets a single compound path put thousands of
1816        // entries in a vector `region_is_ruled` then walks per band.
1817        if self.rules.len() >= MAX_RECORDED_RULES {
1818            return;
1819        }
1820        // SUBPATH BY SUBPATH, because a producer is free to draw all of a
1821        // table's rules in one path, and a box around the lot of them is a box
1822        // and not a line. The bound is taken over the points a segment is made
1823        // of, curve controls included -- a curve lies inside its own hull, so
1824        // the box is never too small, and the arithmetic is a comparison per
1825        // point rather than solving each curve for its extremes. On the slowest
1826        // document of the gate set, which draws a figure of several thousand
1827        // segments, exact curve bounds cost 7% of its 3.3 s; this costs 1.6%.
1828        // WHAT IS DRAWN IS NOT WHAT IS SEEN. A Form XObject's `/BBox` reaches
1829        // this device as a clip and the geometry inside it unclipped, so a line
1830        // ruled across one cell of a form can arrive as a line across the page.
1831        // Two of those are enough for `region_is_ruled` to refuse a column cut
1832        // that should have been taken, so the extent recorded is the visible
1833        // one.
1834        let clip = self.clip_stack.last().copied();
1835        let mut lo = (f64::INFINITY, f64::INFINITY);
1836        let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
1837        let flush = |lo: &mut (f64, f64), hi: &mut (f64, f64), rules: &mut Vec<HorizontalRule>| {
1838            let (mut left, mut right) = (lo.0, hi.0);
1839            let (mut bottom, mut top) = (lo.1, hi.1);
1840            if let Some(clip) = clip {
1841                left = left.max(clip.x0);
1842                right = right.min(clip.x1);
1843                bottom = bottom.max(clip.y0);
1844                top = top.min(clip.y1);
1845            }
1846            if lo.0.is_finite()
1847                && rules.len() < MAX_RECORDED_RULES
1848                && bottom <= top
1849                && hi.1 - lo.1 <= RULE_MAX_THICKNESS
1850                && right - left >= RULE_MIN_LENGTH
1851            {
1852                rules.push(HorizontalRule {
1853                    left,
1854                    right,
1855                    y: (lo.1 + hi.1) * 0.5,
1856                });
1857            }
1858            *lo = (f64::INFINITY, f64::INFINITY);
1859            *hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
1860        };
1861
1862        for element in path.elements() {
1863            if matches!(element, PathEl::MoveTo(_) | PathEl::ClosePath) {
1864                flush(&mut lo, &mut hi, &mut self.rules);
1865            }
1866            for point in element_points(element) {
1867                let p = transform * point;
1868                lo = (lo.0.min(p.x), lo.1.min(p.y));
1869                hi = (hi.0.max(p.x), hi.1.max(p.y));
1870            }
1871        }
1872        flush(&mut lo, &mut hi, &mut self.rules);
1873    }
1874
1875    /// Keep a drawn path's straight axis-aligned strokes for the lattice
1876    /// (#574): each subpath that is a thin line, in either direction, and the
1877    /// four edges of each subpath that is an axis-aligned rectangle -- the way
1878    /// a timetable draws its cells. Anything else, a curve or a slanted line,
1879    /// is not a grid and is not kept.
1880    fn record_rulings(&mut self, path: &BezPath, transform: Affine) {
1881        if self.rulings.len() >= MAX_RECORDED_RULINGS {
1882            return;
1883        }
1884        let clip = self.clip_stack.last().copied();
1885        let mut points: Vec<Point> = Vec::new();
1886        let mut curved = false;
1887        let mut subpaths: Vec<(Vec<Point>, bool)> = Vec::new();
1888        for element in path.elements() {
1889            if matches!(element, PathEl::MoveTo(_)) && !points.is_empty() {
1890                subpaths.push((std::mem::take(&mut points), curved));
1891                curved = false;
1892            }
1893            if matches!(element, PathEl::QuadTo(..) | PathEl::CurveTo(..)) {
1894                curved = true;
1895            }
1896            for point in element_points(element) {
1897                points.push(transform * point);
1898            }
1899        }
1900        if !points.is_empty() {
1901            subpaths.push((points, curved));
1902        }
1903        for (points, curved) in subpaths {
1904            if curved || points.len() < 2 {
1905                continue;
1906            }
1907            let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);
1908            let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
1909            for p in &points {
1910                x0 = x0.min(p.x);
1911                y0 = y0.min(p.y);
1912                x1 = x1.max(p.x);
1913                y1 = y1.max(p.y);
1914            }
1915            let clipped = |r: Ruling| -> Option<Ruling> {
1916                let Some(c) = clip else {
1917                    return Some(r);
1918                };
1919                let r = Ruling {
1920                    x0: r.x0.max(c.x0),
1921                    y0: r.y0.max(c.y0),
1922                    x1: r.x1.min(c.x1),
1923                    y1: r.y1.min(c.y1),
1924                };
1925                (r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r)
1926            };
1927            let (w, h) = (x1 - x0, y1 - y0);
1928            let mut found: Vec<Ruling> = Vec::new();
1929            if h <= RULE_MAX_THICKNESS && w >= RULING_MIN_LENGTH {
1930                let y = (y0 + y1) * 0.5;
1931                found.push(Ruling {
1932                    x0,
1933                    y0: y,
1934                    x1,
1935                    y1: y,
1936                });
1937            } else if w <= RULE_MAX_THICKNESS && h >= RULING_MIN_LENGTH {
1938                let x = (x0 + x1) * 0.5;
1939                found.push(Ruling {
1940                    x0: x,
1941                    y0,
1942                    x1: x,
1943                    y1,
1944                });
1945            } else if w > RULE_MAX_THICKNESS && h > RULE_MAX_THICKNESS {
1946                // A rectangle, and only if every point is one of its corners.
1947                let corner = |p: &Point| {
1948                    ((p.x - x0).abs() < 0.5 || (p.x - x1).abs() < 0.5)
1949                        && ((p.y - y0).abs() < 0.5 || (p.y - y1).abs() < 0.5)
1950                };
1951                if points.iter().all(corner) && points.len() <= 6 {
1952                    found.push(Ruling { x0, y0, x1, y1: y0 });
1953                    found.push(Ruling { x0, y0: y1, x1, y1 });
1954                    found.push(Ruling { x0, y0, x1: x0, y1 });
1955                    found.push(Ruling { x0: x1, y0, x1, y1 });
1956                }
1957            }
1958            for r in found.into_iter().filter_map(clipped) {
1959                if self.rulings.len() >= MAX_RECORDED_RULINGS {
1960                    return;
1961                }
1962                self.rulings.push(r);
1963            }
1964        }
1965    }
1966
1967    /// The lattice rulings the page drew so far, taken out of the device
1968    /// (#574). Called before the device is consumed into its blocks.
1969    #[doc(hidden)]
1970    /// Keep a filled path that is a solid, non-white, axis-aligned rectangle
1971    /// big enough to stand behind a line of text (#574): shading, a
1972    /// highlighted row, a note's box. Anything curved or patterned, anything
1973    /// near white -- a page's own background -- and anything past the cap is
1974    /// left out.
1975    fn record_fill(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'_>) {
1976        if self.fills.len() >= MAX_RECORDED_RULINGS {
1977            return;
1978        }
1979        let Paint::Color(color) = paint else {
1980            return;
1981        };
1982        let rgba = color.to_rgba().to_rgba8();
1983        let [r, g, b, a] = rgba;
1984        let luminance = (299 * u32::from(r) + 587 * u32::from(g) + 114 * u32::from(b)) / 1000;
1985        if a < 26 || luminance >= FILL_WHITE_ABOVE {
1986            return;
1987        }
1988        let mut points: Vec<Point> = Vec::new();
1989        for element in path.elements() {
1990            match element {
1991                PathEl::QuadTo(..) | PathEl::CurveTo(..) => return,
1992                PathEl::MoveTo(_) if !points.is_empty() => return,
1993                _ => points.extend(element_points(element).map(|p| transform * p)),
1994            }
1995        }
1996        if points.len() < 4 {
1997            return;
1998        }
1999        let rect = transform.transform_rect_bbox(path.bounding_box());
2000        // Axis-aligned: every corner on the box's edges.
2001        let on_edge = |p: &Point| {
2002            ((p.x - rect.x0).abs() < 0.5 || (p.x - rect.x1).abs() < 0.5)
2003                && ((p.y - rect.y0).abs() < 0.5 || (p.y - rect.y1).abs() < 0.5)
2004        };
2005        if !points.iter().all(on_edge) {
2006            return;
2007        }
2008        let mut rect = rect;
2009        if let Some(clip) = self.clip_stack.last() {
2010            rect = rect.intersect(*clip);
2011        }
2012        if rect.width() >= FILL_MIN_WIDTH && rect.height() >= FILL_MIN_HEIGHT {
2013            self.fills.push([rect.x0, rect.y0, rect.x1, rect.y1]);
2014        }
2015    }
2016
2017    /// The fills [`Self::record_fill`] kept, in page space.
2018    pub fn take_fills(&mut self) -> Vec<[f64; 4]> {
2019        std::mem::take(&mut self.fills)
2020    }
2021
2022    pub fn take_rulings(&mut self) -> Vec<Ruling> {
2023        std::mem::take(&mut self.rulings)
2024    }
2025
2026    /// The boxes the page drew its images into, `[left, bottom, right, top]`
2027    /// in page space, in drawing order (#636).
2028    #[doc(hidden)]
2029    pub fn take_images(&mut self) -> Vec<[f64; 4]> {
2030        std::mem::take(&mut self.images)
2031    }
2032
2033    /// The figures the page DRAWS rather than places as images, as boxes in
2034    /// page space (#636): the path boxes that touch or overlap, merged; a
2035    /// cluster counts when it holds at least three paths and is at least 20
2036    /// points each way, and not when it covers more than half the page -- a
2037    /// page's background or frame is not a figure anyone captions.
2038    #[doc(hidden)]
2039    pub fn take_graphics(&mut self, width: f64, height: f64) -> Vec<[f64; 4]> {
2040        let boxes = std::mem::take(&mut self.path_boxes);
2041        let mut clusters: Vec<([f64; 4], usize)> = Vec::new();
2042        for b in boxes {
2043            let mut merged = (b, 1usize);
2044            let mut rest = Vec::with_capacity(clusters.len());
2045            for c in clusters {
2046                if touches(&merged.0, &c.0, GRAPHIC_GAP) {
2047                    merged = (union(&merged.0, &c.0), merged.1 + c.1);
2048                } else {
2049                    rest.push(c);
2050                }
2051            }
2052            rest.push(merged);
2053            clusters = rest;
2054        }
2055        let page = (width * height).max(1.0);
2056        clusters
2057            .into_iter()
2058            .filter(|(b, n)| {
2059                let (w, h) = (b[2] - b[0], b[3] - b[1]);
2060                *n >= 3 && w >= 20.0 && h >= 20.0 && w * h <= 0.5 * page
2061            })
2062            .map(|(b, _)| b)
2063            .collect()
2064    }
2065
2066    fn record_path_box(&mut self, path: &BezPath, transform: Affine) {
2067        if self.path_boxes.len() >= MAX_RECORDED_PATH_BOXES {
2068            return;
2069        }
2070        let mut rect = transform.transform_rect_bbox(path.bounding_box());
2071        if let Some(clip) = self.clip_stack.last() {
2072            rect = rect.intersect(*clip);
2073        }
2074        // A THIN LINE IS A RULE, a ruling or an underline -- the table and
2075        // paragraph passes read those -- and not a part of a figure.
2076        if rect.width().min(rect.height()) >= 1.5 {
2077            self.path_boxes.push([rect.x0, rect.y0, rect.x1, rect.y1]);
2078        }
2079    }
2080
2081    /// Record where an image lands: its PIXEL rectangle under the transform
2082    /// the interpreter hands over -- which maps `0..width, 0..height`, not the
2083    /// unit square of the content stream's `cm` -- clipped to the visible box. A sliver under a point wide is not a picture
2084    /// anyone would caption -- it is a rule or a dot drawn as an image -- and
2085    /// is left out, and so is anything past the cap.
2086    fn record_image(&mut self, width: u32, height: u32, transform: Affine) {
2087        if self.images.len() >= MAX_RECORDED_IMAGES {
2088            return;
2089        }
2090        let (w, h) = (f64::from(width.max(1)), f64::from(height.max(1)));
2091        let mut rect = transform.transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w, h));
2092        if let Some(clip) = self.clip_stack.last() {
2093            rect = rect.intersect(*clip);
2094        }
2095        if rect.width() >= 1.0 && rect.height() >= 1.0 {
2096            self.images.push([rect.x0, rect.y0, rect.x1, rect.y1]);
2097        }
2098    }
2099
2100    /// Consume the device and return extracted text as a single string.
2101    pub fn into_text(mut self) -> String {
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 keep_ligatures = self.keep_ligatures;
2108        let rules = std::mem::take(&mut self.rules);
2109        let mut blocks = group_spans_into_blocks(self.spans, &rules);
2110        if !keep_ligatures {
2111            // Folded on the spans, which the lines assemble from: the one
2112            // fold every TEXT output shares (see `crate::ligatures`).
2113            for block in &mut blocks {
2114                crate::ligatures::fold_text_block(block);
2115            }
2116        }
2117        let lines: Vec<String> = blocks.iter().map(|b| b.text()).collect();
2118        let stitched = stitch_hyphenated_lines(&lines);
2119        normalize_text_output(&stitched)
2120    }
2121
2122    /// Consume the device and return text blocks.
2123    pub fn into_blocks(mut self) -> Vec<TextBlock> {
2124        self.end_last_glyph();
2125        if self.geometry_mode == GeometryMode::RichGeometry {
2126            self.compute_tight_bounds();
2127        }
2128        self.spans.iter_mut().for_each(untrack_spaced_letters);
2129        let keep_ligatures = self.keep_ligatures;
2130        let rules = std::mem::take(&mut self.rules);
2131        let mut blocks = group_spans_into_blocks(self.spans, &rules);
2132        if !keep_ligatures {
2133            for block in &mut blocks {
2134                crate::ligatures::fold_text_block(block);
2135            }
2136        }
2137        blocks
2138    }
2139
2140    /// Consume the device and return the page's paragraphs (#567).
2141    ///
2142    /// `page` is the 0-based page index the blocks record, and `first_index`
2143    /// where this page's blocks start in the document's reading order, so a
2144    /// caller walking a document hands the count of what it has emitted so far.
2145    pub fn into_paragraph_blocks(mut self, page: usize, first_index: usize) -> Vec<Block> {
2146        self.end_last_glyph();
2147        if self.geometry_mode == GeometryMode::RichGeometry {
2148            self.compute_tight_bounds();
2149        }
2150        self.spans.iter_mut().for_each(untrack_spaced_letters);
2151        let rules = std::mem::take(&mut self.rules);
2152        // Unfolded: these blocks feed the layout and model decisions and the
2153        // training dump, which all read the characters the glyphs declared.
2154        // The fold happens on the way out of `PdfDocument`, after the
2155        // decisions (see `crate::ligatures`). Every table stands: the
2156        // single-page path takes no options, so no strategy steers it.
2157        paragraph_blocks(self.spans, &rules, page, first_index, TableStrategy::Both)
2158    }
2159
2160    /// The paragraphs AND the rules the page drew, in one pass (#600).
2161    ///
2162    /// The blocks are what a reader wants and the rules are what a model
2163    /// wants: a cell boundary in a ruled table is a drawn line, and a learned
2164    /// table structure that cannot see the lines is guessing at the strongest
2165    /// evidence the page offers. They come back together because the
2166    /// alternative is interpreting the page twice, and two passes over one
2167    /// page is two chances for them to disagree.
2168    ///
2169    /// Hidden rather than published: this is what
2170    /// `crates/pdf-learned`'s feature dump reads, and the shape of it follows
2171    /// whatever that needs rather than a promise to anyone else.
2172    #[doc(hidden)]
2173    pub fn into_paragraph_blocks_and_rules(
2174        self,
2175        page: usize,
2176        first_index: usize,
2177    ) -> (Vec<Block>, Vec<HorizontalRule>) {
2178        self.into_paragraph_blocks_and_rules_with_strategy(
2179            page,
2180            first_index,
2181            TableStrategy::Both,
2182            false,
2183        )
2184    }
2185
2186    /// [`Self::into_paragraph_blocks_and_rules`], with the table-detection
2187    /// strategy given: under [`TableStrategy::Lines`] a grid no drawn rule
2188    /// crosses goes back to the paragraph merge (#659).
2189    ///
2190    /// `cell_path_words` is
2191    /// [`ExtractOptions::cell_path_words`](crate::ExtractOptions::cell_path_words):
2192    /// the leader fold and the line and cell texts read with the cell-path
2193    /// word rules where true.
2194    ///
2195    /// Hidden like the twin: this is what `extract_blocks` reads, and the
2196    /// shape of it follows whatever that needs rather than a promise to
2197    /// anyone else.
2198    #[doc(hidden)]
2199    pub fn into_paragraph_blocks_and_rules_with_strategy(
2200        mut self,
2201        page: usize,
2202        first_index: usize,
2203        table_strategy: TableStrategy,
2204        cell_path_words: bool,
2205    ) -> (Vec<Block>, Vec<HorizontalRule>) {
2206        self.end_last_glyph();
2207        if self.geometry_mode == GeometryMode::RichGeometry {
2208            self.compute_tight_bounds();
2209        }
2210        self.spans.iter_mut().for_each(untrack_spaced_letters);
2211        let rules = std::mem::take(&mut self.rules);
2212        let blocks = paragraph_blocks_with_cell_path_words(
2213            self.spans,
2214            &rules,
2215            page,
2216            first_index,
2217            table_strategy,
2218            cell_path_words,
2219        );
2220        (blocks, rules)
2221    }
2222
2223    /// Consume the device and return the page's blocks in raw content-stream
2224    /// order (#643).
2225    ///
2226    /// Requires [`Self::raw_runs`]: the spans must be the runs the page
2227    /// draws, in the order it draws them. Blocks follow draw order with no
2228    /// reading-order sort; the frames turn with the page exactly as the
2229    /// default path's do.
2230    pub fn into_raw_blocks(mut self, page: usize, first_index: usize) -> Vec<Block> {
2231        self.end_last_glyph();
2232        debug_assert!(self.raw_runs, "raw blocks need raw runs");
2233        if self.geometry_mode == GeometryMode::RichGeometry {
2234            self.compute_tight_bounds();
2235        }
2236        self.spans.iter_mut().for_each(untrack_spaced_letters);
2237        // The page ends every run it drew (#643).
2238        if let Some(last) = self.spans.last_mut() {
2239            last.complete_run = true;
2240        }
2241        let bdc = std::mem::take(&mut self.raw_bdc);
2242        debug_assert_eq!(bdc.len(), self.spans.len());
2243        raw_blocks(self.spans, &bdc, page, first_index)
2244    }
2245
2246    /// Consume the device and return raw spans.
2247    #[allow(dead_code)]
2248    pub(crate) fn into_spans(mut self) -> Vec<TextSpan> {
2249        self.end_last_glyph();
2250        if self.geometry_mode == GeometryMode::RichGeometry {
2251            self.compute_tight_bounds();
2252        }
2253        self.spans.iter_mut().for_each(untrack_spaced_letters);
2254        self.spans
2255    }
2256
2257    /// Forget the last glyph where the device ends: no glyph follows, so its
2258    /// place is never read again and [`LAST_GLYPH`] no longer names this
2259    /// device.
2260    fn end_last_glyph(&mut self) {
2261        self.last_glyph_text = None;
2262        self.holds_last_glyph = false;
2263        self.last_glyph_guard.clear();
2264    }
2265
2266    /// Remember `glyph` as the last glyph and `record` as where its text went.
2267    ///
2268    /// A glyph the page hides (`hidden`), or one whose text lands in a span
2269    /// the page hides, is not remembered: hidden text can be dropped from the
2270    /// output after the fact, and a mark that went into it would go with it.
2271    /// Not read again, its offer is refused and the mark reads as before its
2272    /// row (spec review 14-10 02:00 (ii)).
2273    fn remember_last_glyph(
2274        &mut self,
2275        glyph: &OutlineGlyph,
2276        mut record: LastGlyphText,
2277        hidden: bool,
2278    ) {
2279        let tail = self.spans.last();
2280        if hidden || tail.is_some_and(TextSpan::is_hidden) {
2281            self.last_glyph_text = None;
2282            return;
2283        }
2284        record.span_count = self.spans.len();
2285        record.tail_text = tail.map_or(0, |span| span.text.len());
2286        record.tail_boxes = tail.map_or(0, |span| span.char_bounds.len());
2287        record.place = glyph.place();
2288        if record.place.is_none() {
2289            self.last_glyph_text = None;
2290            return;
2291        }
2292        if !self.holds_last_glyph {
2293            let id = self.id;
2294            self.holds_last_glyph = LAST_GLYPH.try_with(|slot| slot.set(Some(id))).is_ok();
2295        }
2296        self.last_glyph_text = self.holds_last_glyph.then_some(record);
2297    }
2298
2299    /// Whether the last glyph's text still stands last in the last span.
2300    fn last_glyph_still_last(&self, last: &LastGlyphText) -> bool {
2301        let tail = self.spans.last();
2302        self.spans.len() == last.span_count
2303            && tail.map_or(0, |span| span.text.len()) == last.tail_text
2304            && tail.map_or(0, |span| span.char_bounds.len()) == last.tail_boxes
2305    }
2306
2307    /// Read the last glyph's text again and put what it reads now in place of
2308    /// what it read then (see [`LastGlyphText`]). The glyph is read only while
2309    /// its text still stands last, so that the read is never one this device
2310    /// cannot act on; nothing changes when the text is unchanged.
2311    fn reread_last_glyph(&mut self, glyph: &Glyph<'_>) {
2312        let Some(last) = self.last_glyph_text.as_ref() else {
2313            return;
2314        };
2315        if !self.last_glyph_still_last(last) {
2316            self.last_glyph_text = None;
2317            return;
2318        }
2319        // Only the glyph arriving now can have changed the last one, and it
2320        // says so; on a page without a row mark it never has.
2321        let read = match (glyph, last.place) {
2322            (Glyph::Outline(outline), Some(place)) => outline.reread_previous(place),
2323            _ => None,
2324        };
2325        let Some(read) = read else {
2326            return;
2327        };
2328        let last = self.last_glyph_text.as_mut().expect("checked above");
2329        if last.boxless {
2330            // It wrote nothing, and a glyph that read as nothing is a mark
2331            // whose read gave its offer up; there is nothing to put back.
2332            return;
2333        }
2334        let span = &mut self.spans[last.span];
2335        // Compared in place: on a page without mark rows the text never
2336        // changes, and the comparison then costs no allocation.
2337        let written = &span.text[last.offset..];
2338        let unchanged = match &read {
2339            Some(BfString::Char(c)) => written == c.encode_utf8(&mut [0; 4]),
2340            Some(BfString::String(s)) => written == s,
2341            None => written.is_empty(),
2342        };
2343        if unchanged {
2344            return;
2345        }
2346        let now = match read {
2347            Some(BfString::Char(c)) => c.to_string(),
2348            Some(BfString::String(s)) => s,
2349            None => String::new(),
2350        };
2351        span.text.truncate(last.offset);
2352        span.text.push_str(&now);
2353        last.len = now.len();
2354        last.tail_text = span.text.len();
2355        if !now.is_empty() {
2356            return;
2357        }
2358        // Read as nothing now: the glyph gives its box back, and a span it
2359        // opened goes with it.
2360        let last = self.last_glyph_text.take().expect("checked above");
2361        self.deferred_rich_glyphs.truncate(last.deferred);
2362        if last.opened {
2363            self.spans.pop();
2364            if self.raw_runs {
2365                self.raw_bdc.pop();
2366                if let (Some(prev), Some(was)) = (self.spans.last_mut(), last.previous_complete_run)
2367                {
2368                    prev.complete_run = was;
2369                }
2370            }
2371        } else {
2372            let span = &mut self.spans[last.span];
2373            span.char_bounds.truncate(last.char_bounds);
2374            span.width = last.width;
2375        }
2376    }
2377
2378    /// Record glyph-level data for deferred tight-bounds computation.
2379    /// Called from draw_glyph in RichGeometry mode only.
2380    fn record_deferred_glyph(
2381        &mut self,
2382        glyph: &Glyph<'_>,
2383        composed: &Affine,
2384        font_size: f64,
2385        glyph_width: f64,
2386    ) {
2387        let coeffs = composed.as_coeffs();
2388        let is_rotated = coeffs[1].abs() > ROTATION_EPSILON || coeffs[2].abs() > ROTATION_EPSILON;
2389        let is_sheared = (coeffs[0] - coeffs[3]).abs() > SHEAR_EPSILON;
2390        let needs_exact = is_rotated || is_sheared;
2391
2392        // Capture the glyph's ink bbox in raw glyph space. The full outline path
2393        // is retained only when `needs_exact` (rotated/sheared), so a tight bound
2394        // can bound the transformed path; axis-aligned glyphs keep only the bbox
2395        // (enveloping its transformed corners is exact) to bound memory on large
2396        // pages. Ink-less glyphs (spaces, Type3) carry no bbox → Estimate later.
2397        let (outline, font_bbox) = match glyph {
2398            Glyph::Outline(o) => {
2399                let path = o.outline();
2400                let bb = path.bounding_box();
2401                if bb.width() <= 0.0 && bb.height() <= 0.0 {
2402                    (None, None)
2403                } else if needs_exact {
2404                    (Some(path), Some(bb))
2405                } else {
2406                    (None, Some(bb))
2407                }
2408            }
2409            Glyph::Type3(_) => (None, None),
2410        };
2411
2412        self.deferred_rich_glyphs.push(DeferredGlyph {
2413            coeffs,
2414            font_size,
2415            glyph_width,
2416            needs_exact,
2417            outline,
2418            font_bbox,
2419        });
2420    }
2421
2422    /// Compute tight bounds for all recorded glyphs and populate the spans.
2423    /// Called once after all glyphs have been extracted.
2424    fn compute_tight_bounds(&mut self) {
2425        let deferred = std::mem::take(&mut self.deferred_rich_glyphs);
2426        if deferred.is_empty() {
2427            return;
2428        }
2429
2430        let mut idx = 0usize;
2431
2432        for span in &mut self.spans {
2433            let n = span.char_bounds.len();
2434            if n == 0 {
2435                continue;
2436            }
2437
2438            for _gi in 0..n {
2439                if idx >= deferred.len() {
2440                    break;
2441                }
2442                let dg = &deferred[idx];
2443                idx += 1;
2444
2445                let composed = Affine::new(dg.coeffs);
2446                let (tight_bound, source) = if let Some(font_bbox) = dg.font_bbox {
2447                    // Glyph has ink. The outline/bbox is in raw glyph space and
2448                    // `composed` (CTM * glyph_transform) maps it straight to page
2449                    // space — exactly as the renderer fills the raw outline, with
2450                    // NO upem pre-scaling. Applying any extra font_size/1000 here
2451                    // would double-scale the bound.
2452                    if dg.needs_exact {
2453                        // Rotated/sheared: bound the transformed outline path so
2454                        // the box stays tight (the transformed bbox would be
2455                        // looser). Falls back to enveloping the bbox corners if
2456                        // the path was not retained.
2457                        let page_bbox = match dg.outline {
2458                            Some(ref path) => (composed * path.clone()).bounding_box(),
2459                            None => {
2460                                let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
2461                                let b = transform_bbox_corners(&raw, &composed);
2462                                kurbo::Rect::new(b[0], b[1], b[2], b[3])
2463                            }
2464                        };
2465                        (
2466                            [page_bbox.x0, page_bbox.y0, page_bbox.x1, page_bbox.y1],
2467                            BoundsSource::Tight,
2468                        )
2469                    } else {
2470                        // Axis-aligned scale + translate: enveloping the
2471                        // transformed bbox corners is exact.
2472                        let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
2473                        (transform_bbox_corners(&raw, &composed), BoundsSource::Tight)
2474                    }
2475                } else {
2476                    // No ink (space, Type3, missing outline): fall back to the
2477                    // advance box in page space, in the same frame as the span's
2478                    // char_bounds ([x, y, x + advance, y + font_size]).
2479                    let x = dg.coeffs[4];
2480                    let y = dg.coeffs[5];
2481                    (
2482                        [x, y, x + dg.glyph_width, y + dg.font_size],
2483                        BoundsSource::Estimate,
2484                    )
2485                };
2486
2487                span.tight_char_bounds.push(tight_bound);
2488                span.glyph_advances.push(dg.glyph_width);
2489                span.glyph_bounds_sources.push(source);
2490            }
2491
2492            span.bounds_source = aggregate_bounds_source(&span.glyph_bounds_sources);
2493        }
2494    }
2495}
2496
2497impl Device<'_> for TextExtractionDevice {
2498    fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
2499    fn set_blend_mode(&mut self, _: BlendMode) {}
2500    fn begin_marked_content(&mut self, _tag: &[u8], _mcid: Option<i32>, actual_text: Option<&str>) {
2501        if self.actual_text || self.in_annotation_appearance() {
2502            self.actual_text_stack.push(ActualTextEntry {
2503                actual_text: actual_text.map(str::to_string),
2504                consumed: false,
2505                saw_glyph: false,
2506                child_resolved: false,
2507                extent: None,
2508            });
2509        }
2510        if !self.raw_runs {
2511            return;
2512        }
2513        let id = self.raw_bdc_next;
2514        self.raw_bdc_next += 1;
2515        self.raw_bdc_stack.push(id);
2516        self.raw_bdc_changed = true;
2517    }
2518    fn end_marked_content(&mut self) {
2519        if let Some(entry) = self.actual_text_stack.pop() {
2520            self.emit_glyphless_replacement(entry);
2521        }
2522        if !self.raw_runs {
2523            return;
2524        }
2525        self.raw_bdc_stack.pop();
2526        self.raw_bdc_changed = true;
2527    }
2528    fn draw_path(
2529        &mut self,
2530        path: &BezPath,
2531        transform: Affine,
2532        paint: &Paint<'_>,
2533        mode: &PathDrawMode,
2534    ) {
2535        self.record_rule(path, transform);
2536        self.record_rulings(path, transform);
2537        self.record_path_box(path, transform);
2538        if matches!(mode, PathDrawMode::Fill(_)) {
2539            self.record_fill(path, transform, paint);
2540        }
2541        if !self.actual_text_stack.is_empty() {
2542            let mut drawn = transform.transform_rect_bbox(path.bounding_box());
2543            if let Some(clip) = self.clip_stack.last() {
2544                drawn = drawn.intersect(*clip);
2545            }
2546            self.note_drawn_box([drawn.x0, drawn.y0, drawn.x1, drawn.y1]);
2547        }
2548    }
2549    fn begin_annotation_appearance(&mut self, rect: kurbo::Rect) {
2550        self.annotation_appearances.push(rect);
2551    }
2552    fn end_annotation_appearance(&mut self) {
2553        self.annotation_appearances.pop();
2554    }
2555    fn push_clip_path(&mut self, clip_path: &ClipPath) {
2556        // The clip arrives in page space already -- `render.rs` applies it
2557        // under the identity transform -- so its box needs no transform here.
2558        // The box and not the region: a clip shaped like a star still reaches
2559        // only its own bounding box, so this over-states what is visible and
2560        // never under-states it, which is the direction that cannot invent a
2561        // rule.
2562        let box_of_clip = clip_path.path.bounding_box();
2563        let effective = match self.clip_stack.last() {
2564            Some(outer) => outer.intersect(box_of_clip),
2565            None => box_of_clip,
2566        };
2567        self.clip_stack.push(effective);
2568    }
2569    fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
2570    fn draw_image(&mut self, image: Image<'_, '_>, transform: Affine) {
2571        self.record_image(image.width(), image.height(), transform);
2572        if !self.actual_text_stack.is_empty() {
2573            let (w, h) = (
2574                f64::from(image.width().max(1)),
2575                f64::from(image.height().max(1)),
2576            );
2577            let mut drawn = transform.transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w, h));
2578            if let Some(clip) = self.clip_stack.last() {
2579                drawn = drawn.intersect(*clip);
2580            }
2581            self.note_drawn_box([drawn.x0, drawn.y0, drawn.x1, drawn.y1]);
2582        }
2583    }
2584    fn pop_clip_path(&mut self) {
2585        self.clip_stack.pop();
2586    }
2587    fn pop_transparency_group(&mut self) {}
2588    fn draw_placed_text(&mut self, text: &str, rect: kurbo::Rect) {
2589        self.push_placed_span(text, rect);
2590    }
2591
2592    fn draw_glyph(
2593        &mut self,
2594        glyph: &Glyph<'_>,
2595        transform: Affine,
2596        glyph_transform: Affine,
2597        paint: &Paint<'_>,
2598        draw_mode: &GlyphDrawMode,
2599    ) {
2600        // A glyph whose text an `/ActualText` replacement stands in for, or
2601        // that the page hides and the output may drop, cannot carry a mark
2602        // offered across it, so the glyph before it is not read again: the
2603        // offer is then refused and both read as before (spec review (b),
2604        // review 14-10 02:00 (ii)).
2605        let color = paint_to_rgba(paint);
2606        let glyph_hidden = matches!(draw_mode, GlyphDrawMode::Invisible)
2607            || color.is_some_and(|color| color[3] == 0);
2608        let replaced = self.actual_text
2609            && self
2610                .actual_text_stack
2611                .iter()
2612                .any(|entry| entry.actual_text.is_some());
2613        if replaced || glyph_hidden {
2614            self.last_glyph_text = None;
2615        } else {
2616            self.reread_last_glyph(glyph);
2617        }
2618        // Whatever this glyph does, the glyph before it is no longer the last
2619        // one, unless this is the stroke that repeats its fill.
2620        let before = self.last_glyph_text.take();
2621        // The glyph is read once: a read decides a placement offered to it
2622        // and every later read answers the same, so reading it again here
2623        // would only repeat the font's lookup.
2624        let unicode = glyph.as_unicode();
2625
2626        // FILL AND STROKE IS ONE GLYPH (#623). Render mode 2 draws each glyph
2627        // filled and then stroked -- how a heading is made heavier without a
2628        // bold face -- and the interpreter hands both to the device, since a
2629        // renderer needs both. Read twice, `1.0 BACKGROUND` came out as
2630        // `BBAACCKKGGRROOUUNNDD` (GAMOBUDU LANDFILL, DocLayNet validation) and
2631        // the heading matched nothing. The stroke that repeats the fill just
2632        // before it, at the same transform, is the same character.
2633        let placed = (transform * glyph_transform).as_coeffs();
2634        let repeats_fill = matches!(draw_mode, GlyphDrawMode::Stroke(_))
2635            && self
2636                .last_fill
2637                .as_ref()
2638                .is_some_and(|(at, filled)| *at == placed && *filled == unicode);
2639        self.last_fill = match draw_mode {
2640            GlyphDrawMode::Fill => Some((placed, unicode.clone())),
2641            _ => None,
2642        };
2643        if repeats_fill {
2644            self.last_glyph_text = before;
2645            return;
2646        }
2647
2648        let reads_as_nothing = unicode.is_none();
2649        let glyph_text = match unicode {
2650            Some(BfString::Char(c)) => c.to_string(),
2651            Some(BfString::String(s)) => s,
2652            None => String::new(),
2653        };
2654
2655        let composed = transform * glyph_transform;
2656        let coeffs = composed.as_coeffs();
2657        let x = coeffs[4];
2658        let y = coeffs[5];
2659        let glyph_scale = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt().abs();
2660        let draw_size = glyph_scale * 1000.0;
2661        // Under raw runs the size is the area scale of the text matrix, so a
2662        // squashed line reports what it is set in (#643): `Deceased` reads
2663        // 6.9296, the geometric mean of its 6.86 by 7 matrix, where the first
2664        // column alone gives 6.86. Widths keep the drawn scale, so boxes and
2665        // gaps never move; the default path reports the drawn scale, as
2666        // before.
2667        let font_size = if self.raw_runs {
2668            (coeffs[0] * coeffs[3] - coeffs[1] * coeffs[2]).abs().sqrt() * 1000.0
2669        } else {
2670            draw_size
2671        };
2672
2673        // G2: distinguish real advance (Metric) from estimate (Estimate).
2674        let (glyph_width, glyph_ws) = glyph_width_and_source(glyph, draw_size);
2675        let widened = match glyph {
2676            Glyph::Outline(outline) => outline.advance_width().map_or(0.0, |w| {
2677                (glyph_width - f64::from(w) / 1000.0 * font_size).max(0.0)
2678            }),
2679            Glyph::Type3(_) => 0.0,
2680        };
2681        let glyph_end_x = x + glyph_width;
2682        let glyph_bound = [x, y, glyph_end_x, y + font_size];
2683
2684        let style = self.glyph_style(glyph);
2685
2686        // An `/ActualText` replacement reads instead of the glyphs of its
2687        // marked run, once, on the run's first glyph; the box of that glyph
2688        // stays whole for however many letters the replacement holds. The
2689        // innermost open sequence carrying one wins, and a run replaced by
2690        // nothing reads as nothing. Later glyphs of the same run add no
2691        // text and no box, but the pen still moves past what was drawn, so
2692        // the gap after the run is measured from where the run ended.
2693        let actual_idx = if self.actual_text {
2694            self.actual_text_stack
2695                .iter()
2696                .rposition(|e| e.actual_text.is_some())
2697        } else {
2698            None
2699        };
2700        let text = if let Some(idx) = actual_idx {
2701            let replacement = if self.actual_text_stack[idx].consumed {
2702                String::new()
2703            } else {
2704                let found = self.actual_text_stack[idx]
2705                    .actual_text
2706                    .clone()
2707                    .unwrap_or_default();
2708                self.actual_text_stack[idx].consumed = true;
2709                found
2710            };
2711            if replacement.is_empty() {
2712                self.last_y = y;
2713                self.advance_pen(&glyph_text, glyph_end_x, widened);
2714                let adv_len = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
2715                let (adv_x, adv_y) = if adv_len > 0.0 {
2716                    (coeffs[0] / adv_len, coeffs[1] / adv_len)
2717                } else {
2718                    (1.0, 0.0)
2719                };
2720                self.advance_raw_pen(&glyph_text, x, y, adv_x, adv_y, glyph_width);
2721                self.pending_tj_offset = 0.0;
2722                return;
2723            }
2724            replacement
2725        } else {
2726            if reads_as_nothing {
2727                // Read again at the next glyph all the same: a mark drawn
2728                // before its base gives its offer up by being read.
2729                if let Glyph::Outline(outline) = glyph {
2730                    let record = LastGlyphText {
2731                        place: None,
2732                        span: self.spans.len().saturating_sub(1),
2733                        offset: 0,
2734                        len: 0,
2735                        boxless: true,
2736                        opened: false,
2737                        char_bounds: 0,
2738                        width: 0.0,
2739                        previous_complete_run: None,
2740                        deferred: self.deferred_rich_glyphs.len(),
2741                        span_count: 0,
2742                        tail_text: 0,
2743                        tail_boxes: 0,
2744                    };
2745                    self.remember_last_glyph(outline, record, glyph_hidden);
2746                }
2747                return;
2748            }
2749            // A glyph that reads as drawn: the open sequences already stand
2750            // in the text, so their close must not read a replacement too.
2751            // Its box is still a mark a glyphless close could read at.
2752            if !self.actual_text_stack.is_empty() {
2753                for open in self.actual_text_stack.iter_mut() {
2754                    open.saw_glyph = true;
2755                }
2756                self.note_drawn_box(glyph_bound);
2757            }
2758            glyph_text
2759        };
2760        // The glyph's own text, which the next glyph may make it read
2761        // differently (see `LastGlyphText`); a replacement is not.
2762        let own_glyph = match glyph {
2763            Glyph::Outline(outline) if actual_idx.is_none() => Some(outline),
2764            _ => None,
2765        };
2766
2767        // M3: record glyph data for deferred tight-bounds computation.
2768        let deferred_before = self.deferred_rich_glyphs.len();
2769        if self.geometry_mode == GeometryMode::RichGeometry {
2770            self.record_deferred_glyph(glyph, &composed, font_size, glyph_width);
2771        }
2772
2773        // ANN[r17/TEX4] Feed the running sample used to derive the adaptive
2774        // median character width. Capped to protect against pathological
2775        // pages with hundreds of thousands of glyphs.
2776        if self.glyph_widths.len() < 4096 {
2777            self.glyph_widths.push(glyph_width);
2778            if self.glyph_widths.len().is_multiple_of(MEDIAN_REFRESH) {
2779                self.refresh_median_char_width();
2780            }
2781        }
2782
2783        // The gap is what the page drew between this glyph and the last one,
2784        // judged on the pen the last glyph left. The pen moves past this glyph
2785        // only after the merge decision -- `advance_pen` on every path below --
2786        // so the gap reads the previous end, and `last_widened` still carries
2787        // the previous glyph's floor for the tracking read. Advancing here left
2788        // every glyph a span of its own and the tracking dead (#623 family).
2789        let gap = x - self.last_end_x;
2790        let same_line = (y - self.last_y).abs() <= font_size.max(BAND_Y_TOLERANCE) * 0.35;
2791        // Under raw runs a turned glyph joins its run along the way it reads
2792        // (#643): `Form` is one show of four glyphs reading down, and judged
2793        // on x and y each glyph stands alone. The gap runs along the way this
2794        // glyph reads and the same-line band across it, off the pen the last
2795        // glyph left. Two horizontal glyphs keep the judgment above, exactly
2796        // as before.
2797        let adv_len = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
2798        let (adv_x, adv_y) = if adv_len > 0.0 {
2799            (coeffs[0] / adv_len, coeffs[1] / adv_len)
2800        } else {
2801            (1.0, 0.0)
2802        };
2803        let vector = self.raw_runs
2804            && self.raw_pen_valid
2805            && (adv_y.abs() > adv_x.abs() || self.raw_dir[1].abs() > self.raw_dir[0].abs());
2806        let (same_line, gap, dir_changed) = if vector {
2807            let along = (x - self.raw_pen[0]) * adv_x + (y - self.raw_pen[1]) * adv_y;
2808            let across_x = (x - self.raw_pen[0]) - along * adv_x;
2809            let across_y = (y - self.raw_pen[1]) - along * adv_y;
2810            let across = (across_x.powi(2) + across_y.powi(2)).sqrt();
2811            let aligned = adv_x * self.raw_dir[0] + adv_y * self.raw_dir[1] > 0.999;
2812            (aligned && across <= font_size * 0.35, along, !aligned)
2813        } else {
2814            (same_line, gap, false)
2815        };
2816        let adjacent = same_line && gap >= -font_size * 0.25 && gap < font_size * 0.5;
2817
2818        // G1: only merge into the previous span when font + style + color
2819        // match. Otherwise the editor's style toolbar would render the wrong
2820        // state for the cursor position.
2821        //
2822        // A span is one font at one size and style: where the caller asks
2823        // for the document's names, the reported name parts a span even
2824        // where the merge face does not. Two document fonts can stand on one
2825        // face, and merged the second font's name and descriptor metrics are
2826        // lost with it (#636). Words still join across the split at word
2827        // level. The default path keeps merging on the face alone, as it
2828        // did: a program that names no face still reads under the page's
2829        // name (#574).
2830        let same_font = !self.document_font_names
2831            || self
2832                .spans
2833                .last()
2834                .is_some_and(|last| last.font_name == style.font_name);
2835        let style_matches = self
2836            .spans
2837            .last()
2838            .map(|last| {
2839                self.last_merge_face == style.merge_face
2840                    && same_font
2841                    && last.is_bold == style.is_bold
2842                    && last.is_italic == style.is_italic
2843                    && last.color == color
2844            })
2845            .unwrap_or(false);
2846        // Under raw runs a run ends where the type size changes: `MM` at 7pt
2847        // and the space at 4.95pt the page sets between it and `DD` are one
2848        // line but three runs (#643). The default path merges across sizes, as
2849        // before.
2850        let same_size = !self.raw_runs
2851            || self.spans.last().is_some_and(|last| {
2852                let big = last.font_size.max(font_size).max(1.0);
2853                (last.font_size - font_size).abs() <= RAW_SIZE_TOLERANCE * big
2854            });
2855        // A segment that opened or closed since the last glyph ends the run,
2856        // so a run never crosses the segment it was drawn in (#643). Only
2857        // ever set under raw runs; read and cleared here, on every glyph.
2858        let bdc_changed = std::mem::replace(&mut self.raw_bdc_changed, false);
2859        // Under raw runs a run ends where the page's declared face changes,
2860        // for the reason `last_raw_declared` gives (#643). A glyph the page
2861        // named nothing for never parts a run on its own.
2862        let declared = if self.raw_runs {
2863            match glyph {
2864                Glyph::Outline(outline) => outline
2865                    .declared_name()
2866                    .map(|d| strip_subset_prefix(&d).to_string()),
2867                Glyph::Type3(_) => None,
2868            }
2869        } else {
2870            None
2871        };
2872        let same_declared = !self.raw_runs
2873            || match (&self.last_raw_declared, &declared) {
2874                (Some(before), Some(now)) => before == now,
2875                _ => true,
2876            };
2877
2878        if adjacent
2879            && !self.spans.is_empty()
2880            && style_matches
2881            && same_size
2882            && same_declared
2883            && !bdc_changed
2884            && !dir_changed
2885        {
2886            // ANN[r17/TEX1] Multi-signal consensus replaces the prior
2887            // single-threshold rule (`gap > 0.15 * font_size`). The
2888            // consensus evaluates TJ offset, geometric gap, and
2889            // character-class transitions; a space is inserted only
2890            // when the weighted sum meets SPACE_CONSENSUS_THRESHOLD.
2891            // Decision is computed before the mutable borrow of `last`
2892            // to keep the borrow checker happy.
2893            // AND THE TYPESETTER'S TRACKING IS NOT A WORD SPACE (#582). The
2894            // consensus fires on every pair of a letter-spaced display line,
2895            // so the regularity of the run is asked about before its answer is
2896            // believed.
2897            //
2898            // BETWEEN TWO LETTERS THE RUN IS JUDGED ON THE GAP THE PAGE DREW
2899            // (#623). Two things make a tracked run's gaps disagree that are not
2900            // the tracking: the quarter-em floor of `glyph_width_and_source`
2901            // widens a narrow glyph's box, so the gap after every `I` of a
2902            // tracked `DIRECTORS’ DECLARATION` (OTC_STKAF_2002) read half the
2903            // tracking; and a pair kern in the TJ -- `[(ORPORA) 74 (TION)] TJ`
2904            // in NYSE_PVA_2004 -- takes 40 per cent off one gap. Either broke
2905            // the regularity for the next four pairs, and the run ended
2906            // `CORPORAT I O N`. Both are put back. A kern is a POSITIVE TJ, of
2907            // any size, since it moves the next glyph left: `Twenty-five`
2908            // tightens its `Tw` by 1.3 points. A small negative one is the
2909            // tracking itself -- NASDAQ_UCBI_2014 letter-spaces `CONSOLIDATED`
2910            // with `-80` between every pair -- and stays in the gap. Only
2911            // between letters and drawn spaces: a formula's commas and dots are
2912            // floored too, and put back there they make `{1, ..., n}` as
2913            // regular as tracking (1201.4397).
2914            let prev_char = self.spans.last().and_then(|last| last.text.chars().last());
2915            let next_char = text.chars().next();
2916            let letters = prev_char.is_some_and(char::is_alphabetic)
2917                && next_char.is_some_and(char::is_alphabetic);
2918            // A drawn space in a tracked line is floored and kerned like its
2919            // letters: `BACK TO` (NYSE_WAL_2014) kerns the `T` after its space
2920            // by 25, and left raw that one gap kept the run from being seen.
2921            let letter_or_space =
2922                |c: Option<char>| c.is_some_and(|c| c.is_alphabetic() || c == ' ');
2923            let drawn_gap = if letter_or_space(prev_char) && letter_or_space(next_char) {
2924                let kern = f64::from(self.pending_tj_offset.max(0.0)) / 1000.0 * font_size;
2925                gap + self.last_widened + kern
2926            } else {
2927                gap
2928            };
2929            let tracked = self.a_tracked_run(drawn_gap);
2930            let want_space = {
2931                let last = self.spans.last().expect("checked non-empty");
2932                !last.text.ends_with(' ')
2933                    && !text.starts_with(' ')
2934                    && !tracked
2935                    && self.evaluate_space_consensus(gap, font_size, &last.text, &text)
2936            };
2937            // EVERY GAP, and not only the ones wide enough to be a space.
2938            // Filtering to the wide ones leaves a run of word spaces, which
2939            // agree with one another as neatly as tracking does -- that is how
2940            // `in a healthy` came back as `in ahealthy`. What tells the two
2941            // apart is the gaps INSIDE the words: tracking adds its constant
2942            // to those as well, and prose does not.
2943            //
2944            // A gap that breaks the regularity ends one run and the next
2945            // starts after it, and a take-back never reaches back past that
2946            // start: the gap itself is the boundary, so the word space before
2947            // a letter-spaced `C O M B I N E` stays (NYSE_GLW_2004). A span's
2948            // first gap has nothing to break, and it is the run's own. Only a
2949            // gap between two letters breaks a run, measured against the last
2950            // gap between two letters: a drawn space glyph sits a little off
2951            // the tracking on both sides, and a break there left `I T ALL`
2952            // (NYSE_WAL_2014) and `D ECLARATION` (OTC_STKAF_2002).
2953            if letters {
2954                if self.last_letter_gap.is_some_and(|before| {
2955                    (before - drawn_gap).abs() > TRACKING_TOLERANCE * drawn_gap
2956                }) {
2957                    self.run_start = self.spans.last().map_or(0, |last| last.text.len() + 1);
2958                }
2959                self.last_letter_gap = Some(drawn_gap);
2960            }
2961            self.recent_gaps.push(drawn_gap);
2962            if self.recent_gaps.len() > TRACKING_MIN_PAIRS * 2 {
2963                self.recent_gaps.remove(0);
2964            }
2965            let span_index = self.spans.len() - 1;
2966            let last = self.spans.last_mut().expect("checked non-empty");
2967            if tracked {
2968                Self::take_back_tracked_spaces(
2969                    &mut last.text,
2970                    &mut self.inserted_spaces,
2971                    self.run_start,
2972                    drawn_gap,
2973                );
2974            }
2975            if want_space {
2976                self.inserted_spaces.push((last.text.len(), drawn_gap));
2977                last.text.push(' ');
2978            }
2979            let offset = last.text.len();
2980            let char_bounds = last.char_bounds.len();
2981            let width = last.width;
2982            last.text.push_str(&text);
2983            last.width = last.width.max(glyph_end_x - last.x);
2984            last.height = last.height.max(font_size);
2985            // G2: append char_bound; downgrade width_source if this glyph is Estimate.
2986            last.char_bounds.push(glyph_bound);
2987            if glyph_ws == WidthSource::Estimate {
2988                last.width_source = WidthSource::Estimate;
2989            }
2990            if let Some(outline) = own_glyph {
2991                let record = LastGlyphText {
2992                    place: None,
2993                    span: span_index,
2994                    offset,
2995                    len: text.len(),
2996                    boxless: false,
2997                    opened: false,
2998                    char_bounds,
2999                    width,
3000                    previous_complete_run: None,
3001                    deferred: deferred_before,
3002                    span_count: 0,
3003                    tail_text: 0,
3004                    tail_boxes: 0,
3005                };
3006                self.remember_last_glyph(outline, record, glyph_hidden);
3007            }
3008            self.last_y = y;
3009            self.advance_pen(&text, glyph_end_x, widened);
3010            self.advance_raw_pen(&text, x, y, adv_x, adv_y, glyph_width);
3011            // ANN[r17/TEX1] Consume the TJ signal: it only counts for
3012            // the one merge it preceded.
3013            self.pending_tj_offset = 0.0;
3014            return;
3015        }
3016
3017        self.last_y = y;
3018        self.advance_pen(&text, glyph_end_x, widened);
3019        self.advance_raw_pen(&text, x, y, adv_x, adv_y, glyph_width);
3020        // ANN[r17/TEX1] Non-adjacent glyph starts a fresh span, so any
3021        // pending TJ offset is about within-span word breaks and no longer
3022        // meaningful here.
3023        self.pending_tj_offset = 0.0;
3024        // AND A NEW SPAN IS A NEW RUN (#582). The gaps belong to the run they
3025        // were measured in; carried across, the first gap of a new line is
3026        // judged against the last line's tracking, and a line that opens with
3027        // a one-letter word loses its space -- `A report` as `Areport`. The
3028        // placed-text path clears this for the same reason.
3029        self.recent_gaps.clear();
3030        self.inserted_spaces.clear();
3031        self.run_start = 0;
3032        self.last_letter_gap = None;
3033
3034        let span = TextSpan {
3035            text,
3036            x,
3037            y,
3038            width: glyph_width,
3039            height: font_size,
3040            font_size,
3041            font_name: style.font_name.clone(),
3042            is_bold: style.is_bold,
3043            is_italic: style.is_italic,
3044            color,
3045            width_source: glyph_ws,
3046            char_bounds: vec![glyph_bound],
3047            transform: Some(coeffs),
3048            font_weight: style.font_weight,
3049            is_serif: style.is_serif,
3050            is_monospace: style.is_monospace,
3051            render_mode: Some(render_mode_from_draw_mode(draw_mode)),
3052            is_ocr_layer: style.is_ocr_layer,
3053            font_metrics: style.font_metrics,
3054            geometry_mode: self.geometry_mode,
3055            bounds_source: BoundsSource::Advance,
3056            tight_char_bounds: Vec::new(),
3057            glyph_advances: Vec::new(),
3058            glyph_bounds_sources: Vec::new(),
3059            complete_run: false,
3060        };
3061
3062        self.last_merge_face = style.merge_face;
3063        let previous_complete_run = self.spans.last().map(|prev| prev.complete_run);
3064        if self.raw_runs {
3065            // A run ends where the style, the size, the declared face or the
3066            // reading direction does; a segment or a text object alone never
3067            // ends one (`4a` reads as one span across two segments, and the
3068            // foreign-address fields merge across text objects), and neither
3069            // does a purely geometric part -- a gap or a backward step inside
3070            // one show -- so a consumer joining same-style spans may still
3071            // merge there (#643).
3072            let run_end = !style_matches || !same_size || !same_declared || dir_changed;
3073            if let Some(prev) = self.spans.last_mut() {
3074                prev.complete_run = run_end;
3075            }
3076            self.raw_bdc.push(self.raw_bdc_stack.last().copied());
3077            self.last_raw_declared = declared;
3078        }
3079        let record = own_glyph.map(|outline| {
3080            let record = LastGlyphText {
3081                place: None,
3082                span: self.spans.len(),
3083                offset: 0,
3084                len: span.text.len(),
3085                boxless: false,
3086                opened: true,
3087                char_bounds: 0,
3088                width: 0.0,
3089                previous_complete_run,
3090                deferred: deferred_before,
3091                span_count: 0,
3092                tail_text: 0,
3093                tail_boxes: 0,
3094            };
3095            (outline, record)
3096        });
3097        self.spans.push(span);
3098        if let Some((outline, record)) = record {
3099            self.remember_last_glyph(outline, record, glyph_hidden);
3100        }
3101    }
3102
3103    // ANN[r17/TEX1] Record TJ offsets. Accumulate because a single
3104    // inter-substring gap may be expressed as multiple numeric entries
3105    // (rare, but legal per PDF §9.4.3). The next draw_glyph consumes
3106    // the sum.
3107    fn text_adjustment(&mut self, amount: f32) {
3108        self.pending_tj_offset += amount;
3109    }
3110}
3111
3112/// Style metadata derived from a `Glyph` for the G1 text-run extension.
3113#[derive(Debug, Default, Clone)]
3114struct GlyphStyle {
3115    font_name: Option<String>,
3116    /// The face spans merge on: `font_name` as the program names it, without
3117    /// the /BaseFont fallback (see `last_merge_face`).
3118    merge_face: Option<String>,
3119    is_bold: bool,
3120    is_italic: bool,
3121    /// Numeric weight from embedded font data, if available.
3122    font_weight: Option<u16>,
3123    /// Serif flag from embedded font data, if available.
3124    is_serif: Option<bool>,
3125    /// Monospace flag from embedded font data, if available.
3126    is_monospace: Option<bool>,
3127    /// Whether the glyph was set in the engine's own OCR layer resource.
3128    is_ocr_layer: bool,
3129    /// Vertical font metrics from embedded font data, if available.
3130    font_metrics: Option<FontMetrics>,
3131}
3132
3133/// Strip a 6-character subset prefix (e.g. `AAAAAA+Helvetica` → `Helvetica`).
3134fn strip_subset_prefix(name: &str) -> &str {
3135    match name.split_once('+') {
3136        Some((prefix, rest)) if prefix.len() == 6 => rest,
3137        _ => name,
3138    }
3139}
3140
3141/// Heuristic style inference from a PostScript name when no descriptor
3142/// flags are reachable. Matches the same rules `pdf-interpret` uses in
3143/// `FallbackFontQuery::new`.
3144fn name_style_hints(name: &str) -> (bool, bool) {
3145    let lower = name.to_ascii_lowercase();
3146    let italic = lower.contains("italic") || lower.contains("oblique") || lower.contains("slant");
3147    let bold = lower.contains("bold")
3148        || lower.contains("demi")
3149        || lower.contains("semibold")
3150        || lower.contains("heavy")
3151        || lower.contains("black");
3152    (bold, italic)
3153}
3154
3155/// Whether a font name says monospace, for faces whose program is out of
3156/// reach (standard-14 and other non-embedded fonts).
3157///
3158/// Lowercased, spaceless substring match over known monospace families, the
3159/// way [`name_style_hints`] reads bold and italic. `Some(true)` on a hit,
3160/// `None` otherwise: a name that says nothing keeps the unknown it had. A
3161/// bare "mono" is never matched: decorative faces carry it too.
3162fn name_monospace_hint(name: &str) -> Option<bool> {
3163    let squashed: String = name
3164        .to_ascii_lowercase()
3165        .chars()
3166        .filter(|c| c.is_ascii_alphanumeric())
3167        .collect();
3168    const MONOSPACE_FAMILIES: &[&str] = &[
3169        "courier",
3170        "monaco",
3171        "menlo",
3172        "consolas",
3173        "liberationmono",
3174        "dejavusansmono",
3175        "notosansmono",
3176        "sfmono",
3177        "ubuntumono",
3178        "robotomono",
3179        "ptmono",
3180        "droidsansmono",
3181        "cascadiacode",
3182        "cascadiamono",
3183        "firacode",
3184        "firamono",
3185        "inconsolata",
3186        "jetbrainsmono",
3187        "sourcecodepro",
3188        "lucidaconsole",
3189        "nimbusmono",
3190        "texgyrecursor",
3191        "andalemono",
3192        "lettergothic",
3193        "prestigeelite",
3194        "anonymouspro",
3195        "oxygenmono",
3196        "overpassmono",
3197        "plexmono",
3198        "spacemono",
3199        "sharetechmono",
3200        "cutivemono",
3201        "novamono",
3202        "fixedsys",
3203        "typewriter",
3204        "monospace",
3205    ];
3206    if MONOSPACE_FAMILIES
3207        .iter()
3208        .any(|family| squashed.contains(family))
3209    {
3210        Some(true)
3211    } else {
3212        None
3213    }
3214}
3215
3216fn derive_glyph_style(glyph: &Glyph<'_>, document_font_names: bool) -> GlyphStyle {
3217    let mut style = glyph_style_from_font(glyph, document_font_names);
3218    // THE ENGINE'S OWN VOICE. Text set in the OCR layer resource is the
3219    // layer the engine itself wrote, not ink the author hid: the hidden-text
3220    // drop keeps it. The resource name is the only mark that survives, since
3221    // the layer stands on Helvetica like any author text.
3222    style.is_ocr_layer = glyph
3223        .font_resource_name()
3224        .is_some_and(|name| name.as_bytes() == pdf_ocr::pipeline::OCR_FONT_NAME);
3225    // THE PAGE'S NAME, BESIDE THE FACE'S (#623). A font that stands on a
3226    // standard face -- an embedded CFF or Type1 whose program the renderer
3227    // replaced, a named face that is not embedded -- reaches here as that
3228    // face: `RotisSansSerif-Light`, its regular and its `-Bold` were all
3229    // `Helvetica`, and a `-Bold` or `-Italic` the page asked for was lost.
3230    // `font_name` stays the face's, because span merging compares it and a
3231    // formula's faces must keep merging as they did; bold and italic read
3232    // the page's name as well.
3233    if let Glyph::Outline(outline) = glyph {
3234        if let Some(raw) = outline.declared_name() {
3235            let declared = strip_subset_prefix(&raw).to_string();
3236            if !declared.is_empty() && style.font_name.as_deref() != Some(declared.as_str()) {
3237                let (bold, italic) = name_style_hints(&declared);
3238                style.is_bold |= bold;
3239                style.is_italic |= italic;
3240                style.is_monospace = style.is_monospace.or(name_monospace_hint(&declared));
3241            }
3242        }
3243    }
3244    style
3245}
3246
3247fn glyph_style_from_font(glyph: &Glyph<'_>, document_font_names: bool) -> GlyphStyle {
3248    match glyph {
3249        Glyph::Outline(outline) => {
3250            if let Some(data) = outline.font_data() {
3251                let raw = data.postscript_name.as_deref().unwrap_or("");
3252                let name = strip_subset_prefix(raw).to_string();
3253                let weight_bold = data.weight.is_some_and(|w| w >= 700);
3254                let (name_bold, name_italic) = name_style_hints(&name);
3255                let font_name = if name.is_empty() { None } else { Some(name) };
3256                GlyphStyle {
3257                    merge_face: font_name.clone(),
3258                    font_name,
3259                    is_bold: weight_bold || name_bold,
3260                    is_italic: data.is_italic || name_italic,
3261                    font_weight: data.weight.map(|w| w.clamp(1, 1000) as u16),
3262                    is_serif: Some(data.is_serif),
3263                    is_monospace: Some(data.is_monospace),
3264                    // Read back in `derive_glyph_style`, the only caller.
3265                    is_ocr_layer: false,
3266                    font_metrics: match (data.ascent, data.descent) {
3267                        (Some(ascent), Some(descent)) => Some(FontMetrics {
3268                            ascent,
3269                            descent,
3270                            cap_height: data.cap_height,
3271                            x_height: data.x_height,
3272                        }),
3273                        _ => None,
3274                    },
3275                }
3276            } else if document_font_names {
3277                // Type1 / non-embedded font — descriptor not surfaced
3278                // via font_data(). Fall back to the name-only
3279                // accessor which works for standard-14 fallbacks,
3280                // and use the canonical Standard-14 AFM metrics
3281                // when the outline font resolves to a known face.
3282                //
3283                // THE MERGE KEY STAYS THE FACE'S, THE REPORTED NAME IS THE
3284                // PAGE'S (#643 E3). Spans merge on the face the program
3285                // names (or the substitute it stands on), exactly as they
3286                // did (#574); `font_name` is the name the page set its
3287                // text in. Style reads both, as it did (#623): a `Bd` the
3288                // page names is bold because the face says `Bold`, and a
3289                // `Bliss-LightItalic` no face names is italic because the
3290                // page says so.
3291                let face =
3292                    strip_subset_prefix(&outline.postscript_name().unwrap_or_default()).to_string();
3293                let merge_face = if face.is_empty() {
3294                    None
3295                } else {
3296                    Some(face.clone())
3297                };
3298                let raw = outline.document_name().unwrap_or_default();
3299                let name = strip_subset_prefix(&raw).to_string();
3300                // AND WHERE THE PROGRAM NAMES NOTHING, THE PAGE'S NAME (#574).
3301                // An embedded Type1 or CFF reaches here with no descriptor and,
3302                // unless it stands on a standard face, no PostScript name: every
3303                // span of `DIN-Medium`, `CMR12`, `MetalworkBold` had no face at
3304                // all -- five of the rig-like proxy's 36 documents, 105 of its
3305                // 389 pages, on which `font_is_the_body_face` could say nothing.
3306                // A Type1 always declares a /BaseFont. Spans still merge on the
3307                // program's name (`merge_face`).
3308                let name = if name.is_empty() {
3309                    outline
3310                        .declared_name()
3311                        .map(|d| strip_subset_prefix(&d).to_string())
3312                        .unwrap_or_default()
3313                } else {
3314                    name
3315                };
3316                let (name_bold, name_italic) = name_style_hints(&name);
3317                let (face_bold, face_italic) = name_style_hints(&face);
3318                let metrics = outline.font_metrics().map(|(a, d, c, x)| FontMetrics {
3319                    ascent: a,
3320                    descent: d,
3321                    cap_height: c,
3322                    x_height: x,
3323                });
3324                GlyphStyle {
3325                    font_name: if name.is_empty() { None } else { Some(name) },
3326                    merge_face,
3327                    is_bold: name_bold || face_bold,
3328                    is_italic: name_italic || face_italic,
3329                    // Non-embedded font: descriptor metrics are not reachable.
3330                    font_weight: None,
3331                    is_serif: None,
3332                    is_monospace: None,
3333                    // Read back in `derive_glyph_style`, the only caller.
3334                    is_ocr_layer: false,
3335                    font_metrics: metrics,
3336                }
3337            } else {
3338                // THE DEFAULT ANSWERS AS cc2f37b DID (#643 follow-up). The
3339                // option is off: the substitute face's name and AFM pair,
3340                // style hints from that name alone -- the merge key and
3341                // `font_is_the_body_face` see exactly what they saw before.
3342                // `substitute_metrics` is the pre-E4 metrics source under
3343                // its new name; the rest is verbatim.
3344                let raw = outline.postscript_name().unwrap_or_default();
3345                let name = strip_subset_prefix(&raw).to_string();
3346                let merge_face = if name.is_empty() {
3347                    None
3348                } else {
3349                    Some(name.clone())
3350                };
3351                // AND WHERE THE PROGRAM NAMES NOTHING, THE PAGE'S NAME (#574).
3352                // An embedded Type1 or CFF reaches here with no descriptor and,
3353                // unless it stands on a standard face, no PostScript name: every
3354                // span of `DIN-Medium`, `CMR12`, `MetalworkBold` had no face at
3355                // all -- five of the rig-like proxy's 36 documents, 105 of its
3356                // 389 pages, on which `font_is_the_body_face` could say nothing.
3357                // A Type1 always declares a /BaseFont. Spans still merge on the
3358                // program's name (`merge_face`).
3359                let name = if name.is_empty() {
3360                    outline
3361                        .declared_name()
3362                        .map(|d| strip_subset_prefix(&d).to_string())
3363                        .unwrap_or_default()
3364                } else {
3365                    name
3366                };
3367                let (name_bold, name_italic) = name_style_hints(&name);
3368                let metrics = outline
3369                    .substitute_metrics()
3370                    .map(|(a, d, c, x)| FontMetrics {
3371                        ascent: a,
3372                        descent: d,
3373                        cap_height: c,
3374                        x_height: x,
3375                    });
3376                let monospace = name_monospace_hint(&name);
3377                GlyphStyle {
3378                    font_name: if name.is_empty() { None } else { Some(name) },
3379                    merge_face,
3380                    is_bold: name_bold,
3381                    is_italic: name_italic,
3382                    // Non-embedded font: descriptor metrics are not reachable.
3383                    font_weight: None,
3384                    is_serif: None,
3385                    is_monospace: monospace,
3386                    // Read back in `derive_glyph_style`, the only caller.
3387                    is_ocr_layer: false,
3388                    font_metrics: metrics,
3389                }
3390            }
3391        }
3392        Glyph::Type3(_) => GlyphStyle::default(),
3393    }
3394}
3395
3396fn paint_to_rgba(paint: &Paint<'_>) -> Option<[u8; 4]> {
3397    match paint {
3398        Paint::Color(c) => Some(c.to_rgba().to_rgba8()),
3399        Paint::Pattern(_) => None,
3400    }
3401}
3402
3403/// Map the renderer's coarse glyph draw mode to a PDF text render-mode code.
3404///
3405/// NOTE: the interpreter collapses the eight PDF text render modes (Tr 0–7)
3406/// into three visibility classes, so only `{0, 1, 3}` are representable here —
3407/// fill (0), stroke (1), and invisible (3). The fill+stroke and clip modes
3408/// (2, 4–7) are not distinguished. This is sufficient to identify and filter
3409/// invisible (mode-3) OCR/overlay text; recovering the full `Tr` range would
3410/// require threading it through the `Device` trait (out of scope).
3411fn render_mode_from_draw_mode(mode: &GlyphDrawMode) -> u8 {
3412    match mode {
3413        GlyphDrawMode::Fill => 0,
3414        GlyphDrawMode::Stroke(_) => 1,
3415        GlyphDrawMode::Invisible => 3,
3416    }
3417}
3418
3419#[cfg(test)]
3420mod render_mode_tests {
3421    use super::render_mode_from_draw_mode;
3422    use pdf_render::pdf_interpret::{GlyphDrawMode, StrokeProps};
3423
3424    #[test]
3425    fn render_mode_is_only_zero_one_three() {
3426        assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Fill), 0);
3427        assert_eq!(
3428            render_mode_from_draw_mode(&GlyphDrawMode::Stroke(StrokeProps::default())),
3429            1
3430        );
3431        assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Invisible), 3);
3432        // Exhaustive: the mapping only ever yields 0, 1, or 3 — never 2 or 4–7.
3433        for m in [
3434            GlyphDrawMode::Fill,
3435            GlyphDrawMode::Stroke(StrokeProps::default()),
3436            GlyphDrawMode::Invisible,
3437        ] {
3438            assert!(matches!(render_mode_from_draw_mode(&m), 0 | 1 | 3));
3439        }
3440    }
3441}
3442
3443/// Returns `(advance_in_user_space, WidthSource)` for a glyph.
3444///
3445/// Uses the real advance from `OutlineGlyph::advance_width()` when available
3446/// (returns `WidthSource::Metric`); falls back to 50% em otherwise
3447/// (`WidthSource::Estimate`). The result is clamped to at least 25% em so
3448/// invisible-glyph outliers do not collapse spans.
3449fn glyph_width_and_source(glyph: &Glyph<'_>, font_size: f64) -> (f64, WidthSource) {
3450    match glyph {
3451        Glyph::Outline(outline) => {
3452            if let Some(w) = outline.advance_width() {
3453                let advance = (w as f64 / 1000.0 * font_size).max(font_size * 0.25);
3454                (advance, WidthSource::Metric)
3455            } else {
3456                (font_size * 0.5, WidthSource::Estimate)
3457            }
3458        }
3459        Glyph::Type3(_) => (font_size * 0.5, WidthSource::Estimate),
3460    }
3461}
3462
3463/// Two baselines run in the same direction when they are within half a degree
3464/// of each other. A page draws a run of text with one text matrix, so the
3465/// glyphs of one run agree to the last bit; the tolerance is there for the
3466/// rounding a document writes its matrix with (`0.7071 -0.7071`), not for
3467/// telling two angles apart.
3468const DIRECTION_TOLERANCE_RAD: f64 = 0.008_727; // 0.5 degrees
3469
3470/// How far a baseline may stand from a quarter turn and still read as one:
3471/// ten degrees (#574). The defect's runs stand at a quarter or half turn; a
3472/// typeset line that merely drifts -- the 3-degree skew the guard test pins --
3473/// stays well inside this, and so does the rounding a document writes its
3474/// matrix with.
3475const QUARTER_TURN_TOLERANCE_RAD: f64 = 0.174_533; // 10 degrees
3476
3477/// Whether the span's baseline stands at a quarter turn to the page: within
3478/// ten degrees of 90, 180 or 270 degrees (#574). The defect is a
3479/// quarter-turned run read in page space -- six reversed runs on the tuning
3480/// pages, all at 90 degrees -- and only such a run is ever re-grouped. A
3481/// free-angle region already reads forwards and must not be: merging a
3482/// 60-degree region re-groups text that was never broken (OTC_STKAF p-c3c8:
3483/// spans 197 -> 31, text -0.107). Upright text and small skews are not
3484/// quarter-turned, and neither is anything on a majority-turned page, which
3485/// [`PageTurn::into_frame`] lays upright before this is ever asked.
3486fn span_is_quarter_turned(span: &TextSpan) -> bool {
3487    let direction = span_direction(span);
3488    direction_gap(direction, (0.0, 1.0)) <= QUARTER_TURN_TOLERANCE_RAD
3489        || direction_gap(direction, (-1.0, 0.0)) <= QUARTER_TURN_TOLERANCE_RAD
3490        || direction_gap(direction, (0.0, -1.0)) <= QUARTER_TURN_TOLERANCE_RAD
3491}
3492
3493/// A baseline direction: a unit vector in page space.
3494type Direction = (f64, f64);
3495
3496/// A span taken out of the page's blocks, carrying the position in the page's
3497/// order that the block it came from held.
3498type PlacedSpan = (usize, TextSpan);
3499
3500/// The direction the baseline of a glyph runs in, as a unit vector in page
3501/// space: the first column of its composed transform, normalised.
3502///
3503/// This is what the text matrix rotates. `(1, 0)` for upright text, `(0, 1)`
3504/// for a page's 90-degree marginalia, `(0.7071, -0.7071)` for the diagonal
3505/// watermark of pdfjs issue17069 -- and `(1, 0)` again for a degenerate
3506/// transform, which has no direction to speak of and may as well read as the
3507/// page's own (#435).
3508fn baseline_direction(coeffs: &[f64; 6]) -> Direction {
3509    let (a, b) = (coeffs[0], coeffs[1]);
3510    let length = (a * a + b * b).sqrt();
3511    if length <= f64::EPSILON {
3512        (1.0, 0.0)
3513    } else {
3514        (a / length, b / length)
3515    }
3516}
3517
3518/// The direction a span's baseline runs in. A span not built from a glyph
3519/// carries no transform and reads as upright.
3520fn span_direction(span: &TextSpan) -> Direction {
3521    span.transform
3522        .as_ref()
3523        .map(baseline_direction)
3524        .unwrap_or((1.0, 0.0))
3525}
3526
3527/// The angle between two unit vectors, in radians, without a sign.
3528fn direction_gap(lhs: Direction, rhs: Direction) -> f64 {
3529    let cross = lhs.0 * rhs.1 - lhs.1 * rhs.0;
3530    let dot = lhs.0 * rhs.0 + lhs.1 * rhs.1;
3531    cross.atan2(dot).abs()
3532}
3533
3534fn directions_agree(lhs: Direction, rhs: Direction) -> bool {
3535    direction_gap(lhs, rhs) <= DIRECTION_TOLERANCE_RAD
3536}
3537
3538/// How far along its own baseline, and how far across it, a span's origin sits.
3539///
3540/// The pair is the page's `(x, y)` seen from the baseline: `along` grows in
3541/// reading direction, `across` grows to the left of it, so a line of rotated
3542/// text has one `across` and a range of `along` exactly as an upright line has
3543/// one `y` and a range of `x`.
3544fn baseline_coordinates(span: &TextSpan, direction: Direction) -> (f64, f64) {
3545    (
3546        span.x * direction.0 + span.y * direction.1,
3547        -span.x * direction.1 + span.y * direction.0,
3548    )
3549}
3550
3551/// Where a span stops along its own baseline.
3552///
3553/// `width` is the widest a glyph of the span reached in PAGE x, which is the
3554/// along-baseline extent only while the text is upright: a run drawn at thirty
3555/// degrees advances `cos 30` of its length in x, and reading that as the length
3556/// leaves an eighth of the run behind it as phantom gap -- enough to put a
3557/// space in the middle of a word where a style change ends one span and starts
3558/// the next. So the last glyph's own bound says where the run stopped, and
3559/// `width` answers only when there are no bounds to ask (every hand-built span)
3560/// or when it reaches further, which is what an overprinted or backwards-drawn
3561/// run does and what the seam allowance of #432 is measured against (#435).
3562fn baseline_end(span: &TextSpan, direction: Direction) -> f64 {
3563    let start = baseline_coordinates(span, direction).0;
3564    let from_width = start + span.width;
3565    match span.char_bounds.last() {
3566        Some(bound) => {
3567            let last = bound[0] * direction.0 + bound[1] * direction.1;
3568            let advance = bound[2] - bound[0];
3569            (last + advance).max(from_width)
3570        }
3571        None => from_width,
3572    }
3573}
3574
3575/// Threshold for detecting rotation or shear in the composed transform.
3576/// Any |b| or |c| above this is treated as rotation; any difference between
3577/// a and d above this is treated as non-uniform scale (= shear potential).
3578const ROTATION_EPSILON: f64 = 1e-6;
3579const SHEAR_EPSILON: f64 = 1e-3;
3580
3581/// Transform a bounding-box `[x0,y0,x1,y1]` (in local space) to page space
3582/// by applying the affine to its four corners and taking the envelope.
3583fn transform_bbox_corners(local_bbox: &[f64; 4], affine: &Affine) -> [f64; 4] {
3584    use kurbo::Point;
3585    let corners = [
3586        *affine * Point::new(local_bbox[0], local_bbox[1]),
3587        *affine * Point::new(local_bbox[2], local_bbox[1]),
3588        *affine * Point::new(local_bbox[2], local_bbox[3]),
3589        *affine * Point::new(local_bbox[0], local_bbox[3]),
3590    ];
3591    let x0 = corners.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
3592    let y0 = corners.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
3593    let x1 = corners
3594        .iter()
3595        .map(|p| p.x)
3596        .fold(f64::NEG_INFINITY, f64::max);
3597    let y1 = corners
3598        .iter()
3599        .map(|p| p.y)
3600        .fold(f64::NEG_INFINITY, f64::max);
3601    [x0, y0, x1, y1]
3602}
3603
3604/// Collapse fake-bold / overprint duplicates inside one band.
3605///
3606/// Real-word corpus failures such as 0105.pdf draw the same text several times
3607/// with sub-point x drift to simulate heavier weight. Text extraction should
3608/// keep the most informative span once rather than concatenate every overprint.
3609/// A span the page drew over split around the narrower span it covers.
3610///
3611/// Merged spans join a glyph run with the spaces between its glyphs, so one
3612/// span can cover the x of a span the page drew between them: the form's
3613/// `MM      DD      YYYY` field span covers the `/` separators set between
3614/// its groups, and left whole no x-order of spans interleaves them again --
3615/// the line reads `MM DD YYYY/ /` (#636). Where a narrow sibling sits fully
3616/// inside the span on the same baseline and the span maps its characters to
3617/// its glyphs one to one, the span is cut at the whitespace gap holding the
3618/// sibling, so the reading order puts the separator back between its groups.
3619/// A sibling over ink rather than white never cuts: the split point must sit
3620/// under the sibling at a whitespace character.
3621fn split_spans_covering_siblings(spans: &mut Vec<TextSpan>) {
3622    let mut changed = true;
3623    while changed {
3624        changed = false;
3625        let mut at = 0usize;
3626        while at < spans.len() {
3627            if let Some((left, right)) = split_cover_at_sibling(spans, at) {
3628                spans[at] = left;
3629                spans.insert(at + 1, right);
3630                changed = true;
3631                at += 2;
3632            } else {
3633                at += 1;
3634            }
3635            if spans.len() > 4096 {
3636                return;
3637            }
3638        }
3639    }
3640}
3641
3642/// The two halves of `spans[cover]` cut around one span it covers, or `None`.
3643///
3644/// `SIBLING_WIDTH_EM` and `COVER_MARGIN` are above; the y check keeps the cut
3645/// to the band the sort already grouped, since a tall span can reach into the
3646/// band over or under its own.
3647fn split_cover_at_sibling(spans: &[TextSpan], cover: usize) -> Option<(TextSpan, TextSpan)> {
3648    const SIBLING_WIDTH_EM: f64 = 0.6;
3649    const COVER_MARGIN: f64 = 1.0;
3650    let (cover_left, cover_right) = {
3651        let span = &spans[cover];
3652        (span.x, span.measured_right())
3653    };
3654    if cover_right <= cover_left {
3655        return None;
3656    }
3657    let chars: Vec<char> = spans[cover].text.chars().collect();
3658    if chars.len() < 3 || chars.len() != spans[cover].char_bounds.len() {
3659        return None;
3660    }
3661    for (sib, sibling) in spans.iter().enumerate() {
3662        if sib == cover {
3663            continue;
3664        }
3665        let em = sibling.font_size.max(1.0);
3666        if sibling.width > SIBLING_WIDTH_EM * em {
3667            continue;
3668        }
3669        if (sibling.y - spans[cover].y).abs() > 0.35 * spans[cover].font_size.max(em) {
3670            continue;
3671        }
3672        let (sib_left, sib_right) = (sibling.x, sibling.measured_right());
3673        if !(sib_left > cover_left + COVER_MARGIN && sib_right < cover_right - COVER_MARGIN) {
3674            continue;
3675        }
3676        // The whitespace gap under the sibling: the char boundary closest to
3677        // the sibling's centre whose glyphs stand on either side of it.
3678        let centre = (sib_left + sib_right) * 0.5;
3679        let boxes = &spans[cover].char_bounds;
3680        let mut cut: Option<usize> = None;
3681        let mut best = f64::INFINITY;
3682        for i in 1..chars.len() {
3683            if !(chars[i - 1].is_whitespace() || chars[i].is_whitespace()) {
3684                continue;
3685            }
3686            let edge = (boxes[i - 1][2] + boxes[i][0]) * 0.5;
3687            if edge < sib_left || edge > sib_right {
3688                continue;
3689            }
3690            let distance = (edge - centre).abs();
3691            if distance < best {
3692                best = distance;
3693                cut = Some(i);
3694            }
3695        }
3696        let Some(i) = cut else { continue };
3697        let mut left = spans[cover].clone();
3698        let mut right = spans[cover].clone();
3699        left.text = chars[..i].iter().collect();
3700        right.text = chars[i..].iter().collect();
3701        left.char_bounds = boxes[..i].to_vec();
3702        right.char_bounds = boxes[i..].to_vec();
3703        split_parallel_glyph_vecs(&mut left, &mut right, i, chars.len());
3704        left.width = (boxes[i - 1][2] - left.x).max(0.0);
3705        right.x = boxes[i][0];
3706        right.width = (cover_right - right.x).max(0.0);
3707        return Some((left, right));
3708    }
3709    None
3710}
3711
3712/// Split the per-glyph vectors alongside [`split_cover_at_sibling`]'s cut.
3713///
3714/// A vector that does not run with `char_bounds` (nothing names such a case
3715/// today) is dropped on both halves rather than cut at the wrong place.
3716fn split_parallel_glyph_vecs(left: &mut TextSpan, right: &mut TextSpan, at: usize, len: usize) {
3717    if left.tight_char_bounds.len() == len {
3718        right.tight_char_bounds = left.tight_char_bounds[at..].to_vec();
3719        left.tight_char_bounds.truncate(at);
3720    } else {
3721        left.tight_char_bounds.clear();
3722        right.tight_char_bounds.clear();
3723    }
3724    if left.glyph_advances.len() == len {
3725        right.glyph_advances = left.glyph_advances[at..].to_vec();
3726        left.glyph_advances.truncate(at);
3727    } else {
3728        left.glyph_advances.clear();
3729        right.glyph_advances.clear();
3730    }
3731    if left.glyph_bounds_sources.len() == len {
3732        right.glyph_bounds_sources = left.glyph_bounds_sources[at..].to_vec();
3733        left.glyph_bounds_sources.truncate(at);
3734    } else {
3735        left.glyph_bounds_sources.clear();
3736        right.glyph_bounds_sources.clear();
3737    }
3738}
3739
3740fn collapse_overprinted_spans(spans: &mut Vec<TextSpan>) {
3741    if spans.len() < 2 {
3742        return;
3743    }
3744
3745    let mut deduped: Vec<TextSpan> = Vec::with_capacity(spans.len());
3746    for span in spans.drain(..) {
3747        if let Some(last) = deduped.last_mut() {
3748            if spans_are_overprint_duplicates(last, &span) {
3749                let choose_incoming = span.text.chars().count() > last.text.chars().count()
3750                    || (span.text.chars().count() == last.text.chars().count()
3751                        && span.width > last.width);
3752                let preferred_text = if choose_incoming {
3753                    span.text.clone()
3754                } else {
3755                    last.text.clone()
3756                };
3757                let left = last.x.min(span.x);
3758                let right = last.right().max(span.right());
3759                last.x = left;
3760                last.y = (last.y + span.y) * 0.5;
3761                last.width = (right - left).max(last.width).max(span.width);
3762                last.height = last.height.max(span.height);
3763                last.font_size = last.font_size.max(span.font_size);
3764                last.text = preferred_text;
3765                continue;
3766            }
3767        }
3768
3769        deduped.push(span);
3770    }
3771
3772    *spans = deduped;
3773}
3774
3775fn spans_are_overprint_duplicates(lhs: &TextSpan, rhs: &TextSpan) -> bool {
3776    let lhs_text = lhs.text.trim();
3777    let rhs_text = rhs.text.trim();
3778    if lhs_text.is_empty() || rhs_text.is_empty() {
3779        return false;
3780    }
3781
3782    let size = lhs.font_size.max(rhs.font_size);
3783    let drift = (lhs.y - rhs.y).abs();
3784    // Ink over ink: `right()` widens a short span to half an em per character
3785    // for layout, and through that estimate two spans standing side by side
3786    // count as mostly overlapping -- `t ` beside `t`, `oll` beside `o` -- so
3787    // the second reads as a fake-bold overprint and a mid-word letter is lost
3788    // (`follws`, `heir`). A strike over the first sits on the same ink, which
3789    // the measured edges still overlap; neighbours only touch there.
3790    // Quarter-turned spans already measure this way: their `right()` is the
3791    // measured edge.
3792    let lhs_left = lhs.x;
3793    let lhs_right = lhs.measured_right();
3794    let rhs_left = rhs.x;
3795    let rhs_right = rhs.measured_right();
3796    let overlap = (lhs_right.min(rhs_right) - lhs_left.max(rhs_left)).max(0.0);
3797    let min_width = (lhs_right - lhs_left).min(rhs_right - rhs_left).max(1.0);
3798    let share = overlap / min_width;
3799
3800    // A SHADOW IS THE SAME TEXT A LITTLE OFF (#1973). A label drawn twice --
3801    // a halo or drop shadow under it, sometimes a size larger -- sits up to a
3802    // quarter of an em off the first copy's baseline and overlaps it by less
3803    // than a fake-bold overprint does: `KFTC` 0.85 point lower at 4.9 points,
3804    // `NRST` 5.52 over 4.90. Concatenated, it read `KFTCKFTC`. Only for the SAME
3805    // text, and still only where the two lie mostly over one another, so two
3806    // cells or two words that happen to agree never meet here. And of two
3807    // characters or more: one character's shadow and its neighbour are the
3808    // same picture -- the two `1`s of a matrix, the `ll` of `Ersatzteilliste`
3809    // -- and a letter lost there is worse than a letter doubled.
3810    //
3811    // HALF THE WIDTH IS ENOUGH FROM THREE CHARACTERS ON (#623). NASDAQ_SHEN_2003
3812    // (DocLayNet) sets its current-year column head twice, the second copy 8.0
3813    // points right and 0.86 higher at 10.08 points: the two `2003`s overlap by
3814    // 60 per cent, and the page read `20032003` in ten of its tables where the
3815    // annotation reads `2003`. Two different cells cannot lie half over one
3816    // another, and a word of three characters or more drawn twice at that
3817    // distance is a copy; two characters keep the stricter share, for the
3818    // reason one character is left alone.
3819    let marks = lhs_text.chars().filter(|c| !c.is_whitespace()).count();
3820    let needed = if marks >= 3 { 0.5 } else { 0.7 };
3821    if lhs_text == rhs_text && marks >= 2 && drift <= size * 0.25 && share >= needed {
3822        return true;
3823    }
3824
3825    // Fake bold: the same line struck again with sub-point drift.
3826    if drift > size * 0.12 || share < 0.85 {
3827        return false;
3828    }
3829    lhs_text == rhs_text || lhs_text.starts_with(rhs_text) || rhs_text.starts_with(lhs_text)
3830}
3831
3832/// Does `curr` carry on from `prev`, or does it lie under it?
3833///
3834/// The join in `TextBlock::text` drops a word `curr` repeats from the end of
3835/// `prev`, because a document that draws that word twice at the seam between
3836/// two runs holds it once (0105.pdf, #1014). That reading needs `curr` to
3837/// begin where `prev` stopped. A run that begins a whole character *inside*
3838/// its predecessor is not the continuation of it: a text field draws its value
3839/// with the advance the font asks for, which is regularly wider than the field
3840/// box, so each of three fields on one row of a form reaches tens of points
3841/// into the next field's column. With the same value in all three -- a
3842/// repeated date, a column of `N/A`, or Acroform-PDFBOX-2333's own value -- the
3843/// second and the third read as the first one's tail said again, and dropping
3844/// them loses two filled fields. On a form that is the loss of entered data
3845/// (#432, class R-E of #389).
3846///
3847/// One glyph of `curr`'s own mean advance is the allowance, and it is there
3848/// for the seam itself: a measured width carries the last glyph's side bearing
3849/// and a rounding, never a column of another field.
3850///
3851/// The mean is per GLYPH, not per character, and `char_bounds` holds one entry
3852/// per glyph drawn. A glyph that decodes to several characters -- a `fi`
3853/// ligature, any `bfrange` that names a string -- would otherwise divide one
3854/// advance by two or three characters and shrink the allowance below the seam
3855/// it exists for. The character count answers only for a span that carries no
3856/// bounds, which is every hand-built one.
3857fn second_run_carries_on_from_the_first(prev: &TextSpan, curr: &TextSpan) -> bool {
3858    let glyphs = if curr.char_bounds.is_empty() {
3859        curr.text.chars().count()
3860    } else {
3861        curr.char_bounds.len()
3862    };
3863    if glyphs == 0 {
3864        return false;
3865    }
3866
3867    let mean_advance = curr.width / glyphs as f64;
3868    // Along the previous run's baseline, so that a rotated seam is measured in
3869    // the direction the two runs actually overlap in (#435).
3870    let direction = span_direction(prev);
3871    let (curr_along, _) = baseline_coordinates(curr, direction);
3872    baseline_end(prev, direction) - curr_along <= mean_advance
3873}
3874
3875/// A space drawn over the letter before it is no word space (#574).
3876///
3877/// The page draws the word as two runs, and the second run starts with a
3878/// SPACE character placed LEFT of where the first run ends: after a ligature
3879/// glyph the next run starts ` cally` 0.3 em back, after an initial capital
3880/// ` ransition` 0.6 em back. A real word space never starts left of the
3881/// letter before it, so where the next run overlaps the previous by more
3882/// than a tenth of an em and opens with a space and a lower-case letter,
3883/// the join carries no space and the leading space is dropped.
3884///
3885/// Lower-case only, and that is the whole of the guard: leading space plus
3886/// overlap alone fires a thousand times across formulas and tables (`v 2`,
3887/// `0 N`), and some of those are real gaps. A capital or a digit after the
3888/// space keeps its space.
3889///
3890/// `gap` is the join's own gap, measured along the baseline the same way the
3891/// 0.25 em rule measures it. Where this can fire the previous run ends in a
3892/// letter, so it is never a marks-only span and the anchor the gap was
3893/// measured from is the previous run itself.
3894///
3895/// This runs before the ligature fold, which reads the assembled text: the
3896/// previous run still ends in the ligature code point here.
3897fn space_drawn_over_previous_run(prev: &TextSpan, curr: &TextSpan, gap: f64) -> bool {
3898    if gap >= -0.1 * prev.font_size {
3899        return false;
3900    }
3901    let mut next = curr.text.chars();
3902    if next.next() != Some(' ') || !next.next().is_some_and(|c| c.is_lowercase()) {
3903        return false;
3904    }
3905    prev.text
3906        .chars()
3907        .last()
3908        .is_some_and(|c| c.is_alphabetic() || ('\u{FB00}'..='\u{FB06}').contains(&c))
3909}
3910
3911fn trim_overlapping_word_prefix(prev: &str, curr: &str) -> Option<String> {
3912    let prev_chars: Vec<char> = prev.trim_end().chars().collect();
3913    let curr_chars: Vec<char> = curr.trim_start().chars().collect();
3914    let max = prev_chars.len().min(curr_chars.len());
3915
3916    for len in (4..=max).rev() {
3917        let prev_start = prev_chars.len() - len;
3918        if prev_chars[prev_start..] != curr_chars[..len] {
3919            continue;
3920        }
3921
3922        if !curr_chars[..len].iter().all(|ch| ch.is_alphanumeric()) {
3923            continue;
3924        }
3925
3926        let prev_boundary = prev_start == 0 || !prev_chars[prev_start - 1].is_alphanumeric();
3927        let curr_boundary = len == curr_chars.len() || !curr_chars[len].is_alphanumeric();
3928        if !prev_boundary || !curr_boundary {
3929            continue;
3930        }
3931
3932        return Some(curr_chars[len..].iter().collect());
3933    }
3934
3935    None
3936}
3937
3938/// Compute an adaptive column gap threshold from a set of bands.
3939///
3940/// Collects all positive inter-span gaps within each band, computes the
3941/// median, and returns `COLUMN_GAP_MEDIAN_MULTIPLIER × median`, clamped to
3942/// `[COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX]`.  Falls back to
3943/// `COLUMN_GAP_THRESHOLD_FALLBACK` when there are no measurable gaps.
3944fn compute_adaptive_column_gap(bands: &[TextBand]) -> f64 {
3945    let mut all_gaps: Vec<f64> = Vec::new();
3946
3947    for band in bands {
3948        if band.spans.len() < 2 {
3949            continue;
3950        }
3951        let mut sorted = band.spans.clone();
3952        sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
3953        let mut prev_right = sorted[0].right();
3954        for span in sorted.iter().skip(1) {
3955            let gap = span.x - prev_right;
3956            if gap > 0.0 {
3957                all_gaps.push(gap);
3958            }
3959            prev_right = prev_right.max(span.right());
3960        }
3961    }
3962
3963    if all_gaps.is_empty() {
3964        return COLUMN_GAP_THRESHOLD_FALLBACK;
3965    }
3966
3967    all_gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3968
3969    let min_gap = all_gaps[0];
3970
3971    // When all inter-span gaps are already large (> MIN threshold), they are
3972    // likely all column gaps — the draw_glyph merger absorbed word-level
3973    // spaces into span text.  Use a fraction of the smallest gap so that
3974    // ALL column gaps exceed the threshold.
3975    if min_gap > COLUMN_GAP_THRESHOLD_MIN {
3976        return (min_gap * 0.75).clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
3977    }
3978
3979    // Look for a natural break: the largest relative jump between consecutive
3980    // sorted gaps separates word-level gaps from column gaps.
3981    let mut best_break_threshold = 0.0f64;
3982    let mut best_ratio = 1.5f64; // require at least 1.5× jump
3983    for pair in all_gaps.windows(2) {
3984        if pair[0] > 0.5 {
3985            let ratio = pair[1] / pair[0];
3986            if ratio > best_ratio {
3987                best_ratio = ratio;
3988                best_break_threshold = (pair[0] + pair[1]) * 0.5;
3989            }
3990        }
3991    }
3992
3993    if best_break_threshold > 0.0 {
3994        return best_break_threshold.clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
3995    }
3996
3997    // Fallback: median × multiplier.
3998    let mid = all_gaps.len() / 2;
3999    let median = if all_gaps.len().is_multiple_of(2) {
4000        (all_gaps[mid - 1] + all_gaps[mid]) * 0.5
4001    } else {
4002        all_gaps[mid]
4003    };
4004
4005    (median * COLUMN_GAP_MEDIAN_MULTIPLIER)
4006        .clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX)
4007}
4008
4009/// Group spans into reading-order blocks, using column-aware reordering when
4010/// a contiguous region repeatedly exposes the same gutters.
4011/// Per-page adaptive parameters derived from the span set before any
4012/// grouping happens. Centralising these here (TEX4) means the rest of
4013/// the pipeline — band grouping, XY-Cut cuts, in-block space insertion
4014/// — all speak the same typographic baseline for this specific page,
4015/// rather than each helper reaching for an independent fixed constant.
4016#[derive(Debug, Clone, Copy)]
4017struct PageStats {
4018    /// Median font size across all spans (pt).
4019    median_font_size: f64,
4020    /// Median measured character width (pt). Zero-guarded fallback is
4021    /// 0.5 × median_font_size when there aren't enough samples.
4022    /// Currently populated for diagnostics / future tuning; allow dead_code
4023    /// under `-D warnings` until a reader is added.
4024    #[allow(dead_code)]
4025    median_char_width: f64,
4026    /// Tight line-to-line spacing (25th percentile of pairwise band
4027    /// gaps), representing the body-text leading on this page. The
4028    /// quartile is used instead of the median so large paragraph /
4029    /// zone gaps don't inflate the baseline. Zero if the page has
4030    /// only one band.
4031    median_line_spacing: f64,
4032}
4033
4034impl PageStats {
4035    fn from_spans(spans: &[TextSpan]) -> Self {
4036        if spans.is_empty() {
4037            return Self {
4038                median_font_size: 12.0,
4039                median_char_width: 6.0,
4040                median_line_spacing: 0.0,
4041            };
4042        }
4043
4044        // Median font size.
4045        let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
4046        sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4047        let median_font_size = sizes[sizes.len() / 2];
4048
4049        // Median char width — measured width / char count, per span.
4050        let mut char_widths: Vec<f64> = spans
4051            .iter()
4052            .filter_map(|s| {
4053                let chars = s.text.chars().count();
4054                if chars > 0 && s.width > 0.0 {
4055                    Some(s.width / chars as f64)
4056                } else {
4057                    None
4058                }
4059            })
4060            .collect();
4061        let median_char_width = if char_widths.is_empty() {
4062            median_font_size * 0.5
4063        } else {
4064            char_widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4065            char_widths[char_widths.len() / 2]
4066        };
4067
4068        // Median line spacing — pairwise gaps between consecutive band
4069        // y-values.
4070        let band_tolerance = (median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
4071        let mut ys: Vec<f64> = spans.iter().map(|s| s.y).collect();
4072        ys.sort_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
4073        let mut band_ys: Vec<f64> = Vec::new();
4074        for y in ys {
4075            if band_ys
4076                .last()
4077                .map(|prev: &f64| (prev - y).abs() > band_tolerance)
4078                .unwrap_or(true)
4079            {
4080                band_ys.push(y);
4081            }
4082        }
4083        // ANN[r17/TEX4] "Line spacing" here means the TIGHT line-to-line
4084        // gap inside a text block — not the median of all gaps. Using
4085        // the median drags the estimate up when the page has
4086        // paragraph / zone breaks (which are the very gaps the
4087        // paragraph-break threshold is supposed to EXCEED). The 25th
4088        // percentile is the smallest gap that still shows up in more
4089        // than one place on the page; it captures body-text leading
4090        // robustly even when large zone gaps dominate.
4091        let median_line_spacing = if band_ys.len() < 2 {
4092            0.0
4093        } else {
4094            let mut spacings: Vec<f64> = band_ys
4095                .windows(2)
4096                .map(|pair| (pair[0] - pair[1]).abs())
4097                .collect();
4098            spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4099            let q1_index = spacings.len() / 4;
4100            spacings[q1_index]
4101        };
4102
4103        Self {
4104            median_font_size,
4105            median_char_width,
4106            median_line_spacing,
4107        }
4108    }
4109}
4110
4111// ANN[r17/TEX2] Maximum recursion depth for XY-Cut. Any real page layout
4112// is decomposable in well under 10 alternating cuts; the cap guards
4113// against pathological inputs where the cut predicate keeps triggering
4114// due to floating-point drift.
4115const XY_CUT_MAX_DEPTH: usize = 12;
4116// THE GUTTER IS MEASURED AGAINST THE TYPE, NOT AGAINST THE PAGE. There was a
4117// third rail here -- a vertical gap had to reach 4% of the region's width --
4118// and on the page it was written for it is the only rail that ever binds. A
4119// two-column paper is set in 9pt type inside a 500pt text block, so 4% asks for
4120// 20pt of white where the typographer left 13pt: wider than the gutter of every
4121// journal that ever printed two columns, which is what "narrow-gutter academic
4122// layouts are still detected" in the comment below was meant to allow and did
4123// not. It refused the cut on tracemonkey's pages 2 and 13 and on the same pages
4124// of highlights and tracemonkey_with_editable_annotations -- six of the eleven
4125// pages that were left under 95% on the gate set (#389).
4126//
4127// What separates a gutter from a word space is the type, and that is what the
4128// two remaining rails say: the median font size (a word space is a quarter of
4129// an em, a gutter is more than a whole one) and a 10pt absolute floor. A page
4130// four times as wide does not get four times the word spaces, so scaling the
4131// question with the page was never measuring the thing it asked about.
4132/// Floor (in pt) for vertical gap regardless of region width. Matches
4133/// the previous `COLUMN_GAP_THRESHOLD_MIN` and keeps XY-Cut conservative
4134/// on narrow regions (sidebars, tall columns).
4135const XY_CUT_VERTICAL_GAP_FLOOR: f64 = 10.0;
4136/// Multiplier applied to median font size to produce the horizontal-gap
4137/// threshold. 1.8 × line-height matches typical paragraph spacing.
4138const XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER: f64 = 1.8;
4139
4140/// Whether the horizontal cut at `cut_y` falls INSIDE one grid (#596).
4141///
4142/// The defect this refuses is the largest single item on the tables axis, and it
4143/// is not in `tables.rs` at all. A table whose rows are grouped under labels --
4144/// `Study Design`, `Gender studied`, `Region` -- leaves a little more white
4145/// between the groups than between the rows of a group, and
4146/// `try_horizontal_cut`'s threshold is a multiple of the ROW pitch. On
4147/// `PMC3037919-p0007` the rows sit 12.0 points apart and the groups 23.8, against
4148/// a threshold of 12 * 1.8 = 21.6: so the cut fires between every group, each
4149/// group becomes its own region, and `blocks::paragraphs` -- whose stretches are
4150/// bounded per region -- hands `tables::find` one group at a time. The page comes
4151/// back as twelve tables where the document holds one, and `find` is blameless:
4152/// it is called twelve times with between one and six rows and does the right
4153/// thing with each of them. Measured over both sets, that shape is 34 pages of
4154/// DocLayNet and 29 of PubTables-1M, and 0.3378 of the tables axis's 0.5442.
4155///
4156/// WHAT SAYS "ONE GRID" IS THE COLUMN EDGES, and the measurement already exists:
4157/// `TextBand::gap_midpoints` gives the x where a band's runs are separated, and
4158/// `boundaries_match` asks whether two bands are separated at the same places.
4159/// `region_is_a_grid` has asked exactly this question since #429 -- and is
4160/// reachable only from `group_spans_into_blocks_legacy_from_bands`, the path whose
4161/// own comment says XY-Cut supersedes it. The vertical cut carries two guards of
4162/// this kind (`region_is_ruled` and `the_cut_is_a_column_of_a_table`); the
4163/// horizontal cut carried none, so a guard that would have refused the dominant
4164/// table defect was left on the dead side of a replacement.
4165///
4166/// BOTH SIDES, and at least two bands each. One band above a cut agreeing with
4167/// one below is two lines of prose that happen to break at the same place; four
4168/// bands agreeing across the cut is a grid. The bands are taken adjacent to the
4169/// cut rather than over the whole region, because a table's own caption and the
4170/// prose above it break nowhere and would refuse every table.
4171fn the_cut_is_inside_a_grid(bands: &[TextBand], cut_y: f64, gap: f64) -> bool {
4172    let threshold = compute_adaptive_column_gap(bands);
4173    // `TextBand` has no y accessor of its own; a band's y is its spans' and they
4174    // are within the band tolerance of each other, so the maximum will do.
4175    let band_y = |band: &TextBand| {
4176        band.spans
4177            .iter()
4178            .map(|span| span.y)
4179            .fold(f64::NEG_INFINITY, f64::max)
4180    };
4181    let mut above: Vec<&TextBand> = bands.iter().filter(|band| band_y(band) > cut_y).collect();
4182    let mut below: Vec<&TextBand> = bands.iter().filter(|band| band_y(band) <= cut_y).collect();
4183    // Nearest the cut first on each side: ascending y above it, descending below.
4184    above.sort_by(|a, b| band_y(a).partial_cmp(&band_y(b)).unwrap_or(Ordering::Equal));
4185    below.sort_by(|a, b| band_y(b).partial_cmp(&band_y(a)).unwrap_or(Ordering::Equal));
4186    if above.len() < GRID_SIDE_ROWS || below.len() < GRID_SIDE_ROWS {
4187        return false;
4188    }
4189    // The bands touching the cut, nearest first on each side.
4190    let touching: Vec<&TextBand> = above
4191        .iter()
4192        .take(GRID_SIDE_ROWS)
4193        .chain(below.iter().take(GRID_SIDE_ROWS))
4194        .copied()
4195        .collect();
4196    // AND THE GAP HAS TO BE A GROUP'S GAP AND NOT A ZONE'S. Refusing every cut
4197    // whose two sides share column edges refuses the cut that takes the prose off
4198    // the top of the table as well -- and then the region handed to
4199    // `tables::find` opens with more prose than its `MAX_TRIMMED_ROWS` will trim,
4200    // so the grid is refused outright and the page comes back with NO table.
4201    // Measured: `PMC1277013-p0003` went from six tables to none that way, which is
4202    // worse than the six. The white between the label groups of one table is a
4203    // small multiple of its row pitch; the white above a table is not.
4204    let mut steps: Vec<f64> = touching
4205        .windows(2)
4206        .map(|pair| (band_y(pair[0]) - band_y(pair[1])).abs())
4207        .filter(|step| *step > 0.0)
4208        .collect();
4209    steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4210    let pitch = match steps.first() {
4211        Some(pitch) => *pitch,
4212        None => return false,
4213    };
4214    if gap > GRID_GROUP_GAP_PITCHES * pitch {
4215        return false;
4216    }
4217
4218    // A ROW WITH AN EMPTY CELL IS STILL A ROW OF THE GRID, and this is the whole
4219    // difficulty. `boundaries_match` asks for the same NUMBER of separations on
4220    // both bands, which is right for two lines of a column cut and wrong here: the
4221    // rows that make a table fragment are exactly the ones whose label cell is
4222    // empty, so they hold one gap fewer than the row above them. A guard built on
4223    // equal counts would refuse the grid precisely where the grid has the empty
4224    // cells that caused the split -- the same way round as a detector I wrote for
4225    // the interleave class, which could not fire on the pages it was for.
4226    //
4227    // So the widest band sets the edges and every other band has to agree with a
4228    // SUBSET of them: each of its separations falls on one of the edges, and no
4229    // separation of its own sits where the widest band has ink.
4230    let reference = touching
4231        .iter()
4232        .map(|band| band.gap_midpoints(threshold))
4233        .max_by_key(Vec::len)
4234        .unwrap_or_default();
4235    if reference.len() < GRID_MIN_BOUNDARIES {
4236        return false;
4237    }
4238    let tolerance = (threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
4239    // A BAND WITH ONE RUN OF INK IS A ROW OF THE TABLE TOO, and refusing on it
4240    // would refuse exactly the tables this guard is for: the section heads set
4241    // across a grid -- `Region`, `Health status` -- are what the label groups are
4242    // divided by, so they touch almost every cut worth refusing. `tables::find`
4243    // already reads such a band as a row of the table when the grid goes on under
4244    // it (#576); this is the same judgement one layer up. One is allowed and not
4245    // two, because two in a row is prose between two tables rather than a head
4246    // inside one.
4247    let mut unseparated = 0usize;
4248    for band in &touching {
4249        let midpoints = band.gap_midpoints(threshold);
4250        if midpoints.is_empty() {
4251            unseparated += 1;
4252            if unseparated > GRID_MAX_UNSEPARATED {
4253                return false;
4254            }
4255            continue;
4256        }
4257        if !midpoints
4258            .iter()
4259            .all(|mid| reference.iter().any(|edge| (edge - mid).abs() <= tolerance))
4260        {
4261            return false;
4262        }
4263    }
4264    true
4265}
4266
4267/// How many bands on EACH side of a horizontal cut must agree on the column
4268/// edges before the cut is inside one grid (#596). Two: one band against one is
4269/// a coincidence two lines of prose can produce.
4270const GRID_SIDE_ROWS: usize = 2;
4271/// How many row pitches of white a gap may hold and still be a gap BETWEEN THE
4272/// GROUPS of one table rather than the white above the table (#596).
4273const GRID_GROUP_GAP_PITCHES: f64 = 5.0;
4274/// How many column separations the widest band across a cut must show before the
4275/// bands can be one grid (#596). Two, so three columns: a single separation is
4276/// what every line of prose with one wide word space has.
4277const GRID_MIN_BOUNDARIES: usize = 2;
4278/// How many of the bands touching a cut may hold a single run of ink and still
4279/// leave the rest a grid (#596): a section head set across the table.
4280const GRID_MAX_UNSEPARATED: usize = 1;
4281
4282/// A table's extent on the page, found before the XY-cut runs (#574).
4283#[derive(Debug, Clone, Copy, PartialEq)]
4284struct TableRegion {
4285    left: f64,
4286    right: f64,
4287    /// The highest baseline of the region's first row.
4288    top: f64,
4289    /// The lowest baseline of its last row.
4290    bottom: f64,
4291    /// Whether the region was found by something stronger than aligned text --
4292    /// rules above and below, two columns of keys, a leader -- so that a column
4293    /// cut through it is the cut dividing a table and not a page.
4294    ruled: bool,
4295    /// How many rows the detector counted in it.
4296    rows: usize,
4297}
4298
4299impl TableRegion {
4300    fn holds(&self, span: &TextSpan) -> bool {
4301        let centre = span.x + (span.right() - span.x) * 0.5;
4302        span.y <= self.top && span.y >= self.bottom && centre >= self.left && centre <= self.right
4303    }
4304}
4305
4306/// One run of ink inside a band: its left and right edge and how many
4307/// characters it holds.
4308#[derive(Debug, Clone)]
4309struct InkRun {
4310    left: f64,
4311    right: f64,
4312    chars: usize,
4313    /// Whether the run opens with a bullet glyph and goes on in text.
4314    bulleted: bool,
4315    /// The run's text, its spans joined by a space.
4316    text: String,
4317}
4318
4319/// Whether a span is nothing but a dot leader's dots: the filler a table of
4320/// contents or a statement runs from a label out to its figure. It is white to
4321/// the eye that reads the row, and to the table found in it (#574).
4322fn is_leader(span: &TextSpan) -> bool {
4323    let text = span.text.trim();
4324    !text.is_empty()
4325        && text
4326            .chars()
4327            .all(|c| matches!(c, '.' | '\u{00B7}' | '\u{2026}' | ' '))
4328}
4329
4330/// Whether a run's first span opens with a bullet -- one that is not also a
4331/// dash, since a dash alone is the nil of a financial table and a minus opens a
4332/// figure. The bullet is often a span of its own, so whether text follows it is
4333/// asked of the whole run (`chars`).
4334fn opens_with_bullet(text: &str) -> bool {
4335    matches!(text.chars().find(|c| !c.is_whitespace()),
4336        Some(c) if crate::lists::BULLET_CHARS.contains(&c)
4337            && !matches!(c, '-' | '\u{2013}' | '\u{2014}'))
4338}
4339
4340fn ink_runs(band: &TextBand, gap: f64) -> Vec<InkRun> {
4341    let mut runs: Vec<InkRun> = Vec::new();
4342    for span in &band.spans {
4343        let chars = span.text.chars().filter(|c| !c.is_whitespace()).count();
4344        if chars == 0 || is_leader(span) {
4345            continue;
4346        }
4347        // The glyphs' own advance where the page gave one: the estimate is
4348        // deliberately wide, and a long label measured by it runs into the
4349        // value column it stops well short of.
4350        let right = if span.width > 0.0 {
4351            span.measured_right()
4352        } else {
4353            span.right()
4354        };
4355        match runs.last_mut() {
4356            Some(run) if span.x - run.right < gap => {
4357                run.right = run.right.max(right);
4358                run.chars += chars;
4359                run.text.push(' ');
4360                run.text.push_str(span.text.trim());
4361            }
4362            _ => runs.push(InkRun {
4363                left: span.x,
4364                right,
4365                chars,
4366                bulleted: opens_with_bullet(&span.text),
4367                text: span.text.trim().to_string(),
4368            }),
4369        }
4370    }
4371    runs
4372}
4373
4374/// The white strips between a band's runs, left edge and right edge.
4375fn run_gaps(runs: &[InkRun]) -> Vec<(f64, f64)> {
4376    runs.windows(2)
4377        .map(|pair| (pair[0].right, pair[1].left))
4378        .collect()
4379}
4380
4381/// Whether a row repeats the column heads of the row that opened the table:
4382/// the same text in every value column, three columns or more.
4383fn heads_again(first: &[InkRun], here: &[InkRun]) -> bool {
4384    first.len() >= 3
4385        && here.len() == first.len()
4386        && first
4387            .iter()
4388            .zip(here)
4389            .skip(1)
4390            .all(|(a, b)| a.text == b.text && a.text.chars().any(char::is_alphabetic))
4391}
4392
4393/// Which of the table's strips this gap falls in, if any. A strip is the white
4394/// every row so far leaves between two columns, so a gap belongs to it when the
4395/// two overlap: right-aligned figures move their left edges row by row and a
4396/// long label moves its right edge, and neither moves the strip.
4397fn strip_of(strips: &[(f64, f64)], gap: (f64, f64), slack: f64) -> Option<usize> {
4398    strips
4399        .iter()
4400        .position(|strip| gap.1.min(strip.1) - gap.0.max(strip.0) > -slack)
4401}
4402
4403/// The tables of a page, found on the WHOLE page before the XY-cut (#574).
4404///
4405/// The XY-cut decides the regions and `tables::find` only ever sees one region
4406/// at a time, so a table the cut runs through comes back as several -- or as a
4407/// table, a few loose paragraphs and another table. That is the largest single
4408/// item on the DocLayNet tables axis: the annotated table drawn as several
4409/// (33 of 64 pages of the round of 24-09, 58 % of the gap), and of the text the
4410/// annotation puts in a table a third goes to prose (T4, 27-09). Grouping the
4411/// cells we already produce by the annotation's own region takes detection from
4412/// 0.300 to 0.849 on DocLayNet's train split (T2-577), so the cells are right
4413/// and the region is the defect.
4414///
4415/// So the region is decided first, from the page's own bands. A ROW is a band
4416/// that breaks into three runs or more at a gap of two ems, at least two of them
4417/// short (a value, a year, a `$`). A TABLE is a stack of at least
4418/// `TABLE_REGION_MIN_ROWS` such rows that break at the SAME places, with room
4419/// between them for what a financial statement puts there: a group label in the
4420/// stub column (`Leasehold buildings and improvements`), a row with one value,
4421/// and the white between groups -- up to `TABLE_REGION_MAX_STEP_PITCHES` of the
4422/// page's line pitch. The cut then runs round each region and never through it.
4423fn table_regions(
4424    spans: &[TextSpan],
4425    stats: &PageStats,
4426    rules: &[HorizontalRule],
4427) -> Vec<TableRegion> {
4428    table_regions_seen(spans, stats, rules, None)
4429}
4430
4431/// [`table_regions`], and when `seen` is given, every region each detector
4432/// accepted before one gave way to another -- the proposals a judge chooses
4433/// between (#574, T4's judge).
4434fn table_regions_seen(
4435    spans: &[TextSpan],
4436    stats: &PageStats,
4437    rules: &[HorizontalRule],
4438    seen: Option<&mut Vec<(TableRegion, &'static str)>>,
4439) -> Vec<TableRegion> {
4440    if spans.len() < TABLE_REGION_MIN_ROWS * 2 {
4441        return Vec::new();
4442    }
4443    let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
4444    let em = stats.median_font_size.max(1.0);
4445    let gap = TABLE_REGION_GAP_EM * em;
4446    let tolerance = TABLE_REGION_EDGE_EM * em;
4447    // Never under a line of the body type: a page with superscripts or a
4448    // footnote block measures a tighter spacing than any of its rows keep,
4449    // and every step between two rows would then read as a break.
4450    let pitch = stats.median_line_spacing.max(em * 1.2);
4451    let runs: Vec<Vec<InkRun>> = bands.iter().map(|band| ink_runs(band, gap)).collect();
4452    // A key and its description may stand closer than two cells of a grid:
4453    // the second column of a two-column table starts where the longest key
4454    // ends, and the white between them can be a single em.
4455    let pair_runs: Vec<Vec<InkRun>> = bands
4456        .iter()
4457        .map(|band| ink_runs(band, TABLE_REGION_PAIR_GAP_EM * em))
4458        .collect();
4459    // Columns of bulleted items side by side line up like a table and are
4460    // lists (a brochure's `• Power Plants  • Marine Propulsion  • Oil and Gas`).
4461    let is_row = |runs: &[InkRun]| {
4462        runs.len() >= 3
4463            && runs
4464                .iter()
4465                .filter(|run| run.bulleted && run.chars > 1)
4466                .count()
4467                < 2
4468            && runs
4469                .iter()
4470                .filter(|run| run.chars <= TABLE_REGION_SHORT_CELL)
4471                .count()
4472                >= 2
4473            && runs
4474                .iter()
4475                .skip(1)
4476                .all(|run| run.chars <= TABLE_REGION_LONG_CELL)
4477    };
4478
4479    // Asked for what the detectors considered, the loop keeps the stacks of
4480    // two rows as well; they are proposals the judge may rescue, and they are
4481    // left out of what the page itself uses below.
4482    let min_rows = if seen.is_some() {
4483        2
4484    } else {
4485        TABLE_REGION_MIN_ROWS
4486    };
4487    let mut regions = Vec::new();
4488    let mut i = 0;
4489    while i < bands.len() {
4490        if !is_row(&runs[i]) {
4491            i += 1;
4492            continue;
4493        }
4494        let mut strips = run_gaps(&runs[i]);
4495        // Where the first value column starts: a label may run up to it.
4496        let mut values_left = runs[i]
4497            .iter()
4498            .skip(1)
4499            .map(|run| run.left)
4500            .fold(f64::INFINITY, f64::min);
4501        let mut last_row = i;
4502        let mut rows = 1;
4503        let mut loose = 0;
4504        let mut above = bands[i].y;
4505        let mut j = i + 1;
4506        while j < bands.len() {
4507            let here = &runs[j];
4508            // A band of nothing but blank glyphs is white, not a row.
4509            if here.is_empty() {
4510                j += 1;
4511                continue;
4512            }
4513            if above - bands[j].y > TABLE_REGION_MAX_STEP_PITCHES * pitch {
4514                break;
4515            }
4516            above = bands[j].y;
4517            let gaps = run_gaps(here);
4518            let found: Vec<Option<usize>> = gaps
4519                .iter()
4520                .map(|gap| strip_of(&strips, *gap, tolerance))
4521                .collect();
4522            // THE HEAD AGAIN IS A SECOND TABLE: a page that sets two years'
4523            // statements one under the other repeats the column heads over
4524            // the second, and the annotation draws two tables there.
4525            if heads_again(&runs[i], here) {
4526                break;
4527            }
4528            // A CAPTION IS WHERE THE NEXT TABLE STARTS, however short and
4529            // wherever it stands: `Table 2 The same by subtype` between two
4530            // banks is two tables.
4531            let line: Vec<&str> = here.iter().map(|run| run.text.as_str()).collect();
4532            if crate::tables::announces_a_table(&line.join(" ")) {
4533                break;
4534            }
4535            if is_row(here) {
4536                // Most of this row's gaps must fall in strips the table already
4537                // has; a row with one gap more (a column the header did not
4538                // fill) adds it.
4539                let known = found.iter().flatten().count();
4540                if known < 2.min(strips.len()) || known * 2 < gaps.len() {
4541                    break;
4542                }
4543                for (gap, at) in gaps.iter().zip(&found) {
4544                    match at {
4545                        Some(k) => {
4546                            let (lo, hi) = (strips[*k].0.max(gap.0), strips[*k].1.min(gap.1));
4547                            if hi - lo >= TABLE_REGION_MIN_STRIP {
4548                                strips[*k] = (lo, hi);
4549                            }
4550                        }
4551                        None => strips.push(*gap),
4552                    }
4553                }
4554                values_left = here
4555                    .iter()
4556                    .skip(1)
4557                    .map(|run| run.left)
4558                    .fold(values_left, f64::min);
4559                last_row = j;
4560                rows += 1;
4561                loose = 0;
4562            } else {
4563                // A label in the stub column, or a row with fewer cells filled
4564                // whose breaks fall on the table's own edges.
4565                let first_edge = strips.iter().map(|s| s.1).fold(f64::INFINITY, f64::min);
4566                // A label stops short of the figures; a section head set
4567                // across the table may run over them, and is short.
4568                let label = here.len() == 1
4569                    && here[0].left < first_edge
4570                    && (here[0].right < values_left - tolerance
4571                        || here[0].chars <= TABLE_REGION_LONG_CELL);
4572                let sparse = here.len() == 2 && found.iter().all(Option::is_some);
4573                // A cell's second line: one run that closes none of the strips
4574                // -- it may be longer than the line above it, but it does not
4575                // reach across into the next column.
4576                let wrapped = here.len() == 1
4577                    && strips
4578                        .iter()
4579                        .all(|strip| here[0].left > strip.0 || here[0].right < strip.1);
4580                if !(label || sparse || wrapped) {
4581                    break;
4582                }
4583                // A row with a cell left empty is a row of the table even as
4584                // its last line; a label is only a row of it with a row under it.
4585                if sparse {
4586                    last_row = j;
4587                }
4588                loose += 1;
4589                if loose > TABLE_REGION_MAX_LOOSE {
4590                    break;
4591                }
4592            }
4593            j += 1;
4594        }
4595        // THE HEAD OF THE TABLE, above its first row: a line of years or units
4596        // set over the value columns with nothing in the stub, or a head that
4597        // spans two of them, is not a row by the test above, and it is the
4598        // first thing the annotation's box holds.
4599        let mut first_row = i;
4600        let mut k = i;
4601        let mut heads = 0;
4602        while k > 0 && heads < TABLE_REGION_MAX_HEADS {
4603            k -= 1;
4604            let here = &runs[k];
4605            if here.is_empty() {
4606                continue;
4607            }
4608            if bands[k].y - bands[first_row].y > TABLE_REGION_MAX_STEP_PITCHES * pitch {
4609                break;
4610            }
4611            // Over the value columns only, or a row of heads whose breaks fall
4612            // in the table's strips -- a head spanning two columns among them.
4613            let over_values = here.iter().all(|run| run.left >= values_left - tolerance);
4614            let gaps = run_gaps(here);
4615            if (!over_values && gaps.is_empty())
4616                || !gaps
4617                    .iter()
4618                    .all(|gap| strip_of(&strips, *gap, tolerance).is_some())
4619            {
4620                break;
4621            }
4622            first_row = k;
4623            heads += 1;
4624        }
4625        if rows >= min_rows && strips.len() >= 2 {
4626            let chosen = &bands[first_row..=last_row];
4627            let mut left = chosen
4628                .iter()
4629                .map(TextBand::left)
4630                .fold(f64::INFINITY, f64::min);
4631            // A STUB THAT IS A COLUMN OF PROSE is the page's other column: on a
4632            // paper set in two columns a table's rows share their bands with
4633            // the lines of the paragraph beside it, and each such line reads as
4634            // the row's first cell. Labels are ragged on the right; justified
4635            // prose is not. Where most rows open with a long run and those runs
4636            // end flush, the region starts where the rows' second run does.
4637            if let Some(cut) = prose_stub_edge(&runs[first_row..=last_row], em) {
4638                left = cut;
4639            }
4640            let right = chosen
4641                .iter()
4642                .map(TextBand::right)
4643                .fold(f64::NEG_INFINITY, f64::max);
4644            let top = bands[first_row]
4645                .spans
4646                .iter()
4647                .map(|span| span.y)
4648                .fold(f64::NEG_INFINITY, f64::max);
4649            let bottom = bands[last_row]
4650                .spans
4651                .iter()
4652                .map(|span| span.y)
4653                .fold(f64::INFINITY, f64::min);
4654            regions.push(TableRegion {
4655                left,
4656                right,
4657                top,
4658                bottom,
4659                ruled: false,
4660                rows,
4661            });
4662        }
4663        i = last_row + 1;
4664    }
4665    if let Some(seen) = seen {
4666        seen.extend(regions.iter().map(|r| (*r, "aligned")));
4667        regions.retain(|r| r.rows >= TABLE_REGION_MIN_ROWS);
4668        seen.extend(
4669            booktabs_regions(&bands, &runs, rules, pitch, em)
4670                .into_iter()
4671                .map(|r| (r, "booktabs")),
4672        );
4673        seen.extend(
4674            pair_regions(&bands, &pair_runs, pitch, tolerance, &[], 2)
4675                .into_iter()
4676                .map(|r| (r, "pair")),
4677        );
4678        seen.extend(
4679            leader_regions(&bands, &runs, pitch, &[], 2)
4680                .into_iter()
4681                .map(|r| (r, "leader")),
4682        );
4683    }
4684    // A table the document ruled above and below is the table the rules
4685    // enclose, head and all: what the alignment found inside it gives way.
4686    for ruled in booktabs_regions(&bands, &runs, rules, pitch, em) {
4687        regions.retain(|r| !(r.top <= ruled.top + em && r.bottom >= ruled.bottom - em));
4688        if !regions
4689            .iter()
4690            .any(|r| r.bottom <= ruled.top && r.top >= ruled.bottom)
4691        {
4692            regions.push(ruled);
4693        }
4694    }
4695    let taken: Vec<(f64, f64)> = regions.iter().map(|r| (r.bottom, r.top)).collect();
4696    regions.extend(pair_regions(
4697        &bands,
4698        &pair_runs,
4699        pitch,
4700        tolerance,
4701        &taken,
4702        TABLE_REGION_MIN_ROWS,
4703    ));
4704    let taken: Vec<(f64, f64)> = regions.iter().map(|r| (r.bottom, r.top)).collect();
4705    regions.extend(leader_regions(
4706        &bands,
4707        &runs,
4708        pitch,
4709        &taken,
4710        TABLE_REGION_MIN_ROWS,
4711    ));
4712    regions
4713}
4714
4715/// Tables the document drew a rule above and below: the booktabs style of a
4716/// paper or a report, with no vertical lines and no rules between the rows
4717/// (#574, the idea from LiteParse's detector, #632).
4718///
4719/// A top rule and a rule below it of the same extent, at least
4720/// `TABLE_REGION_MIN_ROWS` bands between them all inside that extent, at least
4721/// two of them broken into cells, and no more white between two of them than a
4722/// table holds. The rule farthest down that still encloses such a stack is the
4723/// foot, so a mid-rule under the head is inside the table and not its end.
4724fn booktabs_regions(
4725    bands: &[TextBand],
4726    runs: &[Vec<InkRun>],
4727    rules: &[HorizontalRule],
4728    pitch: f64,
4729    em: f64,
4730) -> Vec<TableRegion> {
4731    // A GRID DRAWS ITS RULES CELL BY CELL: the rule under a row is often one
4732    // segment per cell, end to end, and no one of them spans the table. They are
4733    // one rule for this purpose, so segments at one height that meet are joined
4734    // first (EN tender annexes on DocLayNet: four segments to every rule).
4735    let mut joined: Vec<HorizontalRule> = rules.to_vec();
4736    joined.sort_by(|a, b| {
4737        b.y.partial_cmp(&a.y)
4738            .unwrap_or(Ordering::Equal)
4739            .then(a.left.partial_cmp(&b.left).unwrap_or(Ordering::Equal))
4740    });
4741    let mut merged: Vec<HorizontalRule> = Vec::with_capacity(joined.len());
4742    for rule in joined {
4743        match merged.last_mut() {
4744            Some(last)
4745                if (last.y - rule.y).abs() <= RULE_JOIN_TOLERANCE
4746                    && rule.left - last.right <= RULE_JOIN_TOLERANCE =>
4747            {
4748                last.right = last.right.max(rule.right);
4749            }
4750            _ => merged.push(rule),
4751        }
4752    }
4753    let mut rules: Vec<&HorizontalRule> = merged
4754        .iter()
4755        .filter(|rule| rule.right - rule.left >= TABLE_REGION_RULE_MIN_EM * em)
4756        .collect();
4757    rules.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
4758    let same_extent = |a: &HorizontalRule, b: &HorizontalRule| {
4759        let overlap = a.right.min(b.right) - a.left.max(b.left);
4760        let narrower = (a.right - a.left).min(b.right - b.left);
4761        overlap >= 0.9 * narrower && narrower >= 0.8 * (a.right - a.left).max(b.right - b.left)
4762    };
4763    let mut regions: Vec<TableRegion> = Vec::new();
4764    let mut t = 0;
4765    while t < rules.len() {
4766        let top = rules[t];
4767        let mut found: Option<(usize, TableRegion)> = None;
4768        for (f, foot) in rules.iter().enumerate().skip(t + 1) {
4769            if !same_extent(top, foot) {
4770                continue;
4771            }
4772            let inside: Vec<usize> = (0..bands.len())
4773                .filter(|&k| bands[k].y < top.y && bands[k].y > foot.y && !runs[k].is_empty())
4774                .collect();
4775            // The ink's own extent, from the runs: a band's `right()` is the
4776            // estimate's, deliberately wide, and a long line of a description
4777            // column measured by it runs out past the rule it stops short of.
4778            // A mark in the margin -- a manual's change bar `|`, a footnote
4779            // dagger -- stands outside the rules and is no ink of the table's:
4780            // runs of at most two characters are not asked.
4781            let within = |k: usize| {
4782                runs[k]
4783                    .iter()
4784                    .filter(|r| r.chars > TABLE_REGION_MARGIN_MARK)
4785                    .all(|r| r.left >= top.left - em && r.right <= top.right + em)
4786            };
4787            let contained = inside.iter().all(|&k| within(k));
4788            let celled = inside.iter().filter(|&&k| runs[k].len() >= 2).count();
4789            let tight = inside
4790                .windows(2)
4791                .all(|w| bands[w[0]].y - bands[w[1]].y <= TABLE_REGION_MAX_STEP_PITCHES * pitch);
4792            if !contained || !tight {
4793                break;
4794            }
4795            if inside.len() >= TABLE_REGION_MIN_ROWS && celled >= 2 {
4796                let top_y = inside
4797                    .first()
4798                    .map(|&k| {
4799                        bands[k]
4800                            .spans
4801                            .iter()
4802                            .map(|s| s.y)
4803                            .fold(f64::NEG_INFINITY, f64::max)
4804                    })
4805                    .unwrap_or(top.y);
4806                // A table ruled ABOVE each row has its last row under the last
4807                // rule: rows directly below it, in the extent and broken into
4808                // cells, are still the table's.
4809                let mut last = *inside.last().unwrap_or(&0);
4810                for k in last + 1..bands.len() {
4811                    if runs[k].is_empty() {
4812                        continue;
4813                    }
4814                    let in_extent = within(k);
4815                    if runs[k].len() < 2
4816                        || !in_extent
4817                        || bands[last].y - bands[k].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch
4818                        || foot.y - bands[k].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch
4819                    {
4820                        break;
4821                    }
4822                    last = k;
4823                }
4824                let bottom_y = bands[last]
4825                    .spans
4826                    .iter()
4827                    .map(|s| s.y)
4828                    .fold(f64::INFINITY, f64::min);
4829                found = Some((
4830                    f,
4831                    TableRegion {
4832                        left: top.left.min(foot.left),
4833                        right: top.right.max(foot.right),
4834                        top: top_y,
4835                        bottom: bottom_y,
4836                        ruled: true,
4837                        rows: inside.len(),
4838                    },
4839                ));
4840            }
4841        }
4842        match found {
4843            Some((f, region)) => {
4844                regions.push(region);
4845                t = f + 1;
4846            }
4847            None => t += 1,
4848        }
4849    }
4850    regions
4851}
4852
4853/// Where a region's rows start when their first runs are a column of justified
4854/// prose rather than labels: just left of the leftmost second run. `None` when
4855/// the first runs are ragged, short, or too few (#574).
4856fn prose_stub_edge(rows: &[Vec<InkRun>], em: f64) -> Option<f64> {
4857    let firsts: Vec<&InkRun> = rows
4858        .iter()
4859        .filter(|r| r.len() >= 2)
4860        .map(|r| &r[0])
4861        .collect();
4862    if firsts.len() < TABLE_REGION_MIN_ROWS {
4863        return None;
4864    }
4865    // The long runs, since a paragraph's last line and a short one of a new
4866    // paragraph are neither long nor flush; half the rows at least.
4867    let long: Vec<&&InkRun> = firsts
4868        .iter()
4869        .filter(|r| r.chars > TABLE_REGION_LONG_CELL)
4870        .collect();
4871    if long.len() * 2 < firsts.len() {
4872        return None;
4873    }
4874    let mut rights: Vec<f64> = long.iter().map(|r| r.right).collect();
4875    rights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4876    let median = rights[rights.len() / 2];
4877    let flush = rights
4878        .iter()
4879        .filter(|r| (*r - median).abs() <= TABLE_REGION_JUSTIFIED_EM * em)
4880        .count();
4881    if flush * 3 < rights.len() * 2 {
4882        return None;
4883    }
4884    rows.iter()
4885        .filter(|r| r.len() >= 2)
4886        .map(|r| r[1].left)
4887        .min_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
4888        .map(|x| x - 0.5 * em)
4889}
4890
4891/// How far apart, in ems, the right edges of a column's lines may be and still
4892/// be one justified edge.
4893const TABLE_REGION_JUSTIFIED_EM: f64 = 0.5;
4894
4895/// Tables of two columns: a short key and what it stands for (#574).
4896///
4897/// `Option | Function`, `F3 | Exit`, `11 | Work with PFD definitions` -- a
4898/// manual sets its menus and its function keys this way, and DocLayNet calls
4899/// every one a table. A row of two runs is also what a page set in two columns
4900/// is, which is why `table_regions` asks for three; here the two columns are
4901/// told from a page's by what they hold and how they stand: the first cell is
4902/// short and no list marker, the second is no bullet, and both start at the
4903/// same place on at least `TABLE_REGION_MIN_ROWS` rows running. The second
4904/// cell may wrap onto a line of its own under itself.
4905fn pair_regions(
4906    bands: &[TextBand],
4907    runs: &[Vec<InkRun>],
4908    pitch: f64,
4909    tolerance: f64,
4910    taken: &[(f64, f64)],
4911    min_rows: usize,
4912) -> Vec<TableRegion> {
4913    let free = |y: f64| !taken.iter().any(|(bottom, top)| y >= *bottom && y <= *top);
4914    let is_pair = |runs: &[InkRun]| {
4915        runs.len() == 2
4916            && runs[0].chars <= TABLE_REGION_SHORT_CELL
4917            && !runs[0].bulleted
4918            && !runs[1].bulleted
4919            && !is_list_marker(&runs[0].text)
4920    };
4921    let mut regions = Vec::new();
4922    let mut i = 0;
4923    while i < bands.len() {
4924        if !is_pair(&runs[i]) || !free(bands[i].y) {
4925            i += 1;
4926            continue;
4927        }
4928        let (key, value) = (runs[i][0].left, runs[i][1].left);
4929        let (mut last_row, mut rows, mut loose) = (i, 1, 0);
4930        let mut above = bands[i].y;
4931        let mut j = i + 1;
4932        while j < bands.len() {
4933            let here = &runs[j];
4934            if here.is_empty() {
4935                j += 1;
4936                continue;
4937            }
4938            if above - bands[j].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch || !free(bands[j].y) {
4939                break;
4940            }
4941            above = bands[j].y;
4942            if is_pair(here)
4943                && (here[0].left - key).abs() <= tolerance
4944                && (here[1].left - value).abs() <= tolerance
4945            {
4946                last_row = j;
4947                rows += 1;
4948                loose = 0;
4949            } else if here.len() == 1 && (here[0].left - value).abs() <= tolerance {
4950                loose += 1;
4951                if loose > TABLE_REGION_MAX_LOOSE {
4952                    break;
4953                }
4954            } else {
4955                break;
4956            }
4957            j += 1;
4958        }
4959        if rows >= min_rows {
4960            let chosen = &bands[i..=last_row];
4961            regions.push(TableRegion {
4962                left: chosen
4963                    .iter()
4964                    .map(TextBand::left)
4965                    .fold(f64::INFINITY, f64::min),
4966                right: chosen
4967                    .iter()
4968                    .map(TextBand::right)
4969                    .fold(f64::NEG_INFINITY, f64::max),
4970                top: bands[i]
4971                    .spans
4972                    .iter()
4973                    .map(|span| span.y)
4974                    .fold(f64::NEG_INFINITY, f64::max),
4975                bottom: bands[last_row]
4976                    .spans
4977                    .iter()
4978                    .map(|span| span.y)
4979                    .fold(f64::INFINITY, f64::min),
4980                ruled: true,
4981                rows,
4982            });
4983        }
4984        i = last_row + 1;
4985    }
4986    regions
4987}
4988
4989/// A span that holds its own leader -- `Characters per line ........ 10` drawn in
4990/// one string -- as the label with its dots and, apart, the figure after them
4991/// (#574). The label's box stops where its words do, so the white the dots
4992/// stand in is a gutter to `table_regions` and `tables::find` alike; the dots
4993/// stay in the label's text, since the reader keeps them. A span with no leader
4994/// of at least `TABLE_REGION_LEADER_DOTS` dots, or with more than a short
4995/// figure after it, comes back as it went in.
4996fn split_at_leader(span: TextSpan) -> Vec<TextSpan> {
4997    let chars: Vec<char> = span.text.chars().collect();
4998    // The last run of at least `TABLE_REGION_LEADER_DOTS` dots, single spaces
4999    // allowed between them: the figure after it may hold a point of its own.
5000    let (mut start, mut end, mut dots) = (0usize, 0usize, 0usize);
5001    let mut k = 0;
5002    while k < chars.len() {
5003        if chars[k] != '.' {
5004            k += 1;
5005            continue;
5006        }
5007        let (from, mut count, mut to) = (k, 0usize, k);
5008        while to < chars.len()
5009            && (chars[to] == '.'
5010                || (chars[to] == ' ' && to + 1 < chars.len() && chars[to + 1] == '.'))
5011        {
5012            if chars[to] == '.' {
5013                count += 1;
5014            }
5015            to += 1;
5016        }
5017        if count >= TABLE_REGION_LEADER_DOTS {
5018            (start, end, dots) = (from, to, count);
5019        }
5020        k = to.max(k + 1);
5021    }
5022    let label: String = chars[..start].iter().collect();
5023    let tail: String = chars[end..].iter().collect::<String>().trim().to_string();
5024    if dots < TABLE_REGION_LEADER_DOTS
5025        || label.trim().is_empty()
5026        || tail.chars().count() > TABLE_REGION_SHORT_CELL
5027    {
5028        return vec![span];
5029    }
5030    let n = chars.len().max(1) as f64;
5031    let exact = span.char_bounds.len() == chars.len();
5032    let at = |i: usize, right: bool| -> f64 {
5033        if exact {
5034            span.char_bounds[i][if right { 2 } else { 0 }]
5035        } else {
5036            span.x + span.width * ((i + usize::from(right)) as f64 / n)
5037        }
5038    };
5039    let label_end = label.trim_end().chars().count().max(1) - 1;
5040    let label_right = at(label_end, true);
5041    let mut out = Vec::with_capacity(2);
5042    let mut head = span.clone();
5043    head.text = chars[..end].iter().collect();
5044    head.width = (label_right - span.x).max(0.0);
5045    head.char_bounds.clear();
5046    head.tight_char_bounds.clear();
5047    head.glyph_advances.clear();
5048    head.glyph_bounds_sources.clear();
5049    out.push(head);
5050    if !tail.is_empty() {
5051        let first = chars[end..]
5052            .iter()
5053            .position(|c| !c.is_whitespace())
5054            .map_or(end, |k| end + k);
5055        let mut figure = span.clone();
5056        figure.text = tail;
5057        figure.x = at(first, false);
5058        figure.width = (span.x + span.width - figure.x).max(0.0);
5059        figure.char_bounds.clear();
5060        figure.tight_char_bounds.clear();
5061        figure.glyph_advances.clear();
5062        figure.glyph_bounds_sources.clear();
5063        out.push(figure);
5064    }
5065    out
5066}
5067
5068/// Each leader span's dots appended to the text of the span before it, and the
5069/// leader span removed; `spans` is in reading order along the band.
5070///
5071/// The dots' glyphs ride along: their boxes are appended to the label's, so a
5072/// word the fold glued keeps one box per glyph. Dropped, a folded dot left its
5073/// word boxless and the word fell back to the label's whole box -- a decimal
5074/// dot read on top of its digit, and no join rule could put `1.1.1` together
5075/// again.
5076///
5077/// Where the dots abut the label on its own line -- a decimal dot, not a
5078/// leader run across white -- they join it as text and box: `2` and `.` read
5079/// `2.`, and the label's box grows to cover them, because the box claims the
5080/// text and a gap measured past a stale edge reads white the ink fills. Dots
5081/// standing off across white keep their space and leave the box alone, so
5082/// the gutter past them still reads as one.
5083///
5084/// The abutting join is on both paths (#655): a decimal dot is a decimal
5085/// dot whatever the option says. What `cell_path_words` still gates is the
5086/// standoff fold: with it the dots' boxes ride along, without it the dots
5087/// join with a space and leave the glyph boxes behind, exactly as before.
5088fn fold_leaders(spans: &mut Vec<TextSpan>, cell_path_words: bool) {
5089    let mut kept: Vec<TextSpan> = Vec::with_capacity(spans.len());
5090    for span in spans.drain(..) {
5091        match kept.last_mut() {
5092            Some(label) if is_leader(&span) => {
5093                // Abutting on the label's own line: no daylight between the
5094                // label's right edge and the dots' left, and their heights
5095                // overlap. Anything past a hair is a leader across white.
5096                const ABUT_EPS: f64 = 1e-6;
5097                let dots_left = span
5098                    .char_bounds
5099                    .iter()
5100                    .map(|b| b[0])
5101                    .fold(f64::INFINITY, f64::min);
5102                let dots_right = span
5103                    .char_bounds
5104                    .iter()
5105                    .map(|b| b[2])
5106                    .fold(f64::NEG_INFINITY, f64::max);
5107                let dots_top = span
5108                    .char_bounds
5109                    .iter()
5110                    .map(|b| b[3])
5111                    .fold(f64::NEG_INFINITY, f64::max);
5112                let dots_bottom = span
5113                    .char_bounds
5114                    .iter()
5115                    .map(|b| b[1])
5116                    .fold(f64::INFINITY, f64::min);
5117                let abuts = dots_left <= label.x + label.width + ABUT_EPS
5118                    && dots_top > label.y
5119                    && dots_bottom < label.y + label.height;
5120                if abuts {
5121                    label.text.push_str(span.text.trim());
5122                    label.char_bounds.extend(span.char_bounds.iter().copied());
5123                    label
5124                        .tight_char_bounds
5125                        .extend(span.tight_char_bounds.iter().copied());
5126                    label
5127                        .glyph_advances
5128                        .extend(span.glyph_advances.iter().copied());
5129                    label
5130                        .glyph_bounds_sources
5131                        .extend(span.glyph_bounds_sources.iter().copied());
5132                    label.width = (dots_right - label.x).max(label.width).max(0.0);
5133                    continue;
5134                }
5135                if !cell_path_words {
5136                    label.text.push(' ');
5137                    label.text.push_str(span.text.trim());
5138                    continue;
5139                }
5140                label.text.push(' ');
5141                label.text.push_str(span.text.trim());
5142                label.char_bounds.extend(span.char_bounds.iter().copied());
5143                label
5144                    .tight_char_bounds
5145                    .extend(span.tight_char_bounds.iter().copied());
5146                label
5147                    .glyph_advances
5148                    .extend(span.glyph_advances.iter().copied());
5149                label
5150                    .glyph_bounds_sources
5151                    .extend(span.glyph_bounds_sources.iter().copied());
5152            }
5153            _ => kept.push(span),
5154        }
5155    }
5156    *spans = kept;
5157}
5158
5159/// Rows joined to their figures by a dot leader: a table of contents, a list of
5160/// tables, a statement set with leaders (#574). The leader is dropped by
5161/// `ink_runs`, so a row is a label and a short figure with white between them
5162/// where the dots were; `leader_rows` counts the dots, since a label and a page
5163/// number with white between them and no leader is a line of prose that ends
5164/// in a number. At least `TABLE_REGION_MIN_ROWS` such rows running, a label
5165/// that wraps onto a line of its own allowed between them.
5166fn leader_regions(
5167    bands: &[TextBand],
5168    runs: &[Vec<InkRun>],
5169    pitch: f64,
5170    taken: &[(f64, f64)],
5171    min_rows: usize,
5172) -> Vec<TableRegion> {
5173    let free = |y: f64| !taken.iter().any(|(bottom, top)| y >= *bottom && y <= *top);
5174    let is_leader_row = |k: usize| {
5175        // The dots, whether each is a span of its own or they run on inside
5176        // the label's string (`split_at_leader` has cut the figure off).
5177        let dots: usize = bands[k]
5178            .spans
5179            .iter()
5180            .map(|span| {
5181                if is_leader(span) {
5182                    span.text.chars().filter(|c| !c.is_whitespace()).count()
5183                } else {
5184                    span.text
5185                        .trim_end()
5186                        .chars()
5187                        .rev()
5188                        .take_while(|c| *c == '.' || *c == ' ')
5189                        .filter(|c| *c == '.')
5190                        .count()
5191                }
5192            })
5193            .sum();
5194        dots >= TABLE_REGION_LEADER_DOTS
5195            && runs[k].len() >= 2
5196            && runs[k]
5197                .last()
5198                .is_some_and(|run| run.chars <= TABLE_REGION_SHORT_CELL)
5199    };
5200    let mut regions = Vec::new();
5201    let mut i = 0;
5202    while i < bands.len() {
5203        if !is_leader_row(i) || !free(bands[i].y) {
5204            i += 1;
5205            continue;
5206        }
5207        let (mut last_row, mut rows, mut loose) = (i, 1, 0);
5208        let mut above = bands[i].y;
5209        let mut j = i + 1;
5210        while j < bands.len() {
5211            if runs[j].is_empty() {
5212                j += 1;
5213                continue;
5214            }
5215            if above - bands[j].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch || !free(bands[j].y) {
5216                break;
5217            }
5218            above = bands[j].y;
5219            if is_leader_row(j) {
5220                last_row = j;
5221                rows += 1;
5222                loose = 0;
5223            } else if runs[j].len() == 1 {
5224                loose += 1;
5225                if loose > 2 {
5226                    break;
5227                }
5228            } else {
5229                break;
5230            }
5231            j += 1;
5232        }
5233        if rows >= min_rows {
5234            let chosen = &bands[i..=last_row];
5235            regions.push(TableRegion {
5236                left: chosen
5237                    .iter()
5238                    .map(TextBand::left)
5239                    .fold(f64::INFINITY, f64::min),
5240                right: chosen
5241                    .iter()
5242                    .map(TextBand::right)
5243                    .fold(f64::NEG_INFINITY, f64::max),
5244                top: bands[i]
5245                    .spans
5246                    .iter()
5247                    .map(|span| span.y)
5248                    .fold(f64::NEG_INFINITY, f64::max),
5249                bottom: bands[last_row]
5250                    .spans
5251                    .iter()
5252                    .map(|span| span.y)
5253                    .fold(f64::INFINITY, f64::min),
5254                ruled: true,
5255                rows,
5256            });
5257        }
5258        i = last_row + 1;
5259    }
5260    regions
5261}
5262
5263/// Whether a short run is a list's marker rather than a key: `1.`, `a)`, `(iv)`.
5264fn is_list_marker(text: &str) -> bool {
5265    let text = text.trim();
5266    text.chars().count() <= 5
5267        && (text.ends_with('.') || text.ends_with(')') || text.starts_with('('))
5268}
5269
5270/// A table region one of the page's detectors proposed (#574): what a judge
5271/// chooses between. `hull` is `[left, bottom, right, top]` in page space, the
5272/// extent of the rows' ink, y up.
5273#[doc(hidden)]
5274#[derive(Debug, Clone, PartialEq)]
5275pub struct TableProposal {
5276    /// `aligned`, `booktabs`, `pair` or `leader`.
5277    pub detector: &'static str,
5278    /// Whether the detector would keep it: `false` for a stack of two rows,
5279    /// which is a proposal for the judge and not a table for the page.
5280    pub accepted: bool,
5281    /// The region's extent.
5282    pub hull: [f64; 4],
5283}
5284
5285/// Every table region the detectors accept on a page, before any gives way to
5286/// another, from the same spans and rules the page's blocks are built from.
5287///
5288/// A PAGE THAT IS ITSELF TURNED proposes nothing here: the regions are found in
5289/// the frame the page was written in, and until a proposal can be turned back
5290/// with it exactly, an empty list is the honest answer. Hidden: this is the
5291/// feature dump's and the table judge's, not a promise.
5292#[doc(hidden)]
5293pub fn table_proposals(spans: &[TextSpan], rules: &[HorizontalRule]) -> Vec<TableProposal> {
5294    if spans.is_empty() || PageTurn::of(spans) != PageTurn::None {
5295        return Vec::new();
5296    }
5297    let stats = PageStats::from_spans(spans);
5298    let spans: Vec<TextSpan> = spans.iter().cloned().flat_map(split_at_leader).collect();
5299    let mut seen = Vec::new();
5300    table_regions_seen(&spans, &stats, rules, Some(&mut seen));
5301    // And each column of a page set in columns, as a page of its own: a table
5302    // in one column shares its bands with the other column's prose, which no
5303    // detector reads as rows on the whole page (see `xy_cut_round_tables`).
5304    if let Some((columns, _)) = try_vertical_cut(&spans, &stats, rules) {
5305        for column in &columns {
5306            let mut more = Vec::new();
5307            table_regions_seen(column, &stats, rules, Some(&mut more));
5308            for found in more {
5309                if !seen.contains(&found) {
5310                    seen.push(found);
5311                }
5312            }
5313        }
5314    }
5315    seen.into_iter()
5316        .map(|(r, detector)| {
5317            let top = r.top
5318                + spans
5319                    .iter()
5320                    .filter(|s| r.holds(s))
5321                    .map(|s| s.height)
5322                    .fold(0.0, f64::max);
5323            TableProposal {
5324                detector,
5325                accepted: r.rows >= TABLE_REGION_MIN_ROWS,
5326                hull: [r.left, r.bottom, r.right, top],
5327            }
5328        })
5329        .collect()
5330}
5331
5332/// The page cut round its tables (#574): each table region is one leaf, the
5333/// rest goes through the XY-cut, and each table is put back in front of the
5334/// first region below it that it shares any width with. Each region comes back
5335/// with whether it is one of those tables, which `blocks::paragraphs` reads as
5336/// "one grid if it makes one" (`tables::find_whole`).
5337fn xy_cut_round_tables(
5338    spans: Vec<TextSpan>,
5339    stats: &PageStats,
5340    rules: &[HorizontalRule],
5341    merge_turned_runs: bool,
5342    cell_path_words: bool,
5343) -> Vec<(Vec<TextBlock>, bool)> {
5344    let tables = table_regions(&spans, stats, rules);
5345    // Each quarter-turned run the layout would break as one span for the
5346    // layout below -- the cut, the bands, the grid -- while the detectors
5347    // above read the spans as drawn (#574). A marginal run merged first
5348    // stops being the bands, steps and short marks the detectors count: on
5349    // one proxy page thirteen one-glyph bands veto an aligned region that
5350    // blocks a ruled one, and merged to one span the veto count drops under
5351    // the threshold, the page comes back one ruled region, and two columns
5352    // of prose are gridded as a table.
5353    //
5354    // On an upright page only: a turned page is already in the frame it was
5355    // written in here, where its own runs read upright and its MINORITY --
5356    // the upright furniture of a landscape table -- reads turned, and merging
5357    // that minority re-decides the table around it (#574: a landscape segment
5358    // report comes back more duplicated and fewer of its words match).
5359    let spans = if merge_turned_runs {
5360        merge_rotated_runs(spans)
5361    } else {
5362        spans
5363    };
5364    if tables.is_empty() {
5365        return xy_cut_recursive(spans, 0, stats, rules)
5366            .into_iter()
5367            .map(|region| (region, false))
5368            .collect();
5369    }
5370    // A PAGE SET IN COLUMNS IS LOOKED AT ONE COLUMN AT A TIME. Across the
5371    // gutter a line of prose and a row of a table share a band, and the prose
5372    // reads as the row's stub: the table's region took the column beside it
5373    // with it. Where the cut would divide the page into columns anyway, each
5374    // column is its own page for this.
5375    // Unless the cut would divide a table found by more than its alignment --
5376    // ruled above and below, keys and what they stand for, a leader: the cut
5377    // would take the labels from what they label.
5378    let ruled_across = |columns: &[Vec<TextSpan>]| {
5379        tables.iter().filter(|table| table.ruled).any(|table| {
5380            columns
5381                .iter()
5382                .filter(|column| column.iter().any(|span| table.holds(span)))
5383                .count()
5384                > 1
5385        })
5386    };
5387    if let Some((columns, _)) =
5388        try_vertical_cut(&spans, stats, rules).filter(|(columns, _)| !ruled_across(columns))
5389    {
5390        return columns
5391            .into_iter()
5392            .flat_map(|column| {
5393                xy_cut_round_tables(column, stats, rules, merge_turned_runs, cell_path_words)
5394            })
5395            .collect();
5396    }
5397    let mut held: Vec<Vec<TextSpan>> = vec![Vec::new(); tables.len()];
5398    let mut rest = Vec::new();
5399    for span in spans {
5400        match tables.iter().position(|table| table.holds(&span)) {
5401            Some(at) => held[at].push(span),
5402            None => rest.push(span),
5403        }
5404    }
5405    let mut regions: Vec<(Vec<TextBlock>, bool)> = xy_cut_recursive(rest, 0, stats, rules)
5406        .into_iter()
5407        .map(|region| (region, false))
5408        .collect();
5409    for (table, spans) in tables.iter().zip(held) {
5410        if spans.is_empty() {
5411            continue;
5412        }
5413        let at = regions
5414            .iter()
5415            .position(|(region, _)| {
5416                let spans = region.iter().flat_map(|line| line.spans.iter());
5417                let (mut top, mut left, mut right) =
5418                    (f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
5419                for span in spans {
5420                    top = top.max(span.y);
5421                    left = left.min(span.x);
5422                    right = right.max(span.right());
5423                }
5424                top < table.top && left < table.right && right > table.left
5425            })
5426            .unwrap_or(regions.len());
5427        // One line per row, every cell of the row in it and a wrapped cell's
5428        // lines kept with it (`push_row`): what `tables::find` reads a row as.
5429        // The leaf grouping the cut uses would look for columns of prose in it
5430        // and find the table's.
5431        // A leader's dots go into the text of the label they run out from and
5432        // not into its box: the reader keeps them, and `tables::find` sees the
5433        // gutter between the label and its figure that the dots filled.
5434        let mut bands = group_spans_into_bands_with_stats(spans, stats);
5435        for band in &mut bands {
5436            fold_leaders(&mut band.spans, cell_path_words);
5437        }
5438        let mut rows = Vec::with_capacity(bands.len());
5439        let mut k = 0;
5440        while k < bands.len() {
5441            k = push_row(&bands, k, &mut rows);
5442        }
5443        regions.insert(at, (rows, true));
5444    }
5445    regions
5446}
5447
5448/// The gap, in ems of the page's body size, that separates two cells of a
5449/// table row when its region is looked for on the whole page (#574).
5450const TABLE_REGION_GAP_EM: f64 = 2.0;
5451/// The narrowest a strip of white between two columns may become, in points,
5452/// before a row stops narrowing it.
5453const TABLE_REGION_MIN_STRIP: f64 = 2.0;
5454/// How far, in ems, a row's gap may miss a strip and still be in it.
5455const TABLE_REGION_EDGE_EM: f64 = 1.5;
5456/// A cell this short (non-blank characters) is a value, a year or a unit.
5457const TABLE_REGION_SHORT_CELL: usize = 16;
5458/// No cell but the stub may be longer than this: a run of prose beside a table
5459/// is a column of the page, not a cell of the table.
5460const TABLE_REGION_LONG_CELL: usize = 40;
5461/// The fewest rows that make a table region.
5462const TABLE_REGION_MIN_ROWS: usize = 3;
5463/// How many bands in a row may be labels or sparse rows inside a table.
5464const TABLE_REGION_MAX_LOOSE: usize = 5;
5465/// How many head lines over the value columns a table may carry above its
5466/// first row.
5467const TABLE_REGION_MAX_HEADS: usize = 2;
5468/// The most characters a run outside a ruled table's extent may hold and still
5469/// be a mark in the margin rather than text beside the table.
5470const TABLE_REGION_MARGIN_MARK: usize = 2;
5471/// How far apart, in points, two rule segments at one height may be and still
5472/// be one rule.
5473const RULE_JOIN_TOLERANCE: f64 = 2.0;
5474/// How long a rule must be, in ems, to open or close a table.
5475const TABLE_REGION_RULE_MIN_EM: f64 = 10.0;
5476/// The gap, in ems, that separates a key from its description.
5477const TABLE_REGION_PAIR_GAP_EM: f64 = 1.0;
5478/// How many leader dots make a row a leader row.
5479const TABLE_REGION_LEADER_DOTS: usize = 4;
5480/// How much white between two rows of a two-column table, in line pitches: less
5481/// than a statement's, since such a table has no groups to set apart.
5482const TABLE_REGION_PAIR_STEP_PITCHES: f64 = 2.5;
5483/// How much white between two bands, in line pitches, still lies inside one
5484/// table: the space between a statement's groups.
5485const TABLE_REGION_MAX_STEP_PITCHES: f64 = 4.0;
5486
5487/// The leading the body of THIS SET OF SPANS is set on: the commonest step
5488/// between its bands (#580).
5489///
5490/// PER REGION AND NOT PER PAGE, which is the whole of what makes this safe. A
5491/// page-wide leading is the leading of whatever the page has most of, and a
5492/// document that mixes dense form boxes with columns of prose has two: on
5493/// `pdfjs__test_pdfs_issue6127.pdf` the prose sets it to 12 points while the
5494/// form's own zone gaps are under that, so a page-wide floor suppressed a cut
5495/// the form needed and glued a heading to the marginal notes beside it
5496/// (`order_agreement` 97.8 -> 81.1 over the gate set). The region being cut
5497/// answers about itself.
5498///
5499/// Bounded on both sides, and both bounds earn their place:
5500///   * at least 0.8 of the line height, because two bands closer than that are
5501///     one line's sub- and superscripts, a split run or a rule -- the very
5502///     steps that drag `median_line_spacing` down. That bound is what tells
5503///     6.0pt of table pitch from 12.0pt of leading on the pages this fixes. It
5504///     is on the HEIGHT and not on the em, because a leading can sit below the
5505///     em -- a 10-on-12 setting does, and at 0.9 this refused
5506///     `xy_cut_leaf_falls_back_to_legacy_columns_for_header_plus_three_columns`.
5507///   * at most three ems, because a wider step is a paragraph break, a zone
5508///     break or the white above a heading. Without the ceiling a region that is
5509///     mostly table reports its one big gap as the leading, 57.6pt on an 8pt
5510///     page.
5511///
5512/// Zero when the region has no step that could be a leading at all, which says
5513/// "do not floor anything" rather than "the leading is nothing".
5514fn leading_of_a_region(spans: &[TextSpan], stats: &PageStats) -> f64 {
5515    let tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
5516    let mut ordered: Vec<&TextSpan> = spans.iter().collect();
5517    ordered.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
5518    // Each band keeps the leftmost ink on it, because whether two bands are
5519    // lines of one paragraph is a question about their left edge.
5520    let mut bands: Vec<(f64, f64)> = Vec::new();
5521    for span in ordered {
5522        match bands.last_mut() {
5523            Some(band) if (band.0 - span.y).abs() <= tolerance => {
5524                band.1 = band.1.min(span.x);
5525            }
5526            _ => bands.push((span.y, span.x)),
5527        }
5528    }
5529    let aligned = LEADING_ALIGNED_EM * stats.median_font_size;
5530    let mut steps: Vec<f64> = bands
5531        .windows(2)
5532        // CONTINUOUS TEXT, and not merely a step of the right size (codex, #580
5533        // round two). Two bands are lines of one paragraph when they start at the
5534        // same left edge; a masthead over a title over a byline is three bands
5535        // that may be equally spaced by coincidence but are not flush with each
5536        // other. Without this, three equidistant one-line zones read as a leading
5537        // and the floor suppresses both of the region's cuts -- and a rule
5538        // counting occurrences alone cannot tell the two apart, because two equal
5539        // gaps is every gap such a region has.
5540        .filter(|pair| (pair[0].1 - pair[1].1).abs() <= aligned)
5541        .map(|pair| (pair[0].0 - pair[1].0).abs())
5542        .filter(|step| {
5543            *step >= LEADING_MIN_EM * stats.median_font_size
5544                && *step <= LEADING_MAX_EM * stats.median_font_size
5545        })
5546        .map(|step| (step * 10.0).round() / 10.0)
5547        .collect();
5548    steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
5549    let mut best = (0usize, 0.0f64);
5550    let mut i = 0;
5551    while i < steps.len() {
5552        let mut j = i;
5553        while j < steps.len() && steps[j] == steps[i] {
5554            j += 1;
5555        }
5556        if j - i > best.0 {
5557            best = (j - i, steps[i]);
5558        }
5559        i = j;
5560    }
5561    // A LEADING IS A REPETITION THE REGION DOES NOT EXHAUST (codex, #580, both
5562    // rounds). One step is not a repetition: a region of exactly two bands has a
5563    // single gap, and that gap would win this count with a tally of one, so a
5564    // header over a body would be read as the leading of a body it is not part
5565    // of. The floor would then refuse the only horizontal cut the region has,
5566    // leave the full-width band in place, and through that block the column split
5567    // underneath it -- the interleave this change exists to avoid, reintroduced
5568    // by the change itself.
5569    //
5570    // AND A COUNT THAT EVERY GAP IN THE REGION SATISFIES IS NOT EVIDENCE EITHER,
5571    // which is the second round's point: three bands set equally apart have two
5572    // equal gaps whatever they are, so raising this count to three only moves the
5573    // same hole from two bands to three. What answers it is the filter above --
5574    // the gaps have to be between bands that are FLUSH with each other, which is
5575    // what tells three lines of a paragraph from a masthead over a centred title
5576    // over an indented byline. The count then only has to say "more than one".
5577    //
5578    // Raising the count to three was measured too and rejected: it changed ten of
5579    // the seventy-five pages this machine may hold and left four of the
5580    // twenty-one the floor had fixed unfixed, for a hole the alignment filter
5581    // closes without giving anything up.
5582    if best.0 < LEADING_MIN_OCCURRENCES {
5583        return 0.0;
5584    }
5585    best.1
5586}
5587
5588/// How many gaps of one size, between bands flush with each other, a region must
5589/// show before that size counts as its leading (#580). Two: three lines set
5590/// together on one leading is the least a body can be and still have one, and
5591/// what keeps a small region from inventing a leading out of its own two gaps is
5592/// that those gaps have to be between left-aligned bands, not that there are
5593/// more of them.
5594const LEADING_MIN_OCCURRENCES: usize = 2;
5595
5596/// The narrowest step that can be a line of text rather than one line's own
5597/// parts, as a share of the line height (#580).
5598const LEADING_MIN_EM: f64 = 0.8;
5599/// The widest step that can still be a leading rather than a break (#580).
5600const LEADING_MAX_EM: f64 = 3.0;
5601/// How far two bands' left edges may differ and still be lines of one paragraph
5602/// (#580). Half an em: flush enough to be set together, loose enough that the
5603/// measured left edge of a line opening with a narrow glyph still counts.
5604const LEADING_ALIGNED_EM: f64 = 0.5;
5605
5606/// How far above the body's leading the paragraph-break threshold has to stay
5607/// (#580). Just above, and deliberately so: the threshold also has to stay
5608/// BELOW the page's paragraph breaks or the zone cuts stop firing, and a page
5609/// whose paragraph break is only a fifth wider than its leading exists.
5610const HORIZONTAL_CUT_LEADING_FLOOR: f64 = 1.05;
5611/// Minimum number of spans a column must contain before it is eligible
5612/// for acceptance — one-span "columns" are almost always sidebar noise
5613/// or table-cell fragments.
5614/// The white, in ems of its own size, after a single glyph that sets it apart
5615/// as a marker hung into a gutter rather than a word of the line (#574).
5616const HUNG_MARKER_GAP_EM: f64 = 0.6;
5617/// The letters, not counting figures or signs, the median line of each side
5618/// of a gutter found past hung markers must hold to be a column of text.
5619const RUNNING_LETTERS: usize = 30;
5620const XY_CUT_MIN_SPANS_PER_COLUMN: usize = 2;
5621/// Average characters per band a column must have before it's accepted
5622/// as dense prose (vs. a short-cell table column).
5623const XY_CUT_MIN_CHARS_PER_BAND: f64 = 8.0;
5624
5625/// The page's spans as blocks, in reading order, given the horizontal rules the
5626/// page drew (#545): a region the document itself rules into rows is a table,
5627/// and a table is not read column by column.
5628///
5629/// ANN[r17/TEX2][r17/TEX4] Top-level grouping uses recursive XY-Cut
5630/// with a density guard. Per-page stats are computed once up front so
5631/// every decision downstream speaks the same typographic baseline.
5632fn group_spans_into_blocks(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<TextBlock> {
5633    if spans.is_empty() {
5634        return Vec::new();
5635    }
5636    // A page that is itself turned is read in the frame it was written in, and
5637    // an upright page is turned by nothing at all (#546).
5638    let turn = PageTurn::of(&spans);
5639    let spans = turn.into_frame(spans);
5640    let stats = PageStats::from_spans(&spans);
5641    let rules = turn.rules_in_frame(rules);
5642    // Each quarter-turned run as one span, so the cut cannot part it at a
5643    // word gap (#574). After the stats, which describe the spans as drawn. On
5644    // an upright page only: in the frame of a turned page the minority reads
5645    // turned, and it is not this merge's to touch.
5646    let spans = if turn == PageTurn::None {
5647        merge_rotated_runs(spans)
5648    } else {
5649        spans
5650    };
5651    let blocks = xy_cut_recursive(spans, 0, &stats, &rules)
5652        .into_iter()
5653        .flatten()
5654        .collect();
5655    let blocks = reassemble_cross_direction_runs(blocks);
5656    turn.back(blocks)
5657}
5658
5659/// The page's spans as paragraphs, in reading order (#567).
5660///
5661/// The same layout as [`group_spans_into_blocks`], up to the point where it
5662/// hands back lines: the page is turned into the frame it was written in, cut,
5663/// and its cross-direction runs reassembled. What is added is the merge, and it
5664/// runs in that same frame -- "the next line down" and "the left margin" are
5665/// statements about the frame the type was set in, not about page space. The
5666/// spans a caller receives are in page space, exactly as `into_blocks` leaves
5667/// them.
5668/// The reading direction of a run, quantized to quarter turns (#643): 0 is
5669/// upright or unknown, then 90, 180 and 270 degrees counter-clockwise. Only
5670/// equality matters: runs that read in different directions never share a
5671/// raw block.
5672fn raw_direction(span: &TextSpan) -> u8 {
5673    let Some(t) = span.transform else {
5674        return 0;
5675    };
5676    let (a, b) = (t[0], t[1]);
5677    if a.abs() >= b.abs() {
5678        if a >= 0.0 {
5679            0
5680        } else {
5681            2
5682        }
5683    } else if b >= 0.0 {
5684        1
5685    } else {
5686        3
5687    }
5688}
5689
5690/// Whether the face is one of the fourteen the viewer always has, by the
5691/// name the spans carry (#643).
5692fn raw_is_base14(font_name: Option<&str>) -> bool {
5693    font_name.is_some_and(|name| {
5694        name.starts_with("Helvetica")
5695            || name.starts_with("Times")
5696            || name.starts_with("Courier")
5697            || name.starts_with("Symbol")
5698            || name.starts_with("ZapfDingbats")
5699    })
5700}
5701
5702/// A run's vertical metrics in ems, for the tall boxes the raw grouping
5703/// overlaps (#643): the recorded base-14 pair when the face is one, else the
5704/// embedded font's ascent and descent, else Helvetica's recorded pair for a
5705/// base-14 face with no binary, else the engine's embedded fallback. A face
5706/// the metrics lookup missed is an embedded face, not base-14: the caption
5707/// over the form's title gaps it by 3pt in embedded metrics and would drown
5708/// in Helvetica's.
5709fn raw_metrics(span: &TextSpan) -> (f64, f64) {
5710    match span.font_name.as_deref() {
5711        Some("Helvetica") => (f64::from(1.075f32), f64::from(-0.299f32)),
5712        Some("Helvetica-Bold") => (1.07, -0.307),
5713        _ => span
5714            .font_metrics
5715            .map(|m| (m.ascent / 1000.0, m.descent / 1000.0))
5716            .unwrap_or_else(|| {
5717                if raw_is_base14(span.font_name.as_deref()) {
5718                    (1.075, -0.299)
5719                } else {
5720                    (0.718, -0.207)
5721                }
5722            }),
5723    }
5724}
5725
5726/// A run's tall box: the advance width with the ascender above the baseline
5727/// and the descender below (#643). Bullet rows set 12pt apart at 10pt type
5728/// overlap in these boxes and join one block; in advance boxes they would
5729/// stand 2pt apart and part.
5730fn raw_tall_box(span: &TextSpan) -> [f64; 4] {
5731    let (ascender, descender) = raw_metrics(span);
5732    [
5733        span.x,
5734        span.y + descender * span.font_size,
5735        span.x + span.width,
5736        span.y + ascender * span.font_size,
5737    ]
5738}
5739
5740/// How far apart two runs may stand along the line and still share it, as a
5741/// multiple of the larger type size (#643). The recordings join the 2.65pt
5742/// gap at 7pt type (0.38em, `Deceased` to `MM`) and part the 6.5pt gap at 8pt
5743/// type (0.81em, the dot leaders) and the 10.5pt gap at 10pt type (1.05em,
5744/// the bullets from their text).
5745const RAW_LINE_GAP_EM: f64 = 0.6;
5746
5747/// How far back past the line's end a run may start and still join it, as a
5748/// multiple of the larger type size (#643). The `1a` line number reads back
5749/// 2.2pt at 8pt type (0.28em) into its own line, while a new row reads back
5750/// whole ems (`/` 46pt at 7pt type past `YYYY`). Shares the forward gap's
5751/// threshold; the back side is untested between 0.28em and 6.6em.
5752const RAW_LINE_BACK_EM: f64 = 0.6;
5753
5754/// Cut a block's runs into lines: the x-runs (#643).
5755///
5756/// A run joins the open line while it reads in the line's direction, its
5757/// tall box overlaps the run drawn just before it, and it starts within
5758/// sixtenths of an em of the line's end on either side. Anything else --
5759/// the bullets past their 10.5pt gap, `25` back left of `U.S.`, the caption
5760/// under the title -- opens the next line. Returns the line each run lands
5761/// on, in run order.
5762fn raw_line_of(spans: &[TextSpan]) -> Vec<usize> {
5763    let mut lines = Vec::with_capacity(spans.len());
5764    let mut line = 0;
5765    let mut end = f64::NEG_INFINITY;
5766    let mut low = f64::NEG_INFINITY;
5767    let mut high = f64::NEG_INFINITY;
5768    let mut direction = 0;
5769    let mut size = 0.0f64;
5770    for span in spans {
5771        let dir = raw_direction(span);
5772        let tall = raw_tall_box(span);
5773        let overlap = tall[1] <= high && tall[3] >= low;
5774        let gap = span.x - end;
5775        let em = size.max(span.font_size);
5776        if !lines.is_empty()
5777            && (dir != direction
5778                || !overlap
5779                || span.x < end - RAW_LINE_BACK_EM * em
5780                || gap > RAW_LINE_GAP_EM * em)
5781        {
5782            line += 1;
5783        }
5784        direction = dir;
5785        end = span.x + span.width;
5786        low = tall[1];
5787        high = tall[3];
5788        size = span.font_size;
5789        lines.push(line);
5790    }
5791    lines
5792}
5793
5794/// Rebuild a raw block's text and box from its spans (#643).
5795///
5796/// Lines join with a newline, runs within a line concatenate as drawn -- the
5797/// spaces are inside the runs, and the fillers between them arrive with E2c.
5798/// Shared with the hidden-text drop, so a raw block that loses spans keeps
5799/// raw text.
5800pub(crate) fn raw_text_and_box(spans: &[TextSpan]) -> (String, [f64; 4]) {
5801    let mut text = String::new();
5802    let lines = raw_line_of(spans);
5803    for (i, span) in spans.iter().enumerate() {
5804        if i > 0 && lines[i] != lines[i - 1] {
5805            text.push('\n');
5806        }
5807        text.push_str(&span.text);
5808    }
5809    let mut bbox = [
5810        f64::INFINITY,
5811        f64::INFINITY,
5812        f64::NEG_INFINITY,
5813        f64::NEG_INFINITY,
5814    ];
5815    for span in spans {
5816        bbox[0] = bbox[0].min(span.x);
5817        bbox[1] = bbox[1].min(span.y);
5818        bbox[2] = bbox[2].max(span.x + span.width);
5819        bbox[3] = bbox[3].max(span.y + span.height);
5820    }
5821    (text, bbox)
5822}
5823
5824/// Seal the runs drawn so far into one raw paragraph block.
5825fn push_raw_block(blocks: &mut Vec<Block>, spans: Vec<TextSpan>, page: usize, index: usize) {
5826    if spans.is_empty() {
5827        return;
5828    }
5829    let (text, bbox) = raw_text_and_box(&spans);
5830    let mut block = Block::new(BlockKind::Paragraph, page, bbox, text, index);
5831    block.spans = spans;
5832    blocks.push(block);
5833}
5834
5835/// How much of the previous line's tall reach an overlapping row must cover
5836/// to join its block when it reads forward (#643). The recordings join from
5837/// 0.69 up (`Digital Assets` to its row) and part at 0.375 (the staggered
5838/// checkbox rows) and 0.10 (into `2a`).
5839const RAW_FWD_OVERLAP_FRAC: f64 = 0.5;
5840
5841/// How far a gapped row may stand past the previous line and still read on,
5842/// in ems of the larger type size (#643). Past a gap the recordings join
5843/// to 0.125 and part from 0.186; the size-up joins reach 0.15 through the
5844/// size rule below, not through this gap.
5845const RAW_BLOCK_GAP_EM: f64 = 0.15;
5846
5847/// How wide a gapped line may be and still read on, in points (#643). The
5848/// recordings join to 128pt at the same size and part from 178pt (the
5849/// `Married filing jointly` row) and 348pt; the two wider joins (199pt,
5850/// 224pt) read on as larger type through the size rule below.
5851const RAW_BLOCK_WIDTH_PT: f64 = 150.0;
5852
5853/// How much larger a gapped row may be and always read on, as a multiple of
5854/// the previous line's size (#643). The recordings join size-ups from 1.25
5855/// (section heads after wrapped lines; the two wider ones only through this
5856/// rule). Same-size rows part through the gap and width rules; nothing
5857/// between 1.0 and 1.25 tests the threshold from below.
5858const RAW_SIZE_UP_RATIO: f64 = 1.2;
5859
5860/// One cut line with the features the block rule reads (#643).
5861struct RawLine {
5862    spans: Vec<TextSpan>,
5863    bdc_first: Option<u32>,
5864    bdc_last: Option<u32>,
5865    x0: f64,
5866    x1: f64,
5867    low: f64,
5868    high: f64,
5869    direction: u8,
5870    size: f64,
5871}
5872
5873impl RawLine {
5874    fn width(&self) -> f64 {
5875        self.x1 - self.x0
5876    }
5877
5878    fn height(&self) -> f64 {
5879        self.high - self.low
5880    }
5881}
5882
5883/// Whether the next line opens a new block after the previous one (#643).
5884///
5885/// One marked-content segment never parts; a turn of the reading direction
5886/// always does. Between rows that overlap, a row that reads back joins, and
5887/// a row that reads forward joins while it covers half the previous reach.
5888/// Past a gap, a row that reads forward parts, and a row that reads back
5889/// joins when it opens larger type or reads on: same size or larger, a
5890/// short line, a small gap.
5891fn raw_block_split(before: &RawLine, after: &RawLine) -> bool {
5892    if after.direction != before.direction {
5893        return true;
5894    }
5895    if let (Some(a), Some(b)) = (before.bdc_last, after.bdc_first) {
5896        if a == b {
5897            return false;
5898        }
5899    }
5900    let overlap = before.high.min(after.high) - before.low.max(after.low);
5901    let motion = after.x0 - before.x1;
5902    if overlap > 0.0 {
5903        if motion <= 0.5 {
5904            return false;
5905        }
5906        return overlap / before.height() < RAW_FWD_OVERLAP_FRAC;
5907    }
5908    if motion > 0.5 {
5909        return true;
5910    }
5911    let ratio = after.size / before.size;
5912    if ratio >= RAW_SIZE_UP_RATIO {
5913        return false;
5914    }
5915    let gap_em = -overlap / before.size.max(after.size);
5916    !(ratio >= 1.0 && gap_em <= RAW_BLOCK_GAP_EM && before.width() <= RAW_BLOCK_WIDTH_PT)
5917}
5918
5919/// Group the page's runs into blocks in draw order (#643).
5920///
5921/// Lines cut first, over the whole page, and blocks group whole lines: a
5922/// line never spans a block boundary. The frames turn with the page exactly
5923/// as the default path's do; `bdc` carries each run's marked-content
5924/// segment, in run order.
5925fn raw_blocks(
5926    spans: Vec<TextSpan>,
5927    bdc: &[Option<u32>],
5928    page: usize,
5929    first_index: usize,
5930) -> Vec<Block> {
5931    if spans.is_empty() {
5932        return Vec::new();
5933    }
5934    debug_assert_eq!(bdc.len(), spans.len());
5935    let turn = PageTurn::of(&spans);
5936    let spans = turn.into_frame(spans);
5937    let line_of = raw_line_of(&spans);
5938    let mut lines: Vec<RawLine> = Vec::new();
5939    for (i, span) in spans.into_iter().enumerate() {
5940        let tall = raw_tall_box(&span);
5941        let dir = raw_direction(&span);
5942        let fresh = lines.is_empty() || line_of[i] != line_of[i - 1];
5943        if fresh {
5944            lines.push(RawLine {
5945                spans: Vec::new(),
5946                bdc_first: bdc[i],
5947                bdc_last: bdc[i],
5948                x0: span.x,
5949                x1: span.x + span.width,
5950                low: tall[1],
5951                high: tall[3],
5952                direction: dir,
5953                size: span.font_size,
5954            });
5955        }
5956        if let Some(line) = lines.last_mut() {
5957            line.bdc_last = bdc[i];
5958            line.x0 = line.x0.min(span.x);
5959            line.x1 = line.x1.max(span.x + span.width);
5960            line.low = line.low.min(tall[1]);
5961            line.high = line.high.max(tall[3]);
5962            line.spans.push(span);
5963        }
5964    }
5965    let mut blocks = Vec::new();
5966    let mut current: Vec<TextSpan> = Vec::new();
5967    for i in 0..lines.len() {
5968        let (head, tail) = lines.split_at_mut(i);
5969        let line = &mut tail[0];
5970        let cut = i > 0 && raw_block_split(&head[i - 1], line);
5971        if cut {
5972            let index = first_index + blocks.len();
5973            push_raw_block(&mut blocks, std::mem::take(&mut current), page, index);
5974        }
5975        current.append(&mut line.spans);
5976    }
5977    if !current.is_empty() {
5978        let index = first_index + blocks.len();
5979        push_raw_block(&mut blocks, current, page, index);
5980    }
5981    turn.back_blocks(blocks)
5982}
5983
5984fn paragraph_blocks(
5985    spans: Vec<TextSpan>,
5986    rules: &[HorizontalRule],
5987    page: usize,
5988    first_index: usize,
5989    table_strategy: TableStrategy,
5990) -> Vec<Block> {
5991    paragraph_blocks_with_cell_path_words(spans, rules, page, first_index, table_strategy, false)
5992}
5993
5994/// [`paragraph_blocks`] with the cell-path word rules where `cell_path_words`
5995/// is true: the leader fold joins abutting dots, and line and cell texts read
5996/// the quarter-em boundary as a space.
5997fn paragraph_blocks_with_cell_path_words(
5998    spans: Vec<TextSpan>,
5999    rules: &[HorizontalRule],
6000    page: usize,
6001    first_index: usize,
6002    table_strategy: TableStrategy,
6003    cell_path_words: bool,
6004) -> Vec<Block> {
6005    if spans.is_empty() {
6006        return Vec::new();
6007    }
6008    let turn = PageTurn::of(&spans);
6009    let spans = turn.into_frame(spans);
6010    let stats = PageStats::from_spans(&spans);
6011    let rules = turn.rules_in_frame(rules);
6012    // Once, before anything is cut: a leader drawn inside its label's string
6013    // is where the label ends and the figure begins.
6014    let spans: Vec<TextSpan> = spans.into_iter().flat_map(split_at_leader).collect();
6015    let (regions, whole): (Vec<Vec<TextBlock>>, Vec<bool>) = xy_cut_round_tables(
6016        spans,
6017        &stats,
6018        &rules,
6019        turn == PageTurn::None,
6020        cell_path_words,
6021    )
6022    .into_iter()
6023    .unzip();
6024    let direction = page_direction(&regions.iter().flatten().cloned().collect::<Vec<_>>());
6025    let regions: Vec<Vec<TextBlock>> = regions
6026        .into_iter()
6027        .map(|region| match direction {
6028            Some(direction) => reassemble_with_direction(direction, region),
6029            None => region,
6030        })
6031        .collect();
6032    let blocks = crate::blocks::paragraphs_with_cell_path_words(
6033        page,
6034        first_index,
6035        &regions,
6036        &whole,
6037        &rules,
6038        stats.median_line_spacing,
6039        table_strategy,
6040        cell_path_words,
6041    );
6042    // After the merge and before the heading classifier, so the joined block
6043    // is classified like any other (#574).
6044    let blocks = join_marker_continuations(blocks, first_index);
6045    turn.back_blocks(blocks)
6046}
6047
6048/// Join the lines of a double-spaced paragraph back into one block (#574).
6049///
6050/// A passage set on wider leading than the rest of the page clears the
6051/// paragraph-break threshold at every line, so each of its lines arrives here
6052/// as a block of its own: the double-spaced abstract of an arXiv preprint, a
6053/// double-spaced patent description. This pass joins runs of such one-line
6054/// paragraph blocks, guarded so lists, addresses, labels and contents lines
6055/// never join. The rule is a port of the simulated one (`dsim.py`); the guards
6056/// are drawn from the false merges the simulation found.
6057///
6058/// AFTER the heading classifier, and only over [`BlockKind::Paragraph`]:
6059/// stacked headings arrive on the same wide pitch as a double-spaced run, and
6060/// joining before they are marked turns two headings into one paragraph
6061/// (#636).
6062pub(crate) fn join_double_spaced_runs(blocks: Vec<Block>, first_index: usize) -> Vec<Block> {
6063    let sizes: Vec<f64> = blocks.iter().map(block_size).collect();
6064    let single = |i: usize| {
6065        matches!(blocks[i].kind, BlockKind::Paragraph) && block_line_count(&blocks[i]) == 1
6066    };
6067    // The white between a block and the one above it, in ems of the upper.
6068    let gap_above = |i: usize| {
6069        let em = sizes[i - 1];
6070        let em = if em > 0.0 { em } else { 10.0 };
6071        (blocks[i - 1].bbox[1] - blocks[i].bbox[3]) / em
6072    };
6073    let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
6074    let mut i = 0;
6075    while i < blocks.len() {
6076        if !single(i) {
6077            out.push(blocks[i].clone());
6078            i += 1;
6079            continue;
6080        }
6081        let mut run = vec![i];
6082        let mut j = i + 1;
6083        while j < blocks.len() {
6084            let a = run[run.len() - 1];
6085            if blocks[j].page != blocks[a].page || !single(j) {
6086                break;
6087            }
6088            let em = if sizes[a] > 0.0 { sizes[a] } else { 10.0 };
6089            if (sizes[j] - sizes[a]).abs() > DOUBLE_SPACED_SIZE_TOLERANCE_PT {
6090                break;
6091            }
6092            if (blocks[j].bbox[0] - blocks[run[0]].bbox[0]).abs() > DOUBLE_SPACED_LEFT_EM * em {
6093                break;
6094            }
6095            let gap = gap_above(j);
6096            if !(DOUBLE_SPACED_GAP_MIN_EM..=DOUBLE_SPACED_GAP_MAX_EM).contains(&gap) {
6097                break;
6098            }
6099            run.push(j);
6100            j += 1;
6101        }
6102        if run.len() < 2 {
6103            out.push(blocks[i].clone());
6104            i += 1;
6105            continue;
6106        }
6107        let gaps: Vec<f64> = run.iter().skip(1).map(|&k| gap_above(k)).collect();
6108        let median = median_gap(gaps);
6109        let right = run
6110            .iter()
6111            .map(|&k| blocks[k].bbox[2])
6112            .fold(f64::NEG_INFINITY, f64::max);
6113        let mut current = blocks[run[0]].clone();
6114        for pair in run.windows(2) {
6115            let (a, c) = (pair[0], pair[1]);
6116            let em = if sizes[a] > 0.0 { sizes[a] } else { 10.0 };
6117            let upper = blocks[a].text.trim();
6118            let lower = blocks[c].text.trim();
6119            let fills_the_measure = blocks[a].bbox[2] >= right - DOUBLE_SPACED_SHORT_EM * em;
6120            let join = fills_the_measure
6121                && gap_above(c) <= DOUBLE_SPACED_GAP_RATIO * median
6122                && !starts_with_marker(lower)
6123                && !double_spaced_guard(upper, lower);
6124            if join {
6125                if is_hyphen_wrap_candidate(&current.text, lower) {
6126                    current.text.pop();
6127                    current.text.push_str(lower);
6128                } else {
6129                    current.text.push(' ');
6130                    current.text.push_str(lower);
6131                }
6132                current.spans.extend(blocks[c].spans.iter().cloned());
6133                current.bbox = [
6134                    current.bbox[0].min(blocks[c].bbox[0]),
6135                    current.bbox[1].min(blocks[c].bbox[1]),
6136                    current.bbox[2].max(blocks[c].bbox[2]),
6137                    current.bbox[3].max(blocks[c].bbox[3]),
6138                ];
6139            } else {
6140                out.push(current);
6141                current = blocks[c].clone();
6142            }
6143        }
6144        out.push(current);
6145        i = j;
6146    }
6147    for (n, block) in out.iter_mut().enumerate() {
6148        block.index = first_index + n;
6149    }
6150    out
6151}
6152
6153/// Join a marker line back to its hanging continuation (#574).
6154///
6155/// The merge cuts a marker-led line off its continuation at a change of
6156/// weight -- `(74) Agent: ...` over `Road, Kennett Square ...`, bold over
6157/// regular -- and the heading classifier then reads the marker line as a
6158/// heading. Where the continuation hangs under the marker line's text at
6159/// normal leading, the cut is undone here, before classification, so the
6160/// joined block is classified like any other. Indices are renumbered from
6161/// `first_index`; nothing else moves.
6162fn join_marker_continuations(blocks: Vec<Block>, first_index: usize) -> Vec<Block> {
6163    let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
6164    for block in blocks {
6165        let join = match out.last() {
6166            Some(prev) if prev.page == block.page => {
6167                let prev_lines = lines_of_block(prev);
6168                let next_lines = lines_of_block(&block);
6169                match (prev_lines.first(), prev_lines.last(), next_lines.first()) {
6170                    (Some(first), Some(last), Some(c)) => {
6171                        marker_line_hangs_onto(prev, first, last, &block, c)
6172                    }
6173                    _ => false,
6174                }
6175            }
6176            _ => false,
6177        };
6178        if join {
6179            let prev = out.last_mut().expect("a previous block was checked");
6180            let text = crate::blocks::join_lines(&[prev.text.clone(), block.text.clone()]);
6181            prev.spans.extend(block.spans);
6182            prev.text = text;
6183            crate::blocks::recompute_bbox(prev);
6184        } else {
6185            out.push(block);
6186        }
6187    }
6188    for (n, block) in out.iter_mut().enumerate() {
6189        block.index = first_index + n;
6190    }
6191    out
6192}
6193
6194/// The white a line of a double-spaced run stands under, in ems of the line
6195/// above it (#574). Under six tenths the lines were never split; over three
6196/// the white is a zone break however regularly it recurs.
6197const DOUBLE_SPACED_GAP_MIN_EM: f64 = 0.6;
6198/// See [`DOUBLE_SPACED_GAP_MIN_EM`].
6199const DOUBLE_SPACED_GAP_MAX_EM: f64 = 3.0;
6200
6201/// How far the type size may differ between two lines of one run, in points.
6202const DOUBLE_SPACED_SIZE_TOLERANCE_PT: f64 = 0.5;
6203
6204/// How far a line's left edge may stand from the run's first, in ems.
6205const DOUBLE_SPACED_LEFT_EM: f64 = 0.5;
6206
6207/// How far short of the run's measure a line may fall and still be read as
6208/// filling it, in ems: a justified line does, an address line does not.
6209const DOUBLE_SPACED_SHORT_EM: f64 = 1.5;
6210
6211/// How far a line's gap may stand above the run's own median and still join.
6212const DOUBLE_SPACED_GAP_RATIO: f64 = 1.5;
6213
6214/// The type size most of the block's glyphs are set in, as the feature dump
6215/// measures it: one vote per glyph box, one per span without boxes.
6216fn block_size(block: &Block) -> f64 {
6217    let mut sizes: Vec<f64> = block
6218        .spans
6219        .iter()
6220        .flat_map(|span| {
6221            let votes = span.char_bounds.len().max(1);
6222            std::iter::repeat_n(span.font_size, votes)
6223        })
6224        .collect();
6225    if sizes.is_empty() {
6226        return 0.0;
6227    }
6228    sizes.sort_by(|a, b| a.total_cmp(b));
6229    sizes[sizes.len() / 2]
6230}
6231
6232/// How many lines the block holds, as the feature dump counts them: the
6233/// distinct baselines, rounded to half a point.
6234fn block_line_count(block: &Block) -> usize {
6235    let mut baselines: Vec<f64> = block
6236        .spans
6237        .iter()
6238        .map(|span| (span.y * 2.0).round() / 2.0)
6239        .collect();
6240    baselines.sort_by(|a, b| a.total_cmp(b));
6241    baselines.dedup();
6242    baselines.len()
6243}
6244
6245/// The middle of the run's own gaps, averaged where the run has an even
6246/// number of them, as `statistics.median` answers.
6247fn median_gap(mut gaps: Vec<f64>) -> f64 {
6248    gaps.sort_by(|a, b| a.total_cmp(b));
6249    match gaps.len() {
6250        0 => 0.0,
6251        len if len % 2 == 1 => gaps[len / 2],
6252        len => (gaps[len / 2 - 1] + gaps[len / 2]) / 2.0,
6253    }
6254}
6255
6256/// Whether a line opens with a list marker: `[0012]`, `1.` / `1)` / `(1)`,
6257/// `a)` / `(a)`, a Roman `IV. `, a bullet, or a section marker (`§`, `Art.`).
6258fn starts_with_marker(text: &str) -> bool {
6259    starts_with_bracket_number(text)
6260        || starts_with_numbered_marker(text)
6261        || starts_with_letter_marker(text)
6262        || starts_with_roman_marker(text)
6263        || starts_with_bullet(text)
6264        || starts_with_section_marker(text)
6265}
6266
6267/// `§ 2 ...`, `Art. 3 ...`: a section line never joins the one above it
6268/// (#636).
6269fn starts_with_section_marker(text: &str) -> bool {
6270    let trimmed = text.trim_start();
6271    trimmed.starts_with('§') || trimmed.starts_with("Art.")
6272}
6273
6274/// `[0012]`: an opening bracket, three to five digits, a closing bracket.
6275fn starts_with_bracket_number(text: &str) -> bool {
6276    let Some(rest) = text.trim_start().strip_prefix('[') else {
6277        return false;
6278    };
6279    let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
6280    (3..=5).contains(&digits) && rest.chars().nth(digits) == Some(']')
6281}
6282
6283/// `1.` / `1)` / `(1)`: an optional opening bracket, one to three digits, a
6284/// stop or a closing bracket.
6285fn starts_with_numbered_marker(text: &str) -> bool {
6286    let trimmed = text.trim_start();
6287    let rest = trimmed.strip_prefix('(').unwrap_or(trimmed);
6288    let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
6289    (1..=3).contains(&digits) && matches!(rest.chars().nth(digits), Some('.') | Some(')'))
6290}
6291
6292/// `a)` / `(a)`: an optional opening bracket, a lowercase letter, a stop or a
6293/// closing bracket, and the space after it.
6294fn starts_with_letter_marker(text: &str) -> bool {
6295    let trimmed = text.trim_start();
6296    let rest = trimmed.strip_prefix('(').unwrap_or(trimmed);
6297    let mut chars = rest.chars();
6298    matches!(
6299        (chars.next(), chars.next(), chars.next()),
6300        (Some(letter), Some(stop), Some(space))
6301            if letter.is_ascii_lowercase()
6302                && matches!(stop, '.' | ')')
6303                && space.is_whitespace()
6304    )
6305}
6306
6307/// `IV. `: one to four Roman capitals, a stop, and the space after it.
6308fn starts_with_roman_marker(text: &str) -> bool {
6309    let trimmed = text.trim_start();
6310    let numerals = trimmed
6311        .chars()
6312        .take_while(|c| matches!(c, 'I' | 'V' | 'X'))
6313        .count();
6314    if !(1..=4).contains(&numerals) {
6315        return false;
6316    }
6317    let mut chars = trimmed.chars().skip(numerals);
6318    matches!((chars.next(), chars.next()), (Some('.'), Some(space)) if space.is_whitespace())
6319}
6320
6321/// `• item`: a bullet and the space after it.
6322fn starts_with_bullet(text: &str) -> bool {
6323    let mut chars = text.trim_start().chars();
6324    matches!(
6325        (chars.next(), chars.next()),
6326        (Some(bullet), Some(space))
6327            if matches!(bullet, '•' | '●' | '▪' | '■' | '–' | '—' | '-' | '*')
6328                && space.is_whitespace()
6329    )
6330}
6331
6332/// Whether the pair must stay two blocks: every guard is drawn from a false
6333/// merge the simulation found (#574).
6334fn double_spaced_guard(upper: &str, lower: &str) -> bool {
6335    starts_with_number_then_capital(lower)
6336        || ends_in_digit(upper)
6337        || holds_key_value(upper)
6338        || lower.ends_with(':')
6339        || reads_as_heading(lower)
6340        || ends_sentence_then_capital(upper, lower)
6341}
6342
6343/// `26 Consolidated Statements`: a contents line, a number and a capital word.
6344fn starts_with_number_then_capital(text: &str) -> bool {
6345    let digits = text.chars().take_while(|c| c.is_ascii_digit()).count();
6346    if !(1..=4).contains(&digits) {
6347        return false;
6348    }
6349    let mut chars = text.chars().skip(digits);
6350    let mut spaces = 0;
6351    for c in chars.by_ref() {
6352        if c.is_whitespace() {
6353            spaces += 1;
6354        } else {
6355            return spaces > 0 && c.is_ascii_uppercase();
6356        }
6357    }
6358    false
6359}
6360
6361/// `Ausfertigungsdatum: 20.12.1963`: a contents line ends in a digit, whatever
6362/// punctuation follows it.
6363fn ends_in_digit(text: &str) -> bool {
6364    text.trim_end()
6365        .trim_end_matches(|c: char| !(c.is_alphanumeric() || c == '_'))
6366        .chars()
6367        .next_back()
6368        .is_some_and(|c| c.is_ascii_digit())
6369}
6370
6371/// `Key: value`: a line holding a colon followed by text is a label and its
6372/// value, not the start of a sentence.
6373fn holds_key_value(text: &str) -> bool {
6374    text.match_indices(':')
6375        .any(|(at, _)| !text[at + 1..].trim_start().is_empty())
6376}
6377
6378/// `Leases`: three words or fewer with no closing punctuation is a heading.
6379fn reads_as_heading(text: &str) -> bool {
6380    text.split_whitespace().count() <= 3 && !text.ends_with(['.', '!', '?', ';', ','])
6381}
6382
6383/// A full stop and a capital on the same pitch: two paragraphs, not one.
6384fn ends_sentence_then_capital(upper: &str, lower: &str) -> bool {
6385    upper.ends_with(['.', '!', '?']) && lower.chars().next().is_some_and(|c| c.is_uppercase())
6386}
6387
6388/// Characters a marker-led line opens with as a bullet (#574).
6389///
6390/// The dozen the patent front pages and the annual reports use. Both
6391/// triangular bullets are here because the two codepoints render as one
6392/// glyph and either may be what the page drew.
6393const MARKER_BULLETS: [char; 13] = [
6394    '\u{2022}', // bullet
6395    '\u{25CF}', // black circle
6396    '\u{25AA}', // black small square
6397    '\u{25E6}', // white bullet
6398    '\u{2023}', // triangular bullet
6399    '\u{2043}', // hyphen bullet
6400    '\u{2219}', // bullet operator
6401    '\u{00B7}', // middle dot
6402    '\u{2013}', // en dash
6403    '\u{2014}', // em dash
6404    '-',        // hyphen-minus
6405    '*',        // asterisk
6406    '>',        // greater-than
6407];
6408
6409/// Whether a letter is a roman digit, either case (#574).
6410fn is_roman_digit(c: char) -> bool {
6411    matches!(
6412        c.to_ascii_lowercase(),
6413        'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm'
6414    )
6415}
6416
6417/// The marker a line opens with, if it opens with one (#574).
6418///
6419/// A bullet, `(n)` or `[n]` of one to three digits, `n.` or `n)`, `a.`,
6420/// `a)` or `(a)`, or a roman numeral `(i)` or `iv.`. Ordered markers want
6421/// white space and text after the delimiter, so `3.14` and a name's `A.`
6422/// stay prose; a bullet wants text after it. Answers the marker's length
6423/// in bytes without the trailing space, and its inked characters, which is
6424/// what maps the marker onto the spans that drew it.
6425fn marker_prefix(text: &str) -> Option<(usize, usize)> {
6426    let trimmed = text.trim_start();
6427    if trimmed.is_empty() {
6428        return None;
6429    }
6430    let first = trimmed.chars().next()?;
6431    if MARKER_BULLETS.contains(&first) {
6432        let rest = &trimmed[first.len_utf8()..];
6433        if rest.trim().is_empty() {
6434            return None;
6435        }
6436        return Some((first.len_utf8(), 1));
6437    }
6438    if first == '(' || first == '[' {
6439        let closing = if first == '(' { ')' } else { ']' };
6440        let body = &trimmed[first.len_utf8()..];
6441        let digits = body.chars().take_while(|c| c.is_ascii_digit()).count();
6442        if (1..=3).contains(&digits) {
6443            let mut rest = &body[digits..];
6444            if rest.starts_with(closing) {
6445                rest = &rest[closing.len_utf8()..];
6446                if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
6447                    return Some((first.len_utf8() + digits + closing.len_utf8(), 2 + digits));
6448                }
6449            }
6450        }
6451        if first == '(' {
6452            let letters = body.chars().take_while(|c| c.is_ascii_alphabetic()).count();
6453            if (1..=6).contains(&letters) {
6454                let word = &body[..letters];
6455                if letters == 1 || word.chars().all(is_roman_digit) {
6456                    let mut rest = &body[letters..];
6457                    if rest.starts_with(')') {
6458                        rest = &rest[1..];
6459                        if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
6460                            return Some((1 + letters + 1, 2 + letters));
6461                        }
6462                    }
6463                }
6464            }
6465        }
6466        return None;
6467    }
6468    if first.is_ascii_digit() {
6469        let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
6470        if (1..=3).contains(&digits) {
6471            let rest = &trimmed[digits..];
6472            if matches!(rest.chars().next(), Some('.') | Some(')')) {
6473                let after = &rest[1..];
6474                if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
6475                    return Some((digits + 1, digits + 1));
6476                }
6477            }
6478        }
6479        return None;
6480    }
6481    if first.is_ascii_alphabetic() {
6482        let letters = trimmed
6483            .chars()
6484            .take_while(|c| c.is_ascii_alphabetic())
6485            .count();
6486        if letters == 1 {
6487            let rest = &trimmed[1..];
6488            if matches!(rest.chars().next(), Some('.') | Some(')')) {
6489                let after = &rest[1..];
6490                if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
6491                    return Some((2, 2));
6492                }
6493            }
6494            return None;
6495        }
6496        if (2..=6).contains(&letters) {
6497            let word = &trimmed[..letters];
6498            if word.chars().all(is_roman_digit) {
6499                let rest = &trimmed[letters..];
6500                if matches!(rest.chars().next(), Some('.') | Some(')')) {
6501                    let after = &rest[1..];
6502                    if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
6503                        return Some((letters + 1, letters + 1));
6504                    }
6505                }
6506            }
6507        }
6508        return None;
6509    }
6510    None
6511}
6512
6513/// The vertical extent of a span, from its glyph boxes where they exist and
6514/// from the type size where they do not.
6515fn span_bottom_top(span: &TextSpan) -> (f64, f64) {
6516    let mut bottom = f64::INFINITY;
6517    let mut top = f64::NEG_INFINITY;
6518    for bounds in &span.char_bounds {
6519        bottom = bottom.min(bounds[1]);
6520        top = top.max(bounds[3]);
6521    }
6522    if bottom.is_finite() && top.is_finite() && top > bottom {
6523        return (bottom, top);
6524    }
6525    (span.y - span.font_size * 0.2, span.y + span.font_size * 0.8)
6526}
6527
6528/// Characters that are not white space: what a marker is counted in, because
6529/// the line's text joins its spans with spaces the spans never drew.
6530fn inked_chars(text: &str) -> usize {
6531    text.chars().filter(|c| !c.is_whitespace()).count()
6532}
6533
6534/// Where the text after a marker line's marker begins: the left edge of the
6535/// first span the marker does not reach into (#574).
6536///
6537/// `None` where the marker is glued inside its line's first span and has no
6538/// column of its own to measure. A run of white space after the marker is
6539/// the spacing the page set it apart with, not where its text begins.
6540fn text_left_after_marker(block: &Block, line: &[usize], marker_inked: usize) -> Option<f64> {
6541    if marker_inked == 0 {
6542        return None;
6543    }
6544    let mut seen = 0usize;
6545    for i in line {
6546        let span = &block.spans[*i];
6547        let count = inked_chars(&span.text);
6548        if seen == marker_inked {
6549            if count == 0 {
6550                continue;
6551            }
6552            return Some(span.x);
6553        }
6554        if seen > marker_inked {
6555            return None;
6556        }
6557        seen += count;
6558    }
6559    None
6560}
6561
6562/// Whether the line c continues the marker block a: all six clauses of the
6563/// marker-hang rule (#574).
6564///
6565/// a's FIRST line opens with a marker and its LAST line does not end in a
6566/// colon, c hangs under the first line's text at normal leading without
6567/// opening a marker of its own, and the last line ran to the measure rather
6568/// than turning back. The hang is read off the first line's own text column
6569/// where the page drew the marker in a span of its own, and off its left
6570/// edge where it did not.
6571///
6572/// Two lines of a rather than one, because the marker item may wrap over
6573/// several lines before the weight cut parts it from its continuation: the
6574/// marker is on the first line and the wrap on the last, and reading both
6575/// off the last line leaves a wrapped item uncovered.
6576fn marker_line_hangs_onto(
6577    a_block: &Block,
6578    a_first: &[usize],
6579    a_last: &[usize],
6580    c_block: &Block,
6581    c_line: &[usize],
6582) -> bool {
6583    let a_text = line_text(a_block, a_first);
6584    let c_text = line_text(c_block, c_line);
6585    let Some((_, a_inked)) = marker_prefix(&a_text) else {
6586        return false;
6587    };
6588    if marker_prefix(&c_text).is_some() {
6589        return false;
6590    }
6591    if line_text(a_block, a_last).trim_end().ends_with(':') {
6592        return false;
6593    }
6594    let anchor_of = |block: &Block, line: &[usize]| -> Option<usize> {
6595        line.iter()
6596            .copied()
6597            .max_by_key(|i| inked_chars(&block.spans[*i].text))
6598    };
6599    let (Some(a_at), Some(c_at)) = (anchor_of(a_block, a_last), anchor_of(c_block, c_line)) else {
6600        return false;
6601    };
6602    let a_anchor = &a_block.spans[a_at];
6603    let c_anchor = &c_block.spans[c_at];
6604    let em = a_anchor.font_size;
6605    if !em.is_finite() || em <= 0.0 {
6606        return false;
6607    }
6608    if a_anchor.y <= c_anchor.y {
6609        return false;
6610    }
6611    let left_of = |block: &Block, line: &[usize]| {
6612        line.iter()
6613            .map(|i| block.spans[*i].x)
6614            .fold(f64::INFINITY, f64::min)
6615    };
6616    let right_of = |block: &Block, line: &[usize]| {
6617        line.iter()
6618            .map(|i| crate::blocks::span_right(&block.spans[*i]))
6619            .fold(f64::NEG_INFINITY, f64::max)
6620    };
6621    let a_left = left_of(a_block, a_first);
6622    let a_right = right_of(a_block, a_last);
6623    let c_left = left_of(c_block, c_line);
6624    let c_right = right_of(c_block, c_line);
6625    if !(a_left.is_finite() && a_right.is_finite() && c_left.is_finite() && c_right.is_finite()) {
6626        return false;
6627    }
6628    if let Some(text_left) = text_left_after_marker(a_block, a_first, a_inked) {
6629        if (c_left - text_left).abs() > MARKER_TEXT_LEFT_TOL_EM * em {
6630            return false;
6631        }
6632    } else if !(c_left > a_left + MARKER_HANG_MIN_EM * em
6633        && c_left < a_left + MARKER_HANG_MAX_EM * em)
6634    {
6635        return false;
6636    }
6637    if a_right < a_right.max(c_right) - MARKER_WRAP_SLACK_EM * em {
6638        return false;
6639    }
6640    let a_bottom = a_last
6641        .iter()
6642        .map(|i| span_bottom_top(&a_block.spans[*i]).0)
6643        .fold(f64::INFINITY, f64::min);
6644    let c_top = c_line
6645        .iter()
6646        .map(|i| span_bottom_top(&c_block.spans[*i]).1)
6647        .fold(f64::NEG_INFINITY, f64::max);
6648    if !(a_bottom.is_finite() && c_top.is_finite()) {
6649        return false;
6650    }
6651    if ((a_bottom - c_top) / em) >= MARKER_GAP_MAX_EM {
6652        return false;
6653    }
6654    true
6655}
6656
6657/// The spans of a block grouped into lines, top first, each line left to
6658/// right. The same grouping the feature dump reads words by: sorted by
6659/// baseline, then by x, a new line where the baseline steps by half an em.
6660///
6661/// `pub(crate)` for the markdown serializer, which reads an all-monospace
6662/// paragraph's lines back out of it as a fenced block (#636).
6663pub(crate) fn lines_of_block(block: &Block) -> Vec<Vec<usize>> {
6664    let mut order: Vec<usize> = (0..block.spans.len()).collect();
6665    order.sort_by(|a, b| {
6666        block.spans[*b]
6667            .y
6668            .partial_cmp(&block.spans[*a].y)
6669            .unwrap_or(Ordering::Equal)
6670            .then_with(|| {
6671                block.spans[*a]
6672                    .x
6673                    .partial_cmp(&block.spans[*b].x)
6674                    .unwrap_or(Ordering::Equal)
6675            })
6676    });
6677    let mut lines: Vec<Vec<usize>> = Vec::new();
6678    let mut tops: Vec<f64> = Vec::new();
6679    for idx in order {
6680        let size = block.spans[idx].font_size;
6681        let tolerance = if size > 0.0 { 0.5 * size } else { 1e-6 };
6682        if let (Some(last), Some(top)) = (lines.last_mut(), tops.last().copied()) {
6683            if (top - block.spans[idx].y).abs() < tolerance {
6684                last.push(idx);
6685                continue;
6686            }
6687        }
6688        lines.push(vec![idx]);
6689        tops.push(block.spans[idx].y);
6690    }
6691    for line in &mut lines {
6692        line.sort_by(|a, b| {
6693            block.spans[*a]
6694                .x
6695                .partial_cmp(&block.spans[*b].x)
6696                .unwrap_or(Ordering::Equal)
6697        });
6698    }
6699    lines
6700}
6701
6702/// One line as it reads: its spans left to right through the same assembly
6703/// the merge built the block's text from.
6704///
6705/// `pub(crate)` beside [`lines_of_block`], for the same fenced block (#636).
6706pub(crate) fn line_text(block: &Block, line: &[usize]) -> String {
6707    let spans: Vec<TextSpan> = line.iter().map(|i| block.spans[*i].clone()).collect();
6708    TextBlock { spans }.text()
6709}
6710
6711/// The quarter turn that puts a page which is itself turned back in the frame
6712/// it was written in (#546).
6713///
6714/// A page carries a `/Rotate`, of its own or inherited from the `Pages` tree,
6715/// and `Page::initial_transform` turns every glyph on it with the page: the
6716/// text device sees a page of lines running up, down or backwards. Bands are
6717/// horizontal and they read one direction, so on such a page they lie ACROSS
6718/// its lines: each glyph of a line lands in a band of its own and the page
6719/// reads as letter salad -- `ro t a ti o n  9 0` where pdfbox `PDFBOX-6049`
6720/// says `rotation 90`, and `081 noitator` on its half-turned page. This is the
6721/// same failure #435 fixed for a run drawn against the direction of its page,
6722/// and it stayed on the pages where the turned direction is the MAJORITY: what
6723/// #435 assembles there is the upright text on a turned page.
6724///
6725/// So the layout runs in the frame the page was written in. Every span is
6726/// turned into the direction most of the page's glyphs are drawn in before
6727/// `PageStats`, the cut and the bands, and the blocks are turned back before
6728/// they are returned, which leaves the spans the caller gets in page space to
6729/// the bit.
6730///
6731/// QUARTER TURNS ONLY, and the reason is exactness rather than taste: the sine
6732/// and cosine of a quarter turn are 0 and ±1, so the turn and its inverse move
6733/// no coordinate by a single bit, and a page this pass declines to turn comes
6734/// out of it byte for byte as it went in. A page `/Rotate` is a multiple of 90
6735/// by §7.7.3.3, which is the whole of what this is about; a page drawn entirely
6736/// at a free angle keeps its own bands, exactly as it did before (they read it:
6737/// `a_page_written_entirely_at_an_angle_keeps_its_own_bands`).
6738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6739enum PageTurn {
6740    /// The page reads left to right already, or in no direction this undoes.
6741    None,
6742    /// Its lines run up the page: `/Rotate 90`.
6743    Quarter,
6744    /// Its lines run right to left: `/Rotate 180`.
6745    Half,
6746    /// Its lines run down the page: `/Rotate 270`.
6747    ThreeQuarter,
6748}
6749
6750impl PageTurn {
6751    /// The turn that makes the direction the page is WRITTEN in upright.
6752    ///
6753    /// Written in, and not merely the heaviest of: this turns the layout of a
6754    /// whole page, so the direction has to carry more than half its glyphs.
6755    /// The plurality rule #435 picks its cross-direction runs by is the right
6756    /// one there -- whatever is not the page's direction is a run on it -- and
6757    /// the wrong one here, because a page with no majority direction has no
6758    /// frame to be read in and turning it by a 26% plurality moves every block
6759    /// on it. pdfbox `AcroFormsRotation` is that page: four blocks of form
6760    /// fields at the four rotations, a quarter of the glyphs each, and the
6761    /// plurality rule turned its UPRIGHT page and put its fields in an order
6762    /// neither mutool nor poppler reads them in (order agreement 100 -> 50 on
6763    /// the gate set, where nothing else on 500 documents moved at all).
6764    /// A REFLECTION IS NOT A TURN, and only the determinant says which it is
6765    /// (codex, review round 2). `heaviest_direction` reads the baseline vector,
6766    /// and a horizontal reflection such as `[-1, 0, 0, 1, e, f]` runs its
6767    /// baseline in `(-1, 0)` exactly as a half turn does. Classifying that page
6768    /// as `Half` makes [`Self::into_frame`] negate BOTH coordinates, so a
6769    /// mirrored block whose lines descend the page comes back bottom to top --
6770    /// a reading order this pass invented out of a page nobody turned.
6771    ///
6772    /// Reflected glyphs are therefore counted in the page's total and in no
6773    /// direction's share. A page mostly drawn mirrored cannot reach a majority
6774    /// and comes out of this pass byte for byte as it went in, which is what
6775    /// `Self::None` means here.
6776    fn of(spans: &[TextSpan]) -> Self {
6777        let page_total: usize = spans.iter().map(glyph_weight).sum();
6778        if page_total == 0 {
6779            return Self::None;
6780        }
6781        let turned: Vec<&TextSpan> = spans
6782            .iter()
6783            .filter(|span| is_a_proper_rotation(span))
6784            .collect();
6785        let turned_total: usize = turned.iter().copied().map(glyph_weight).sum();
6786        let Some((page, share_of_turned)) = heaviest_direction(turned.into_iter()) else {
6787            return Self::None;
6788        };
6789        // The share is of the WHOLE page, mirrored glyphs included: measured
6790        // against the rotations alone, one upright line on a mirrored page
6791        // would carry 100 % of the vote.
6792        let share = share_of_turned * turned_total as f64 / page_total as f64;
6793        if share <= 0.5 {
6794            return Self::None;
6795        }
6796        [Self::Quarter, Self::Half, Self::ThreeQuarter]
6797            .into_iter()
6798            .find(|turn| directions_agree(page, turn.direction()))
6799            .unwrap_or(Self::None)
6800    }
6801
6802    /// The direction a page this turn undoes is written in.
6803    fn direction(self) -> Direction {
6804        match self {
6805            Self::None => (1.0, 0.0),
6806            Self::Quarter => (0.0, 1.0),
6807            Self::Half => (-1.0, 0.0),
6808            Self::ThreeQuarter => (0.0, -1.0),
6809        }
6810    }
6811
6812    /// The `(cos, sin)` of the rotation into the frame: the one that takes
6813    /// [`Self::direction`] to `(1, 0)`.
6814    fn rotation(self) -> (f64, f64) {
6815        match self {
6816            Self::None => (1.0, 0.0),
6817            Self::Quarter => (0.0, -1.0),
6818            Self::Half => (-1.0, 0.0),
6819            Self::ThreeQuarter => (0.0, 1.0),
6820        }
6821    }
6822
6823    /// Every span in the frame the page was written in.
6824    fn into_frame(self, mut spans: Vec<TextSpan>) -> Vec<TextSpan> {
6825        if self == Self::None {
6826            return spans;
6827        }
6828        let rotation = self.rotation();
6829        for span in &mut spans {
6830            // In its own frame a run is as wide as it advances along its
6831            // baseline, and that is not what `draw_glyph` accumulated: `width`
6832            // is the furthest a glyph reached in PAGE x, which for a run
6833            // drawn up the page is one advance however long the run is. Every
6834            // reader of `width` above this point -- the column gutters, the
6835            // band gaps, the cell edges -- would take a line of a turned page
6836            // for a single word.
6837            let along = baseline_extent(span);
6838            turn_span(span, rotation);
6839            span.width = along;
6840        }
6841        spans
6842    }
6843
6844    /// The ruling lines of #545 in the frame the page is read in.
6845    ///
6846    /// The device records horizontal rules and no others, and only the half
6847    /// turn maps a horizontal line to a horizontal line -- mirrored, which is
6848    /// why the two ends swap. A quarter turn maps one to a vertical line:
6849    /// what the device caught on such a page are its column separators, and
6850    /// its row rules were never horizontal to begin with, so they were never
6851    /// recorded at all. Those pages are cut without rules, exactly as every
6852    /// page was before #545, rather than with rules that mean the other thing
6853    /// (#546, review round 2).
6854    fn rules_in_frame(self, rules: &[HorizontalRule]) -> Cow<'_, [HorizontalRule]> {
6855        let rotation = self.rotation();
6856        match self {
6857            Self::None => Cow::Borrowed(rules),
6858            Self::Half => Cow::Owned(
6859                rules
6860                    .iter()
6861                    .map(|rule| {
6862                        let (left, y) = turn_point(rule.left, rule.y, rotation);
6863                        let (right, _) = turn_point(rule.right, rule.y, rotation);
6864                        HorizontalRule {
6865                            left: left.min(right),
6866                            right: left.max(right),
6867                            y,
6868                        }
6869                    })
6870                    .collect(),
6871            ),
6872            Self::Quarter | Self::ThreeQuarter => Cow::Owned(Vec::new()),
6873        }
6874    }
6875
6876    /// The blocks back in page space, with every span's geometry the caller's
6877    /// own again.
6878    fn back(self, mut blocks: Vec<TextBlock>) -> Vec<TextBlock> {
6879        if self == Self::None {
6880            return blocks;
6881        }
6882        let (cos, sin) = self.rotation();
6883        for span in blocks.iter_mut().flat_map(|block| block.spans.iter_mut()) {
6884            turn_span(span, (cos, -sin));
6885            span.width = page_space_width(span);
6886        }
6887        blocks
6888    }
6889
6890    /// The paragraphs back in page space (#567).
6891    ///
6892    /// The same restoration [`Self::back`] performs on lines, and then the
6893    /// block's own box again: a box computed in the frame the page was written
6894    /// in describes a rectangle nobody on the caller's side can find.
6895    fn back_blocks(self, mut blocks: Vec<Block>) -> Vec<Block> {
6896        if self == Self::None {
6897            return blocks;
6898        }
6899        let (cos, sin) = self.rotation();
6900        for block in blocks.iter_mut() {
6901            for span in block.spans.iter_mut() {
6902                turn_span(span, (cos, -sin));
6903                span.width = page_space_width(span);
6904            }
6905            crate::blocks::recompute_bbox(block);
6906        }
6907        blocks
6908    }
6909}
6910
6911/// How far a span reaches along its own baseline, from its origin.
6912fn baseline_extent(span: &TextSpan) -> f64 {
6913    let direction = span_direction(span);
6914    baseline_end(span, direction) - baseline_coordinates(span, direction).0
6915}
6916
6917/// The width `draw_glyph` measures: the furthest any of the span's glyphs
6918/// reaches in page x, from the span's origin.
6919///
6920/// This is what [`PageTurn::back`] restores, so that a caller of
6921/// `extract_text_blocks` on a turned page reads the same `width` off a span as
6922/// one on an upright page does. It is derived from the bounds rather than
6923/// remembered because the bounds are the same measurements `draw_glyph`
6924/// accumulated it from; a span with no bounds -- every hand-built one -- keeps
6925/// the width it came with.
6926fn page_space_width(span: &TextSpan) -> f64 {
6927    span.char_bounds
6928        .iter()
6929        .map(|bound| bound[2] - span.x)
6930        .reduce(f64::max)
6931        .unwrap_or(span.width)
6932}
6933
6934/// Turn one span's geometry by `(cos, sin)`.
6935///
6936/// What turns is what the layout reads as a position: the span's origin, the
6937/// baseline its transform names, and the origin of each glyph bound.
6938///
6939/// What does not turn is what is measured along that baseline and means the
6940/// same in either frame. A `char_bound` is one of those: `draw_glyph` writes
6941/// it as `[x, y, x + advance, y + font size]` around a PAGE-space origin, so
6942/// it is an advance and a font size hung off a point rather than the rectangle
6943/// a turned glyph covers -- which is what `baseline_end` reads back out of it.
6944/// Turning the two numbers as if the entry were a rectangle would turn a
6945/// measurement that was never in page space to begin with.
6946///
6947/// `tight_char_bounds` is deliberately left alone. That one IS a page-space
6948/// rectangle, and it is ink rather than layout: nothing between here and the
6949/// blocks reads it, so leaving it where it is keeps it exact for the caller who
6950/// does (`GeometryMode::RichGeometry`).
6951fn turn_span(span: &mut TextSpan, rotation: (f64, f64)) {
6952    let (x, y) = turn_point(span.x, span.y, rotation);
6953    span.x = x;
6954    span.y = y;
6955    if let Some(coeffs) = span.transform.as_mut() {
6956        let (a, b) = turn_point(coeffs[0], coeffs[1], rotation);
6957        let (c, d) = turn_point(coeffs[2], coeffs[3], rotation);
6958        let (e, f) = turn_point(coeffs[4], coeffs[5], rotation);
6959        *coeffs = [a, b, c, d, e, f];
6960    }
6961    for bound in &mut span.char_bounds {
6962        let (advance, size) = (bound[2] - bound[0], bound[3] - bound[1]);
6963        let (x0, y0) = turn_point(bound[0], bound[1], rotation);
6964        *bound = [x0, y0, x0 + advance, y0 + size];
6965    }
6966}
6967
6968/// A point turned by `(cos, sin)`.
6969fn turn_point(x: f64, y: f64, (cos, sin): (f64, f64)) -> (f64, f64) {
6970    (x * cos - y * sin, x * sin + y * cos)
6971}
6972
6973/// Take the runs drawn in another direction than the page's out of the blocks
6974/// the page's own layout put them in, and give each one back as the line it is
6975/// (#435).
6976///
6977/// Bands are horizontal, and they read one direction. The letters of a diagonal
6978/// watermark, of a rotated table header, of the marginalia down the side of a
6979/// page each land in a band of their own, interleaved with whatever upright text
6980/// shares that band, and the reader gets `rk Xa Orm test value aBte D df wN t pA
6981/// sS te` where pdfjs issue17069 says `test pdf watermark` and `SANDBOX`.
6982///
6983/// WHERE THE PAGE'S OWN DIRECTION COMES FROM. The direction most of the page's
6984/// glyphs are drawn in keeps the page's bands, and on a page that is ITSELF
6985/// turned -- a `/Rotate 90` page, whose every glyph reaches the device turned
6986/// with it -- those bands would lie across its lines and read it as badly as a
6987/// watermark read before this pass (pdfbox `PDFBOX-6049`, `AcroFormsRotation`).
6988/// Such a page is turned back into the frame it was written in before the cut
6989/// and the bands ever see it ([`PageTurn`], #546), so by the time this pass
6990/// runs the page's own direction is upright again and what is left for it is
6991/// what it is for: the run drawn against the direction of the page it is on.
6992///
6993/// This runs AFTER the page's cut and takes nothing away from it. Where the
6994/// page's columns and zones are is decided by every glyph on it, rotated ones
6995/// included -- a run of marginalia down the left edge is part of why a page
6996/// splits where it does, and a version of this that cut the page without them
6997/// moved the reading order of `issue6127` by sixteen points. So the layout is
6998/// the layout, and what changes here is only which line each rotated glyph is
6999/// read as part of.
7000fn reassemble_cross_direction_runs(blocks: Vec<TextBlock>) -> Vec<TextBlock> {
7001    let Some(page) = page_direction(&blocks) else {
7002        return blocks;
7003    };
7004    reassemble_with_direction(page, blocks)
7005}
7006
7007/// The same pass, told which direction the page reads in.
7008///
7009/// Split out for [`paragraph_blocks`], which runs it once per region of the cut
7010/// rather than once over the page: a run drawn across the type is read as part
7011/// of the region it was drawn in, and the page's own direction is still the
7012/// page's -- decided over every block on it, which is why it is an argument
7013/// here and not something each region votes on for itself.
7014fn reassemble_with_direction(page: Direction, blocks: Vec<TextBlock>) -> Vec<TextBlock> {
7015    let mut kept: Vec<TextBlock> = Vec::with_capacity(blocks.len());
7016    // Each cross-direction span, with the position in the page's order that the
7017    // block it was taken from held.
7018    let mut cross: Vec<PlacedSpan> = Vec::new();
7019    for block in blocks {
7020        let at = kept.len();
7021        let mut page_spans = Vec::with_capacity(block.spans.len());
7022        for span in block.spans {
7023            if directions_agree(page, span_direction(&span)) {
7024                page_spans.push(span);
7025            } else {
7026                cross.push((at, span));
7027            }
7028        }
7029        if !page_spans.is_empty() {
7030            kept.push(TextBlock { spans: page_spans });
7031        }
7032    }
7033
7034    if cross.is_empty() {
7035        return kept;
7036    }
7037
7038    // `lines_along_their_own_baselines` returns the runs in the order they are
7039    // to be read. They are inserted back to front -- so that inserting one
7040    // never moves the position another was measured at -- and two runs that
7041    // share a position are inserted in the reverse of their order, because each
7042    // insertion at the same index pushes the last one down (#435, review round
7043    // 2).
7044    let runs = lines_along_their_own_baselines(cross);
7045    for (at, run) in runs.into_iter().rev() {
7046        let at = at.min(kept.len());
7047        kept.insert(at, run);
7048    }
7049    kept
7050}
7051
7052/// The direction most of a page's glyphs are drawn in, or `None` for a page
7053/// with no text at all.
7054fn page_direction(blocks: &[TextBlock]) -> Option<Direction> {
7055    dominant_direction(blocks.iter().flat_map(|block| block.spans.iter()))
7056}
7057
7058/// A span's provenance from its glyphs': an estimate if any glyph was
7059/// estimated, tight if any was measured tight and none estimated, the advance
7060/// otherwise. One definition, for the span as `compute_tight_bounds` makes it
7061/// and for a span that loses glyphs afterwards (codex, #1974).
7062pub(crate) fn aggregate_bounds_source(sources: &[BoundsSource]) -> BoundsSource {
7063    if sources.contains(&BoundsSource::Estimate) {
7064        BoundsSource::Estimate
7065    } else if sources.contains(&BoundsSource::Tight) {
7066        BoundsSource::Tight
7067    } else {
7068        BoundsSource::Advance
7069    }
7070}
7071
7072/// #582's reading of tracking, asked again of a FINISHED span (#1973).
7073///
7074/// #582 believes a run is tracked once four gaps in a row agree, deciding at
7075/// each glyph from the gaps before it. A word of four letters has three --
7076/// `P E N N`, `P A G E` -- and a word space set wider than the tracking starts a
7077/// new span, so on a display line of short words the count never reaches four
7078/// and every letter keeps the space the consensus gave it. Once the span is
7079/// finished all its gaps are known, and the same question has an answer: the
7080/// span's tracking is its median gap, a consensus space at a gap within
7081/// [`TRACKING_TOLERANCE`]-and-a-bit of it -- or tighter, a kerned pair such as
7082/// `TA` -- is tracking and goes, and one clearly wider is a word boundary and
7083/// stays. Which is #582's choice about letters at one constant distance,
7084/// pinned there by `a_line_of_single_letter_words_reads_as_tracking_and_that_is_the_choice`.
7085///
7086/// Asked only of a span of capitals that shows the symptom (four single
7087/// letters between spaces), and only where its glyphs are its letters one for
7088/// one: every space in it is then the consensus's own and none is a glyph the
7089/// page drew. A span with a drawn space, a ligature or lower case is left to
7090/// the rules after this.
7091fn retrack_a_finished_span(span: &mut TextSpan, chars: &[char]) -> bool {
7092    let letters: Vec<char> = chars.iter().copied().filter(|c| *c != ' ').collect();
7093    if letters.len() != span.char_bounds.len() || letters.len() < 4 {
7094        return false;
7095    }
7096    if letters.iter().any(|c| c.is_lowercase()) || !letters.iter().any(|c| c.is_uppercase()) {
7097        return false;
7098    }
7099    let singles = span
7100        .text
7101        .split(' ')
7102        .filter(|w| w.chars().count() == 1 && w.chars().all(char::is_uppercase))
7103        .count();
7104    if singles < 4 {
7105        return false;
7106    }
7107    let gaps: Vec<f64> = span
7108        .char_bounds
7109        .windows(2)
7110        .map(|p| p[1][0] - p[0][2])
7111        .collect();
7112    let mut sorted = gaps.clone();
7113    sorted.sort_by(|a, b| a.total_cmp(b));
7114    // THE LOWER MEDIAN (codex, #1980). A short phrase can have as many word
7115    // gaps as letter gaps -- `I AM OK`: 1, 1, 3, 3 -- and the upper median then
7116    // takes the word space for the tracking and removes every space. The lower
7117    // one stays with the letters, and a kerned pair below them (`TA`) is too
7118    // rare in a word to pull it down.
7119    let tracking = sorted[(sorted.len() - 1) / 2];
7120    // Real tracking: the letters stand apart. In prose the boxes of a word's
7121    // letters touch -- they are advances -- so its median gap is nothing, and
7122    // the consensus's spaces there are word spaces, which this must not take.
7123    if tracking <= TRACKED_GAP_MIN_EM * span.font_size {
7124        return false;
7125    }
7126    let inside_a_word = tracking * (1.0 + TRACKING_TOLERANCE + 0.1);
7127    let mut spaced_before = vec![false; letters.len()];
7128    let mut k = 0;
7129    for c in chars {
7130        if *c == ' ' {
7131            if k < letters.len() {
7132                spaced_before[k] = true;
7133            }
7134        } else {
7135            k += 1;
7136        }
7137    }
7138    let mut text = String::with_capacity(chars.len());
7139    for (k, c) in letters.iter().enumerate() {
7140        if k > 0 && spaced_before[k] && gaps[k - 1] > inside_a_word {
7141            text.push(' ');
7142        }
7143        text.push(*c);
7144    }
7145    if chars.last() == Some(&' ') {
7146        text.push(' ');
7147    }
7148    let changed = text != span.text;
7149    span.text = text;
7150    changed
7151}
7152
7153/// Letter-spacing done with SPACE CHARACTERS, taken back out (#1973).
7154///
7155/// #582 reads tracking that the page draws as GAPS. A good share of display
7156/// lines draw it with space glyphs instead -- `P O W E L L  I N D U S T R I E S`,
7157/// a space after every capital and two where the words part -- and those
7158/// arrive as text, so no gap is ever there to judge: on the evidence of the
7159/// 24-09 round it is the largest class of text we write that the page does not
7160/// hold (0.74 % of our words on DocLayNet, against Docling's 0.07 %).
7161///
7162/// A run of four single capitals or more with one space between each is taken
7163/// for letter-spacing only when THE PAGE MARKS ITS WORD BOUNDARIES with a
7164/// double space (`P O W E L L  I N D U S T R I E S`). The single spaces then go
7165/// and each double one becomes one. With no such mark, `A B C D` stays as it
7166/// is: letters set apart on purpose and letters tracked by spaces are the same
7167/// bytes, and the boundary is the only thing that tells them apart. A pair of
7168/// capitals inside the run (`O P E R AT I O N S`, a kerned pair drawn as one)
7169/// does not end it.
7170fn untrack_spaced_letters(span: &mut TextSpan) {
7171    let chars: Vec<char> = span.text.chars().collect();
7172    if chars.len() < 7 || !chars.contains(&' ') {
7173        return;
7174    }
7175    // The finished span first; what it leaves is asked of the rules below.
7176    let chars: Vec<char> = if retrack_a_finished_span(span, &chars) {
7177        span.text.chars().collect()
7178    } else {
7179        chars
7180    };
7181    let aligned = span.char_bounds.len() == chars.len();
7182    let tight = span.tight_char_bounds.len() == chars.len();
7183    // Words and the separators between them, as char ranges.
7184    let mut words: Vec<(usize, usize)> = Vec::new();
7185    let mut at = 0;
7186    while at < chars.len() {
7187        if chars[at] == ' ' {
7188            at += 1;
7189            continue;
7190        }
7191        let start = at;
7192        while at < chars.len() && chars[at] != ' ' {
7193            at += 1;
7194        }
7195        words.push((start, at));
7196    }
7197    // Capitals and their punctuation only, as in `untrack_spaced_spans`.
7198    let small = |(a, b): (usize, usize)| {
7199        (b - a == 1 || b - a == 2)
7200            && chars[a..b]
7201                .iter()
7202                .all(|c| c.is_uppercase() || matches!(c, ',' | '.' | '\'' | '’' | '&' | '-'))
7203            && (b - a == 1 || chars[a..b].iter().any(|c| c.is_uppercase()))
7204    };
7205    let mut drop = vec![false; chars.len()];
7206    let mut w = 0;
7207    while w < words.len() {
7208        if !small(words[w]) {
7209            w += 1;
7210            continue;
7211        }
7212        // The run: small words, one or two spaces apart.
7213        let mut end = w + 1;
7214        while end < words.len()
7215            && small(words[end])
7216            && (1..=2).contains(&(words[end].0 - words[end - 1].1))
7217        {
7218            end += 1;
7219        }
7220        // A last "word" of one capital after a double space is not a tracked
7221        // word: `F C F C  S` is a label beside a letter, as the row rule
7222        // reads it (codex, #1974).
7223        while end > w + 1 {
7224            let last_double = (w + 1..end)
7225                .rev()
7226                .find(|&i| words[i].0 - words[i - 1].1 >= 2);
7227            match last_double {
7228                Some(at)
7229                    if words[at..end]
7230                        .iter()
7231                        .map(|&(a, b)| chars[a..b].iter().filter(|c| c.is_uppercase()).count())
7232                        .sum::<usize>()
7233                        < 2 =>
7234                {
7235                    end = at;
7236                }
7237                _ => break,
7238            }
7239        }
7240        let run = &words[w..end];
7241        let singles = run
7242            .iter()
7243            .filter(|&&(a, b)| b - a == 1 && chars[a].is_uppercase())
7244            .count();
7245        if singles >= 4 && run.len() >= 4 {
7246            let seps: Vec<(usize, usize)> = run.windows(2).map(|p| (p[0].1, p[1].0)).collect();
7247            // Only where the page marks its word boundaries with a double
7248            // space. Widths and gaps were tried and read figure labels and
7249            // OCR noise (`B C B C`, `I I I`) as tracked words.
7250            let boundary: Option<Vec<bool>> = seps
7251                .iter()
7252                .any(|(a, b)| b - a >= 2)
7253                .then(|| seps.iter().map(|(a, b)| b - a >= 2).collect());
7254            if let Some(boundary) = boundary {
7255                for (&(a, b), keep) in seps.iter().zip(boundary) {
7256                    // A boundary keeps its first space; everything else goes.
7257                    let from = if keep { a + 1 } else { a };
7258                    for d in drop.iter_mut().take(b).skip(from) {
7259                        *d = true;
7260                    }
7261                }
7262            }
7263        }
7264        w = end;
7265    }
7266    if !drop.contains(&true) {
7267        return;
7268    }
7269    span.text = chars
7270        .iter()
7271        .zip(&drop)
7272        .filter(|(_, d)| !**d)
7273        .map(|(c, _)| *c)
7274        .collect();
7275    if aligned {
7276        let mut k = 0;
7277        span.char_bounds.retain(|_| {
7278            k += 1;
7279            !drop[k - 1]
7280        });
7281    }
7282    if tight {
7283        let mut k = 0;
7284        span.tight_char_bounds.retain(|_| {
7285            k += 1;
7286            !drop[k - 1]
7287        });
7288    }
7289    // The rich-geometry vectors run beside the tight boxes, glyph for glyph.
7290    if span.glyph_advances.len() == chars.len() {
7291        let mut k = 0;
7292        span.glyph_advances.retain(|_| {
7293            k += 1;
7294            !drop[k - 1]
7295        });
7296    }
7297    if span.glyph_bounds_sources.len() == chars.len() {
7298        let mut k = 0;
7299        span.glyph_bounds_sources.retain(|_| {
7300            k += 1;
7301            !drop[k - 1]
7302        });
7303        // A space has no outline and marks its span an estimate; with the
7304        // spaces gone the span is what its letters are (codex, #1974).
7305        span.bounds_source = aggregate_bounds_source(&span.glyph_bounds_sources);
7306    }
7307}
7308
7309/// The direction most of a set of spans' glyphs are drawn in, or `None` for no
7310/// spans at all.
7311///
7312/// Weighted by glyphs and not by spans: one word of upright text is not the
7313/// page's direction because a rotated run happened to arrive as three spans.
7314/// And by GLYPHS and not by characters, which are not the same count: a
7315/// ligature decodes to two or three of them, and the spaces the space consensus
7316/// puts between two glyphs are characters that were never drawn. Either would
7317/// let a minority direction outvote the page's own on a page where the two are
7318/// close. `char_bounds` holds one entry per glyph drawn; the character count
7319/// answers only for a span that carries no bounds, which is every hand-built
7320/// one (#435, review round 2).
7321fn dominant_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<Direction> {
7322    heaviest_direction(spans).map(|(direction, _)| direction)
7323}
7324
7325/// What one span weighs in a vote about direction: the glyphs it drew, or its
7326/// characters when it carries no bounds. One definition, because two of them
7327/// would let the same page have two majorities.
7328fn glyph_weight(span: &TextSpan) -> usize {
7329    if span.char_bounds.is_empty() {
7330        span.text.chars().count().max(1)
7331    } else {
7332        span.char_bounds.len()
7333    }
7334}
7335
7336/// Whether a span was drawn by a proper rotation rather than a reflection.
7337///
7338/// A baseline vector cannot tell the two apart. `[-1, 0, 0, 1, e, f]` mirrors
7339/// the page about its vertical axis and leaves the baseline running in `(-1,
7340/// 0)` -- the same direction a half turn produces -- while the page's vertical
7341/// axis stays upright and nothing has turned. The determinant is what separates
7342/// them: it is positive for a rotation and negative for a reflection.
7343///
7344/// A span with no transform is hand-built and reads upright, which is a proper
7345/// rotation by the identity.
7346fn is_a_proper_rotation(span: &TextSpan) -> bool {
7347    match span.transform {
7348        None => true,
7349        Some([a, b, c, d, _, _]) => a * d - b * c > 0.0,
7350    }
7351}
7352
7353/// The heaviest direction and the share of the glyphs drawn in it, weighed by
7354/// the rule [`dominant_direction`] documents.
7355fn heaviest_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<(Direction, f64)> {
7356    let mut directions: Vec<(Direction, usize)> = Vec::new();
7357    for span in spans {
7358        let direction = span_direction(span);
7359        let weight = glyph_weight(span);
7360        match directions
7361            .iter_mut()
7362            .find(|(known, _)| directions_agree(*known, direction))
7363        {
7364            Some(entry) => entry.1 += weight,
7365            None => directions.push((direction, weight)),
7366        }
7367    }
7368    let total: usize = directions.iter().map(|(_, weight)| *weight).sum();
7369    // The first of the heaviest, so that a page whose two directions carry the
7370    // same number of glyphs reads in the one it was drawn in first rather than
7371    // in whichever the iterator saw last.
7372    let mut winner: Option<(Direction, usize)> = None;
7373    for (direction, weight) in directions {
7374        if winner.map(|(_, best)| weight > best).unwrap_or(true) {
7375            winner = Some((direction, weight));
7376        }
7377    }
7378    winner.map(|(direction, weight)| (direction, weight as f64 / total as f64))
7379}
7380
7381/// Assemble cross-direction spans into lines along their own baselines.
7382///
7383/// The rule the page's bands follow, in each run's own frame: spans within the
7384/// band tolerance of each other across the baseline are one line, ordered along
7385/// it. A line is placed where the page's order held the first of its spans, so
7386/// a watermark drawn across the middle of a page is read there and not after
7387/// the footer.
7388fn lines_along_their_own_baselines(spans: Vec<PlacedSpan>) -> Vec<(usize, TextBlock)> {
7389    // One group per direction: two runs at two angles never join, however close
7390    // their glyphs land.
7391    let mut groups: Vec<(Direction, Vec<PlacedSpan>)> = Vec::new();
7392    for (at, span) in spans {
7393        let direction = span_direction(&span);
7394        match groups
7395            .iter_mut()
7396            .find(|(known, _)| directions_agree(*known, direction))
7397        {
7398            Some((_, group)) => group.push((at, span)),
7399            None => groups.push((direction, vec![(at, span)])),
7400        }
7401    }
7402
7403    let mut lines: Vec<(usize, TextBlock)> = Vec::new();
7404    for (direction, group) in groups {
7405        let mut rows: Vec<(f64, usize, Vec<TextSpan>)> = Vec::new();
7406        for (at, span) in group {
7407            let across = baseline_coordinates(&span, direction).1;
7408            let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
7409            match rows
7410                .iter_mut()
7411                .find(|(row_across, _, _)| (row_across - across).abs() <= tolerance)
7412            {
7413                Some((_, first, row)) => {
7414                    *first = (*first).min(at);
7415                    row.push(span);
7416                }
7417                None => rows.push((across, at, vec![span])),
7418            }
7419        }
7420
7421        // Read in the order the page's own order first held them, and where
7422        // two lines start at the same place -- which is what a block of
7423        // rotated text does -- from the top of the run's own frame down.
7424        rows.sort_by(|a, b| {
7425            a.1.cmp(&b.1)
7426                .then_with(|| b.0.partial_cmp(&a.0).unwrap_or(Ordering::Equal))
7427        });
7428        for (_, at, mut row) in rows {
7429            row.sort_by(|a, b| {
7430                baseline_coordinates(a, direction)
7431                    .0
7432                    .partial_cmp(&baseline_coordinates(b, direction).0)
7433                    .unwrap_or(Ordering::Equal)
7434            });
7435            lines.push((at, TextBlock { spans: row }));
7436        }
7437    }
7438    // Across directions too: the page's order decides, and a stable sort keeps
7439    // each direction's own line order inside it.
7440    lines.sort_by(|a, b| a.0.cmp(&b.0));
7441    lines
7442}
7443
7444/// How far apart, in ems of the previous span's size, two quarter-turned
7445/// spans may stand along their shared baseline and still be glyphs of one
7446/// run (#574).
7447///
7448/// A word space is a fraction of an em; a tracked display line, and a word gap
7449/// the producer positioned rather than drew, run wider, to about an em. Two
7450/// ems is past every within-run gap the tuning pages show and short of the
7451/// distance between two runs that share a baseline -- the axis labels of a
7452/// chart, the row labels of a stub -- which stand several ems apart or they
7453/// would read as one line.
7454const ROTATED_RUN_MAX_GAP_EM: f64 = 2.0;
7455
7456/// How wide a gap along a turned baseline must be before the run it sits in
7457/// is merged for the cut's sake (#574).
7458///
7459/// The cut parts a run at a gap on the paragraph-break scale: two words
7460/// 1.2 em apart come back two blocks, reversed for a run that climbs the
7461/// page. A gap of an em or less never does -- the marginal runs that stand
7462/// alone in a margin are drawn one text matrix with uniform advances, a word
7463/// gap the width of a glyph -- so a run whose widest gap is an em, standing
7464/// alone outside any table, is left as drawn: #435 already reads it forwards,
7465/// and merging it buys nothing downstream can use.
7466const LONE_RUN_MAX_GAP_EM: f64 = 1.0;
7467
7468/// One minority run drawn turned on an upright page, as one span, before the
7469/// cut ever sees it (#574).
7470///
7471/// The producers of the defect's pages draw a turned run one glyph per show
7472/// operation, so the device keeps every glyph a span of its own: the page
7473/// space adjacency it merges on never holds across a baseline that stands up
7474/// the page. What #435 assembles afterwards is then unmade twice. The cut
7475/// reads a word gap of the run as a paragraph break and parts the run into
7476/// regions, which the page's top-to-bottom order reads backwards for a run
7477/// that climbs it (`rate interest average Weighted`); and the table grid reads
7478/// a stack of turned heads as rows of one-glyph cells, which the stacked split
7479/// then writes out a letter at a time (`T R O P E R L A U N N A`).
7480///
7481/// So each quarter-turned run is one text item before any grid is built:
7482/// consecutive quarter-turned spans that share an orientation and a baseline,
7483/// within a word gap of each other along it, ordered by their coordinate along
7484/// the baseline. One span cannot be cut in two, banded apart, or gridded a
7485/// glyph at a time, and what the grid and the cells then read is the run whole
7486/// and forwards.
7487///
7488/// The text is assembled by the same code that assembles a line of prose --
7489/// the word spacing, the overprint dedupe, the tracking -- so a run this joins
7490/// reads byte for byte as #435 reads it where the cut leaves it alone. Each
7491/// guard below names what it keeps unchanged:
7492///
7493/// * only quarter-turned spans are candidates ([`span_is_quarter_turned`]:
7494///   within ten degrees of 90, 180 or 270 degrees), so upright text, small
7495///   skews and free-angle regions come back untouched -- the defect class is
7496///   quarter turns only (six reversed runs, all at 90 degrees), and a
7497///   free-angle region already reads forwards (OTC_STKAF p-c3c8);
7498/// * its callers pass only upright pages: in the frame of a turned page the
7499///   minority reads turned, and merging it re-decides a landscape table;
7500/// * a style change still ends the run, the way it ends a span in the device,
7501///   so a bold word inside a quarter-turned run keeps its face;
7502/// * a gap wider than any word gap ends the run, so two runs that share a
7503///   baseline stay two;
7504/// * a gap another quarter-turned run's glyph starts in ends the run too, so
7505///   an axis label's capitals never join across the small digits set between
7506///   them;
7507/// * a quarter-turned span with no partner on its baseline is left exactly as
7508///   it was.
7509///
7510/// A group merges only when the layout would otherwise break it: a gap wide
7511/// enough to part it at the cut, or a sibling quarter-turned run on its row
7512/// the grid would read it with. A run standing lone and tight already reads
7513/// forwards through #435, and merging it only changes the shape downstream
7514/// counts -- thirteen glyph spans read as thirteen lines, one span as one,
7515/// which on one proxy page carries a marginal run to heading level and
7516/// re-decides the page's tables around it. Lone and tight stays as drawn,
7517/// everywhere, including a run held inside a table region's rectangle that
7518/// is margin furniture and not table ink.
7519/// A lone run inside a table region is the accepted residual: the grid slices
7520/// only rows of two or more, so a single turned line beside upright text is
7521/// thin and refused, as it is without this merge; no tuning page shows
7522/// otherwise.
7523fn merge_rotated_runs(spans: Vec<TextSpan>) -> Vec<TextSpan> {
7524    if !spans.iter().any(span_is_quarter_turned) {
7525        return spans;
7526    }
7527    /// A baseline of quarter-turned text taking shape: the direction its
7528    /// members share, the across-coordinate of its first span, and its members
7529    /// in draw order.
7530    struct Baseline {
7531        direction: Direction,
7532        across: f64,
7533        members: Vec<usize>,
7534    }
7535    let mut baselines: Vec<Baseline> = Vec::new();
7536    'spans: for (index, span) in spans.iter().enumerate() {
7537        if !span_is_quarter_turned(span) {
7538            continue;
7539        }
7540        let direction = span_direction(span);
7541        let across = baseline_coordinates(span, direction).1;
7542        // The band tolerance #435 groups a run's own lines by, asked of the
7543        // span joining: sub-point drift in the matrices still joins, and the
7544        // next column's head never does.
7545        let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
7546        for baseline in &mut baselines {
7547            if !directions_agree(baseline.direction, direction)
7548                || (baseline.across - across).abs() > tolerance
7549            {
7550                continue;
7551            }
7552            let first = &spans[baseline.members[0]];
7553            if first.font_name != span.font_name
7554                || first.is_bold != span.is_bold
7555                || first.is_italic != span.is_italic
7556                || first.color != span.color
7557            {
7558                continue;
7559            }
7560            baseline.members.push(index);
7561            continue 'spans;
7562        }
7563        baselines.push(Baseline {
7564            direction,
7565            across,
7566            members: vec![index],
7567        });
7568    }
7569
7570    // A baseline in pieces where another quarter-turned run stands inside
7571    // it: the members of one run are consecutive along it, and a gap another
7572    // run's glyph starts in is not a word gap of this one. A chart's axis label
7573    // reads `W`, `1`, `0`, `Omega`, `Delta` down one baseline with the two
7574    // small digits set in another face between the capitals; joining the
7575    // capitals across the digits parts the label where nothing parts it (#574:
7576    // the label comes back `01` in one block and `Delta Omega W` in another).
7577    // The label's own tight pairs stay whole -- intervention only ever splits.
7578    let mut split: Vec<Baseline> = Vec::with_capacity(baselines.len());
7579    for baseline in baselines {
7580        let mut members = baseline.members;
7581        members.sort_by(|a, b| {
7582            baseline_coordinates(&spans[*a], baseline.direction)
7583                .0
7584                .partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
7585                .unwrap_or(Ordering::Equal)
7586        });
7587        let mut piece = vec![members[0]];
7588        for pair in members.windows(2) {
7589            let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
7590            let end = baseline_end(prev, baseline.direction);
7591            let start = baseline_coordinates(curr, baseline.direction).0;
7592            let intervened = (start > end)
7593                && spans.iter().enumerate().any(|(index, span)| {
7594                    !members.contains(&index)
7595                        && span_is_quarter_turned(span)
7596                        && directions_agree(span_direction(span), baseline.direction)
7597                        && {
7598                            let (along, across) = baseline_coordinates(span, baseline.direction);
7599                            let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
7600                            along > end
7601                                && along < start
7602                                && (baseline.across - across).abs() <= tolerance
7603                        }
7604                });
7605            let size = prev.font_size.max(f64::MIN_POSITIVE);
7606            if start - end > ROTATED_RUN_MAX_GAP_EM * size || intervened {
7607                split.push(Baseline {
7608                    direction: baseline.direction,
7609                    across: baseline.across,
7610                    members: std::mem::take(&mut piece),
7611                });
7612            }
7613            piece.push(pair[1]);
7614        }
7615        split.push(Baseline {
7616            direction: baseline.direction,
7617            across: baseline.across,
7618            members: piece,
7619        });
7620    }
7621    let mut baselines = split;
7622
7623    // Keep the groups the layout would break and let the rest through as
7624    // drawn.
7625    {
7626        // Each group's reading-first baseline and size, for the sibling test
7627        // below. Reading-first is where the grid would row the run: the
7628        // bottom of a run that climbs the page, the top of one that falls.
7629        struct Seat {
7630            baseline: f64,
7631            size: f64,
7632        }
7633        let seats: Vec<Seat> = baselines
7634            .iter()
7635            .map(|baseline| {
7636                let first = baseline
7637                    .members
7638                    .iter()
7639                    .min_by(|a, b| {
7640                        baseline_coordinates(&spans[**a], baseline.direction)
7641                            .0
7642                            .partial_cmp(&baseline_coordinates(&spans[**b], baseline.direction).0)
7643                            .unwrap_or(Ordering::Equal)
7644                    })
7645                    .copied()
7646                    .unwrap_or(0);
7647                Seat {
7648                    baseline: spans[first].y,
7649                    size: baseline
7650                        .members
7651                        .iter()
7652                        .map(|i| spans[*i].font_size)
7653                        .fold(0.0, f64::max),
7654                }
7655            })
7656            .collect();
7657        let mut keep = vec![false; baselines.len()];
7658        for (ordinal, baseline) in baselines.iter().enumerate() {
7659            // A gap on the paragraph-break scale parts the run at the cut,
7660            // and the pieces come back in page order, backwards for a run
7661            // that climbs the page. Past two ems the gap splits the baseline
7662            // into runs below instead, so it is not this trigger: stacked
7663            // labels part where they should.
7664            let mut members = baseline.members.clone();
7665            members.sort_by(|a, b| {
7666                baseline_coordinates(&spans[*a], baseline.direction)
7667                    .0
7668                    .partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
7669                    .unwrap_or(Ordering::Equal)
7670            });
7671            let mut wide = false;
7672            for pair in members.windows(2) {
7673                let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
7674                let gap = baseline_coordinates(curr, baseline.direction).0
7675                    - baseline_end(prev, baseline.direction);
7676                let size = prev.font_size.max(f64::MIN_POSITIVE);
7677                if gap > LONE_RUN_MAX_GAP_EM * size && gap <= ROTATED_RUN_MAX_GAP_EM * size {
7678                    wide = true;
7679                    break;
7680                }
7681            }
7682            if wide {
7683                keep[ordinal] = true;
7684                continue;
7685            }
7686            // A sibling quarter-turned run on the same row is the row the grid
7687            // would read them as, one-glyph cells across: side-by-side heads
7688            // share a reading-first baseline to the point. Parts of one run split
7689            // by a style change never do -- consecutive parts start at least
7690            // an advance apart, past the half em below -- nor do stacked
7691            // labels or runs that read opposite ways, so the lone and the
7692            // stacked stay as drawn.
7693            keep[ordinal] = seats.iter().enumerate().any(|(other, seat)| {
7694                other != ordinal
7695                    && (seats[ordinal].baseline - seat.baseline).abs()
7696                        <= 0.5 * seats[ordinal].size.max(seat.size)
7697            });
7698        }
7699        let mut kept: Vec<Baseline> = Vec::with_capacity(baselines.len());
7700        for (baseline, keep) in baselines.into_iter().zip(keep) {
7701            if keep {
7702                kept.push(baseline);
7703            }
7704        }
7705        baselines = kept;
7706        if baselines.is_empty() {
7707            return spans;
7708        }
7709    }
7710
7711    // Each baseline along itself, in reading order, split into runs where the
7712    // white is wider than any word gap.
7713    let mut runs: Vec<Vec<usize>> = Vec::new();
7714    for baseline in &baselines {
7715        let mut members = baseline.members.clone();
7716        members.sort_by(|a, b| {
7717            baseline_coordinates(&spans[*a], baseline.direction)
7718                .0
7719                .partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
7720                .unwrap_or(Ordering::Equal)
7721        });
7722        let mut run = vec![members[0]];
7723        for pair in members.windows(2) {
7724            let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
7725            let gap = baseline_coordinates(curr, baseline.direction).0
7726                - baseline_end(prev, baseline.direction);
7727            if gap > ROTATED_RUN_MAX_GAP_EM * prev.font_size.max(f64::MIN_POSITIVE) {
7728                runs.push(std::mem::take(&mut run));
7729            }
7730            run.push(pair[1]);
7731        }
7732        runs.push(run);
7733    }
7734
7735    // Each run of two or more as one span, emitted where its first-drawn
7736    // member stood; a quarter-turned span with no partner keeps its own place.
7737    let mut replacement: Vec<Option<TextSpan>> = (0..spans.len()).map(|_| None).collect();
7738    let mut folded = vec![false; spans.len()];
7739    for run in runs.iter().filter(|run| run.len() >= 2) {
7740        let members: Vec<TextSpan> = run.iter().map(|i| spans[*i].clone()).collect();
7741        // The run as #435 would assemble it: its spans in reading order
7742        // through the line assembly, so the string is the one the page holds.
7743        // The bounds below follow the same order the text was joined in.
7744        let text = TextBlock {
7745            spans: members.clone(),
7746        }
7747        .visual_text();
7748        let reading_first = &members[0];
7749        let x = members
7750            .iter()
7751            .map(|span| span.x)
7752            .fold(f64::INFINITY, f64::min);
7753        let y = members
7754            .iter()
7755            .map(|span| span.y)
7756            .fold(f64::INFINITY, f64::min);
7757        let right = members
7758            .iter()
7759            .map(|span| span.measured_right())
7760            .fold(f64::NEG_INFINITY, f64::max);
7761        // Glyph-scale, and deliberately not the envelope: every reader of
7762        // `height` -- the band tolerance, the row pitch -- expects one line's
7763        // worth, and the envelope lives in the bounds, where the boxes are
7764        // actually read from.
7765        let height = members.iter().map(|span| span.height).fold(0.0, f64::max);
7766        let font_size = members
7767            .iter()
7768            .map(|span| span.font_size)
7769            .fold(0.0, f64::max);
7770        let mut char_bounds = Vec::new();
7771        let mut tight_char_bounds = Vec::new();
7772        let mut glyph_advances = Vec::new();
7773        let mut glyph_bounds_sources = Vec::new();
7774        for span in &members {
7775            char_bounds.extend(span.char_bounds.iter().copied());
7776            tight_char_bounds.extend(span.tight_char_bounds.iter().copied());
7777            glyph_advances.extend(span.glyph_advances.iter().copied());
7778            glyph_bounds_sources.extend(span.glyph_bounds_sources.iter().copied());
7779        }
7780        let merged = TextSpan {
7781            text,
7782            x,
7783            y,
7784            width: (right - x).max(0.0),
7785            height,
7786            font_size,
7787            font_name: reading_first.font_name.clone(),
7788            is_bold: reading_first.is_bold,
7789            is_italic: reading_first.is_italic,
7790            color: reading_first.color,
7791            // An estimate anywhere in the run, like the device's own merge.
7792            width_source: if members
7793                .iter()
7794                .any(|span| span.width_source == WidthSource::Estimate)
7795            {
7796                WidthSource::Estimate
7797            } else {
7798                WidthSource::Metric
7799            },
7800            char_bounds,
7801            geometry_mode: reading_first.geometry_mode,
7802            bounds_source: aggregate_bounds_source(&glyph_bounds_sources),
7803            tight_char_bounds,
7804            glyph_advances,
7805            glyph_bounds_sources,
7806            transform: reading_first.transform,
7807            font_weight: reading_first.font_weight,
7808            is_serif: reading_first.is_serif,
7809            is_ocr_layer: members.iter().any(|span| span.is_ocr_layer),
7810            is_monospace: reading_first.is_monospace,
7811            render_mode: reading_first.render_mode,
7812            font_metrics: reading_first.font_metrics,
7813            // The merged span ends where its last-drawn member does (#643).
7814            complete_run: members.last().is_some_and(|span| span.complete_run),
7815        };
7816        let first_drawn = run.iter().copied().min().unwrap_or(0);
7817        for index in run {
7818            folded[*index] = *index != first_drawn;
7819        }
7820        replacement[first_drawn] = Some(merged);
7821    }
7822
7823    let mut out: Vec<TextSpan> = Vec::with_capacity(spans.len());
7824    for (index, span) in spans.into_iter().enumerate() {
7825        if folded[index] {
7826            continue;
7827        }
7828        match replacement[index].take() {
7829            Some(merged) => out.push(merged),
7830            None => out.push(span),
7831        }
7832    }
7833    out
7834}
7835
7836fn xy_cut_recursive(
7837    spans: Vec<TextSpan>,
7838    depth: usize,
7839    stats: &PageStats,
7840    rules: &[HorizontalRule],
7841) -> Vec<Vec<TextBlock>> {
7842    if spans.is_empty() {
7843        return Vec::new();
7844    }
7845    if depth >= XY_CUT_MAX_DEPTH {
7846        return vec![band_based_blocks(spans, stats, rules)];
7847    }
7848
7849    // ANN[r17/TEX2] Pick whichever direction has the largest qualifying
7850    // gap. Always cutting vertically first breaks layouts where a
7851    // footer sits in the mid-x range — it would attach to the left
7852    // column instead of being recognized as a page-level zone. The
7853    // "largest gap wins" rule is the standard XY-Cut tie-breaker used
7854    // by academic OCR literature and matches pdf_oxide.
7855    let vcut = try_vertical_cut(&spans, stats, rules);
7856    let hcut = try_horizontal_cut(&spans, stats);
7857
7858    let (chosen, _) = match (vcut, hcut) {
7859        (Some((v_groups, v_gap)), Some((h_groups, h_gap))) => {
7860            if v_gap >= h_gap {
7861                (Some(v_groups), v_gap)
7862            } else {
7863                (Some(h_groups), h_gap)
7864            }
7865        }
7866        (Some((v_groups, v_gap)), None) => (Some(v_groups), v_gap),
7867        (None, Some((h_groups, h_gap))) => (Some(h_groups), h_gap),
7868        (None, None) => (None, 0.0),
7869    };
7870
7871    if let Some(groups) = chosen {
7872        let mut out = Vec::new();
7873        for group in groups {
7874            out.extend(xy_cut_recursive(group, depth + 1, stats, rules));
7875        }
7876        return out;
7877    }
7878
7879    vec![band_based_blocks(spans, stats, rules)]
7880}
7881
7882/// Emit per-band row blocks without any column detection. Used as the
7883/// leaf of XY-Cut recursion — at this point the region either has no
7884/// further cuts or the density guard refused them.
7885fn band_based_blocks(
7886    spans: Vec<TextSpan>,
7887    stats: &PageStats,
7888    rules: &[HorizontalRule],
7889) -> Vec<TextBlock> {
7890    // XY-Cut can miss recurring gutters when a small number of bands span the
7891    // full page width (e.g. a running header above a 3-column body). In that
7892    // case, fall back to the older band/gutter detector inside the leaf region
7893    // instead of flattening everything row-major.
7894    group_spans_into_blocks_legacy_with_stats(spans, stats, rules)
7895}
7896
7897/// Median font-size helper. Currently unreferenced after `PageStats` took over
7898/// the typography baseline computation; kept available for future tuning paths.
7899#[allow(dead_code)]
7900fn median_font_size(spans: &[TextSpan]) -> f64 {
7901    if spans.is_empty() {
7902        return 12.0;
7903    }
7904    let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
7905    sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
7906    sizes[sizes.len() / 2]
7907}
7908
7909/// Whether span `at` of a band stands in a gutter without being its ink: a
7910/// marker hung into it (one glyph set apart, `hung`), or a word space drawn as
7911/// a run of its own (#574).
7912///
7913/// NYSE_MSA outdents its `t` bullets 10 pt past the left column's measure and
7914/// draws the space after each as a run of its own, so no x on the page is free
7915/// of every span and the two columns were read across, line by line. A run of
7916/// spaces wider than an em is not looked past: a table that fills its row with
7917/// spaces from cell to cell has said the row is one.
7918fn is_marker_or_space(hung: &HashSet<usize>, at: usize, span: &TextSpan) -> bool {
7919    hung.contains(&at)
7920        || (!span.text.chars().any(|c| !c.is_whitespace())
7921            && span.measured_right() - span.x <= span.font_size.max(1.0))
7922}
7923
7924/// Whether both sides of `cut_x` are running text: the median line on each
7925/// side holds more than `RUNNING_LETTERS` letters.
7926///
7927/// Asked of a gutter found only by looking past markers and spaces, because
7928/// the cell column of a table is also a strip of white with a few spans in it
7929/// -- NYSE_JWN's exhibit index, `Filed herewith electronically` down the right
7930/// -- and a cell is short where a column's line runs on.
7931fn both_sides_run_on(bands: &[TextBand], cut_x: f64) -> bool {
7932    let mut left: Vec<usize> = Vec::new();
7933    let mut right: Vec<usize> = Vec::new();
7934    for band in bands {
7935        // A band with ink on a side counts on that side even when its ink
7936        // is all figures: a row of amounts is a row with no letters, and
7937        // left out it would let one long header stand for the whole side
7938        // (codex, #2045).
7939        let (mut l, mut r) = (0usize, 0usize);
7940        let (mut l_ink, mut r_ink) = (false, false);
7941        for span in &band.spans {
7942            let inked = span.text.chars().any(|c| !c.is_whitespace());
7943            // Letters, because a row of amounts is as long as a line of text
7944            // and says nothing a reader would call running on.
7945            let chars = span.text.chars().filter(|c| c.is_alphabetic()).count();
7946            if span.x + (span.measured_right() - span.x) * 0.5 < cut_x {
7947                l += chars;
7948                l_ink |= inked;
7949            } else {
7950                r += chars;
7951                r_ink |= inked;
7952            }
7953        }
7954        if l_ink {
7955            left.push(l);
7956        }
7957        if r_ink {
7958            right.push(r);
7959        }
7960    }
7961    let median = |v: &mut Vec<usize>| {
7962        v.sort_unstable();
7963        v.get(v.len() / 2).copied().unwrap_or(0)
7964    };
7965    median(&mut left) > RUNNING_LETTERS && median(&mut right) > RUNNING_LETTERS
7966}
7967
7968/// Attempt a vertical (column) cut. Returns the span groups plus the
7969/// gap size (in pt) if a suitable gutter is found AND the density +
7970/// alignment guards accept.
7971///
7972/// ANN[r17/TEX2] Three guards together avoid false-positive columns:
7973///   1. `min_gap` is the MAX of (median_font, 10pt) — deliberately
7974///      lower than `median_font * 2` so narrow-gutter academic papers
7975///      (12pt gutters, common in print) are still detected. It does not
7976///      scale with the region: see the note on the constants above.
7977///   2. `columns_are_dense` rejects column splits where either side
7978///      has <2 spans or <8 chars/band — catches table cells.
7979///   3. `columns_are_band_aligned` rejects cuts where any band would
7980///      end up on only one side of the cut while being wider than
7981///      ~70% of that side's column width — catches full-width
7982///      paragraphs (Intro / Outro) that accidentally sit in the
7983///      left-column x-range.
7984fn try_vertical_cut(
7985    spans: &[TextSpan],
7986    stats: &PageStats,
7987    rules: &[HorizontalRule],
7988) -> Option<(Vec<Vec<TextSpan>>, f64)> {
7989    if spans.len() < 2 * XY_CUT_MIN_SPANS_PER_COLUMN {
7990        return None;
7991    }
7992    // A LINE NUMBER DRAWN INTO ITS LINE IS PEELED OFF FIRST (#1973), so that
7993    // the margin scan below can see it for what it is.
7994    let peeled = peel_line_numbers(spans);
7995    let spans: &[TextSpan] = &peeled;
7996
7997    let region_left = spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
7998    let region_right = spans
7999        .iter()
8000        .map(TextSpan::right)
8001        .fold(f64::NEG_INFINITY, f64::max);
8002    let region_width = region_right - region_left;
8003    if region_width <= 0.0 {
8004        return None;
8005    }
8006
8007    // ANN[r17/TEX2][r17/TEX4] Threshold uses the ADAPTIVE median-word-gap
8008    // from the bands rather than a flat font-size multiple. Narrow-gutter
8009    // academic layouts have 12pt gutters next to 4pt word spaces — the
8010    // adaptive threshold scales with the actual typography used on this
8011    // page. Clamped to `XY_CUT_VERTICAL_GAP_FLOOR` to avoid firing on
8012    // ordinary inter-word spaces when character advance data is noisy.
8013    // median_font and the width fraction act only as safety rails for
8014    // pathological inputs.
8015    let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
8016    // THE PAGE'S OWN RULING LINES COME FIRST. A region the document rules into
8017    // rows is a table, and a table read down its columns is the defect this
8018    // guard exists for (#545). Asked here as well as in the leaf below because
8019    // a table with a wide gutter is taken by this cut and never reaches the
8020    // leaf at all.
8021    let all_bands: Vec<usize> = (0..bands.len()).collect();
8022    if region_is_ruled(&bands, &all_bands, rules) {
8023        return None;
8024    }
8025    let adaptive = compute_adaptive_column_gap(&bands);
8026    let floor = stats.median_font_size.max(XY_CUT_VERTICAL_GAP_FLOOR);
8027    let min_gap = adaptive.min(floor).max(XY_CUT_VERTICAL_GAP_FLOOR);
8028
8029    // Intervals [x_left, x_right] of every span; we look for an x value
8030    // that is free of ALL intervals (full-height gap).
8031    // MEASURED EDGES ONLY. `right()` returns x + width.max(estimated_width()),
8032    // and the estimate is a character count times a nominal advance -- on
8033    // tracemonkey's abstract it overshoots the measured edge by 61pt, which is
8034    // wider than the gutter it then covers. A gap scan that asks "is any x free
8035    // of every span" cannot use an edge that is deliberately generous.
8036    // Unmeasured spans (width 0) keep the estimate, since a point-wide interval
8037    // would open a gap where there is text.
8038    //
8039    // AND THE MARGIN COLUMN IS NOT INK FOR THIS PURPOSE (#582). A statute and a
8040    // US patent print their line numbers down the middle of the page, INSIDE
8041    // the gutter, so no x is free of every span and the cut is unfindable at
8042    // any depth -- the page comes back read line by line across both columns.
8043    // `a_margin_column` is what those figures are and a word is not. The
8044    // intervals are taken from the bands rather than from the region's own
8045    // slice, because the set that names the spans to look past is indexed on
8046    // each band's spans and two numberings of one set of spans is how an index
8047    // goes wrong.
8048    let word_space = (adaptive / COLUMN_GAP_MEDIAN_MULTIPLIER).max(0.5);
8049    let (mut skipped, figures) = a_margin_column(&bands, region_width, min_gap, word_space);
8050    // A BULLET HUNG INTO THE GUTTER IS NOT THE GUTTER'S INK (#574). A column
8051    // of items outdents its markers to the left of its text, and on a page
8052    // set with a narrow gutter they stand in it: NYSE_MSA's `t` bullets sit
8053    // 10 pt from the left column's measure where the columns' text is 18 pt
8054    // apart, and the page was read across both columns line by line. A glyph
8055    // set apart from the rest of its band -- one character, then more white
8056    // than a word space -- is looked past here and still goes with the column
8057    // its midpoint falls in.
8058    let hung: Vec<HashSet<usize>> = bands
8059        .iter()
8060        .map(|row| {
8061            (0..row.spans.len())
8062                .filter(|&at| {
8063                    let span = &row.spans[at];
8064                    // LEADING, with white before it as well as after: the
8065                    // last glyph of a left-column line, `)` in a run of its
8066                    // own, has its line's text right against it (codex,
8067                    // #2041).
8068                    let apart_before = row.spans[..at]
8069                        .iter()
8070                        .rev()
8071                        .find(|prev| prev.text.chars().any(|c| !c.is_whitespace()))
8072                        .is_none_or(|prev| {
8073                            span.x - prev.measured_right()
8074                                >= HUNG_MARKER_GAP_EM * span.font_size.max(1.0)
8075                        });
8076                    span.text.chars().filter(|c| !c.is_whitespace()).count() == 1
8077                        && apart_before
8078                        && row.spans[at + 1..]
8079                            .iter()
8080                            .find(|next| next.text.chars().any(|c| !c.is_whitespace()))
8081                            .is_some_and(|next| {
8082                                next.x - span.measured_right()
8083                                    >= HUNG_MARKER_GAP_EM * span.font_size.max(1.0)
8084                            })
8085                })
8086                .collect()
8087        })
8088        .collect();
8089    let scan =
8090        |skipped: &[HashSet<usize>], from: usize, past_markers: bool| -> Option<(f64, f64)> {
8091            let mut intervals: Vec<(f64, f64)> = bands
8092                .iter()
8093                .enumerate()
8094                .skip(from)
8095                .flat_map(|(band, row)| {
8096                    let skipped = &skipped[band];
8097                    let hung = &hung[band];
8098                    row.spans
8099                        .iter()
8100                        .enumerate()
8101                        .filter(move |(at, s)| {
8102                            !skipped.contains(at)
8103                                && (!past_markers || !is_marker_or_space(hung, *at, s))
8104                        })
8105                        .map(|(_, s)| {
8106                            let r = if s.width > 0.0 {
8107                                s.measured_right()
8108                            } else {
8109                                s.right()
8110                            };
8111                            (s.x, r.max(s.x + 0.001))
8112                        })
8113                })
8114                .collect();
8115            intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
8116            let mut cursor = intervals.first()?.1;
8117            let mut best: Option<(f64, f64)> = None; // (gap_size, cut_x)
8118            for (left, right) in intervals.iter().skip(1) {
8119                if *left > cursor {
8120                    let gap = *left - cursor;
8121                    if gap >= min_gap && !matches!(best, Some((b, _)) if b >= gap) {
8122                        best = Some((gap, (cursor + *left) * 0.5));
8123                    }
8124                }
8125                cursor = cursor.max(*right);
8126            }
8127            best
8128        };
8129    // A RUNNING HEAD ACROSS THE GUTTER IS NOT THE PAGE (#1973). A US patent
8130    // centres `US 7,268,176 B2` over the gutter and sets its column numbers a
8131    // line under it, close enough to the body that no horizontal cut takes them
8132    // off first -- so the head alone leaves no x free, and the page was read
8133    // across both columns. Where the gutter is found once the top band or two
8134    // are left out, and those bands are a line of a few runs each, they are a
8135    // group of their own and read first.
8136    //
8137    // ONLY ON A PAGE THAT NUMBERS ITS LINES IN THE GUTTER, which is the page
8138    // this is for. Anywhere else a centred title over a table is the same
8139    // shape -- `SCHEDULE 3` over the two columns of a statute's schedule, a
8140    // heading over a reconciliation table -- and setting it aside handed the
8141    // table to the column cut and read it down its columns (validation,
8142    // uksi_20200121 0.939 -> 0.627, OTC_SDXHF 0.774 -> 0.455).
8143    let mut head = 0;
8144    let mut best_gap = scan(&skipped, 0, false);
8145    // Numbered BY A COLUMN OF FIGURES, as the margin scan found it: every
8146    // member it confirmed on its own band is a figure. The scan also looks past
8147    // a column of tab runs down a financial table (a 10-K, 0.641 -> 0.535) and
8148    // a list's markers, and one digit among `i`, `ii` is not a numbered page
8149    // (codex, #1991).
8150    let numbered = figures && skipped.iter().any(|band| !band.is_empty());
8151    if best_gap.is_none() && numbered {
8152        for drop in 1..=HEAD_BANDS_MAX.min(bands.len().saturating_sub(2)) {
8153            if bands[drop - 1].spans.len() > HEAD_BAND_MAX_SPANS {
8154                break;
8155            }
8156            if let Some(found) = scan(&skipped, drop, false) {
8157                best_gap = Some(found);
8158                head = drop;
8159                break;
8160            }
8161        }
8162    }
8163
8164    // ONLY WHERE NOTHING ELSE OPENS A GUTTER is it looked for past the
8165    // markers and word spaces standing in it, and what it then separates has
8166    // to be running text on both sides -- see `is_marker_or_space`.
8167    let mut past_markers = false;
8168    if best_gap.is_none() {
8169        best_gap = scan(&skipped, 0, true);
8170        past_markers = best_gap.is_some();
8171    }
8172    let (gap_size, cut_x) = best_gap?;
8173    // A marker looked past belongs to the column it hangs before, so the cut
8174    // goes just left of the first one standing in the gap.
8175    let cut_x = if past_markers {
8176        bands
8177            .iter()
8178            .zip(&hung)
8179            .flat_map(|(row, hung)| {
8180                row.spans
8181                    .iter()
8182                    .enumerate()
8183                    .filter(|(at, s)| is_marker_or_space(hung, *at, s))
8184                    .map(|(_, s)| s.x)
8185                    .collect::<Vec<_>>()
8186            })
8187            .filter(|x| (x - cut_x).abs() < gap_size * 0.5)
8188            .fold(cut_x, f64::min)
8189            - 0.01
8190    } else {
8191        cut_x
8192    };
8193
8194    // AND THE SKIP HAS TO HAVE BEEN WHAT WAS IN THE WAY. A thin column
8195    // somewhere ELSE on the page -- the hanging figures of a bibliography at
8196    // the left edge of each column, which are narrow, short and at one x on
8197    // every band -- is not a reason to believe in a gutter it does not stand
8198    // in. Where the column the scan looked past is not inside the gap that was
8199    // found, the scan is run again with nothing skipped and THAT cut is the
8200    // answer: refusing instead would take the columns off a page that has a
8201    // perfectly good gutter, which cost `tracemonkey.pdf` page 14 its reading
8202    // order, 100.0 to 74.5, where both foreign readers agree at 100.
8203    let margin_of = |sets: &[HashSet<usize>]| -> Vec<TextSpan> {
8204        bands
8205            .iter()
8206            .enumerate()
8207            .flat_map(|(band, row)| {
8208                let set = &sets[band];
8209                row.spans
8210                    .iter()
8211                    .enumerate()
8212                    .filter(move |(at, _)| set.contains(at))
8213                    .map(|(_, s)| s.clone())
8214            })
8215            .collect()
8216    };
8217    let mut margin = margin_of(&skipped);
8218    let inside = |spans: &[TextSpan], cut_x: f64, gap: f64| {
8219        spans
8220            .iter()
8221            .all(|s| s.x >= cut_x - gap && s.measured_right() <= cut_x + gap)
8222    };
8223    let (gap_size, cut_x) = if margin.is_empty() || inside(&margin, cut_x, gap_size) {
8224        (gap_size, cut_x)
8225    } else {
8226        skipped = vec![HashSet::new(); bands.len()];
8227        margin.clear();
8228        scan(&skipped, head, past_markers)?
8229    };
8230    let head_spans: Vec<TextSpan> = bands[..head].iter().flat_map(|b| b.spans.clone()).collect();
8231    let in_margin: HashSet<(u64, u64)> = margin
8232        .iter()
8233        .chain(&head_spans)
8234        .map(|s| (s.x.to_bits(), s.y.to_bits()))
8235        .collect();
8236
8237    // Split spans around the cut. A span whose midpoint is < cut_x belongs to
8238    // the left group; the margin column is a group of its OWN, so the line
8239    // numbers come out as their own block instead of inside the running text
8240    // of whichever column they happened to fall on.
8241    let mut left_group = Vec::new();
8242    let mut right_group = Vec::new();
8243    for span in spans {
8244        if in_margin.contains(&(span.x.to_bits(), span.y.to_bits())) {
8245            continue;
8246        }
8247        let midpoint = span.x + (span.right() - span.x) * 0.5;
8248        if midpoint < cut_x {
8249            left_group.push(span.clone());
8250        } else {
8251            right_group.push(span.clone());
8252        }
8253    }
8254
8255    if !columns_are_dense(&left_group, &right_group, stats) {
8256        return None;
8257    }
8258    // WITHOUT THE MARGIN COLUMN, for the reason the gap scan is run without it:
8259    // a band that carries a line number would otherwise reach across the cut
8260    // by the width of two figures and be read as a band sitting on one side.
8261    let aligned: Vec<TextSpan> = if in_margin.is_empty() {
8262        spans.to_vec()
8263    } else {
8264        spans
8265            .iter()
8266            .filter(|s| !in_margin.contains(&(s.x.to_bits(), s.y.to_bits())))
8267            .cloned()
8268            .collect()
8269    };
8270    let aligned: Vec<TextSpan> = if past_markers {
8271        aligned
8272            .into_iter()
8273            .filter(|s| s.text.chars().filter(|c| !c.is_whitespace()).count() > 1)
8274            .collect()
8275    } else {
8276        aligned
8277    };
8278    if past_markers && !both_sides_run_on(&bands[head..], cut_x) {
8279        return None;
8280    }
8281    if !columns_are_band_aligned(&aligned, cut_x, region_left, region_right, stats) {
8282        return None;
8283    }
8284    if a_column_of_labels(&bands[head..], cut_x, region_left, region_right, min_gap) {
8285        return None;
8286    }
8287    // THE SPANS THE SCAN SKIPPED REACH THE GUARD, and this is the caller the
8288    // parameter was added for. Counted as ink, a column of line numbers beside
8289    // the gutter puts a wide strip of white between itself and the rest of its
8290    // side on nearly every band, which reads as a bank's own cell boundary --
8291    // and the guard would refuse exactly the cut that skipping them was meant
8292    // to allow (#582, on the parameter #580 landed).
8293    if the_cut_is_a_column_of_a_table(&bands[head..], cut_x, gap_size, &skipped[head..]) {
8294        return None;
8295    }
8296
8297    let mut groups = Vec::new();
8298    if !head_spans.is_empty() {
8299        groups.push(head_spans);
8300    }
8301    groups.push(left_group);
8302    groups.push(right_group);
8303    if !margin.is_empty() {
8304        groups.push(margin);
8305    }
8306    Some((groups, gap_size))
8307}
8308
8309/// A line number drawn into the line beside it, split off as a span of its own
8310/// (#1973).
8311///
8312/// `a_margin_column` looks past a span that IS a line number. An OCR text layer
8313/// -- every US patent of DocLayNet's validation half set in two columns, 45 of
8314/// their 65 pages -- draws each right-column line as one object that opens in
8315/// the gutter with the line's number, so `10 example, aluminum and steel` is
8316/// one span from the gutter to the margin: a number and a line, which that
8317/// scan cannot look past, and the page was read across both columns. On the
8318/// rig that was one document, 9840656_AdobeOCR, and the whole of the text gap
8319/// to the reference: 0.340 against 0.886, 0.050 of the mean over eleven.
8320///
8321/// THE GEOMETRY DECIDES, as it does for the margin column: the head of the
8322/// span is one to three figures, then white, and the rest of the span starts
8323/// on an edge at least [`MARGIN_COLUMN_MIN_BANDS`] other spans start on --
8324/// the column's own left edge -- while the figures stand left of it. A line
8325/// that opens with a number at the column's edge (`10 people attended`) does
8326/// not stick out and is left whole; a figure that sticks out of a column
8327/// nothing else starts on is not peeled either. Whether the peeled figure is
8328/// then a margin column is `a_margin_column`'s question, asked as before.
8329fn peel_line_numbers(spans: &[TextSpan]) -> Vec<TextSpan> {
8330    let mut out = Vec::with_capacity(spans.len());
8331    for (at, span) in spans.iter().enumerate() {
8332        match peel_one(span, at, spans) {
8333            Some((figure, rest)) => {
8334                out.push(figure);
8335                out.push(rest);
8336            }
8337            None => match peel_trailing(span, at, spans) {
8338                Some((rest, figure)) => {
8339                    out.push(rest);
8340                    out.push(figure);
8341                }
8342                None => out.push(span.clone()),
8343            },
8344        }
8345    }
8346    out
8347}
8348
8349fn peel_one(span: &TextSpan, at: usize, spans: &[TextSpan]) -> Option<(TextSpan, TextSpan)> {
8350    let digits = span.text.chars().take_while(char::is_ascii_digit).count();
8351    if !(1..=3).contains(&digits) || !span.text[digits..].starts_with(' ') {
8352        return None;
8353    }
8354    let bounds = &span.char_bounds;
8355    if bounds.len() <= digits {
8356        return None;
8357    }
8358    let size = span.font_size.max(1.0);
8359    let figure_right = bounds[digits - 1][2];
8360    let clear = size * MARGIN_COLUMN_MIN_CLEARANCE_EM;
8361    // The rest opens at the first glyph clear of the figures: a drawn space
8362    // between them is a glyph of its own and belongs to neither.
8363    let first = (digits..bounds.len()).find(|&k| bounds[k][0] >= figure_right + clear)?;
8364    let rest_x = bounds[first][0];
8365    let tolerance = size * PEEL_EDGE_EM;
8366    if span.x >= rest_x - tolerance {
8367        return None;
8368    }
8369    let on_edge = spans
8370        .iter()
8371        .enumerate()
8372        .filter(|(other, s)| *other != at && (s.x - rest_x).abs() <= tolerance)
8373        .count();
8374    if on_edge < MARGIN_COLUMN_MIN_BANDS {
8375        return None;
8376    }
8377    let rest_text = span.text[digits..].trim_start();
8378    if rest_text.is_empty() {
8379        return None;
8380    }
8381    let cut = |v: &[[f64; 4]], lo: usize, hi: usize| {
8382        if v.len() == bounds.len() {
8383            v[lo..hi].to_vec()
8384        } else {
8385            Vec::new()
8386        }
8387    };
8388    let right = span.measured_right();
8389    let mut figure = span.clone();
8390    figure.text = span.text[..digits].to_string();
8391    figure.width = figure_right - span.x;
8392    figure.char_bounds = bounds[..digits].to_vec();
8393    figure.tight_char_bounds = cut(&span.tight_char_bounds, 0, digits);
8394    let mut rest = span.clone();
8395    rest.text = rest_text.to_string();
8396    rest.x = rest_x;
8397    rest.width = (right - rest_x).max(0.0);
8398    rest.char_bounds = bounds[first..].to_vec();
8399    // THE ORIGIN MOVES WITH THE SPAN (codex, #1991): `transform` locates the
8400    // span's first glyph, and the rest's first glyph is `first`, not the
8401    // figure it was drawn after.
8402    if let Some(t) = rest.transform.as_mut() {
8403        t[4] += bounds[first][0] - bounds[0][0];
8404        t[5] += bounds[first][1] - bounds[0][1];
8405    }
8406    rest.tight_char_bounds = cut(&span.tight_char_bounds, first, bounds.len());
8407    // The rich-geometry vectors run glyph for glyph beside the tight boxes;
8408    // split where they line up, dropped where they do not.
8409    if span.glyph_advances.len() == bounds.len() {
8410        figure.glyph_advances = span.glyph_advances[..digits].to_vec();
8411        rest.glyph_advances = span.glyph_advances[first..].to_vec();
8412    } else {
8413        figure.glyph_advances.clear();
8414        rest.glyph_advances.clear();
8415    }
8416    if span.glyph_bounds_sources.len() == bounds.len() {
8417        figure.glyph_bounds_sources = span.glyph_bounds_sources[..digits].to_vec();
8418        rest.glyph_bounds_sources = span.glyph_bounds_sources[first..].to_vec();
8419    } else {
8420        figure.glyph_bounds_sources.clear();
8421        rest.glyph_bounds_sources.clear();
8422    }
8423    Some((figure, rest))
8424}
8425
8426/// A line number drawn at the END of the line before it, the mirror of
8427/// [`peel_one`] (#574). A US patent prints its line numbers in the gutter, and
8428/// where a left-column line runs close to them the OCR layer's words join the
8429/// figure into the line's run: `market, citric acid and/or 35` (8287919)
8430/// ended nine points into a gutter of fourteen, and the page was read across
8431/// both columns line by line. Peeled the same way: one to three figures at the
8432/// end, white before them, and the rest ending on an edge at least
8433/// [`MARGIN_COLUMN_MIN_BANDS`] other spans end on -- the column's own right
8434/// edge -- with the figures standing right of it.
8435///
8436/// Counted from the END, because that is where the glyphs and the text agree:
8437/// a space the device inserts between two glyphs has no box, so the boxes and
8438/// the characters are out of step from the first inserted space on, but a
8439/// space is only ever inserted BEFORE a glyph, so whatever ends the text was
8440/// drawn.
8441fn peel_trailing(span: &TextSpan, at: usize, spans: &[TextSpan]) -> Option<(TextSpan, TextSpan)> {
8442    let body = span.text.trim_end();
8443    let tail_ws = span.text[body.len()..].chars().count();
8444    let digits = body.chars().rev().take_while(char::is_ascii_digit).count();
8445    if !(1..=3).contains(&digits) || !body[..body.len() - digits].ends_with(' ') {
8446        return None;
8447    }
8448    let rest_text = body[..body.len() - digits].trim_end();
8449    if rest_text.is_empty() {
8450        return None;
8451    }
8452    let bounds = &span.char_bounds;
8453    if bounds.len() <= tail_ws + digits {
8454        return None;
8455    }
8456    let figure_end = bounds.len() - tail_ws;
8457    let figure_start = figure_end - digits;
8458    let size = span.font_size.max(1.0);
8459    let figure_left = bounds[figure_start][0];
8460    let clear = size * MARGIN_COLUMN_MIN_CLEARANCE_EM;
8461    // The rest ends at the last glyph clear of the figures: a drawn space
8462    // between them reaches up to the figure and belongs to neither.
8463    let last = (0..figure_start)
8464        .rev()
8465        .find(|&k| bounds[k][2] <= figure_left - clear)?;
8466    let rest_right = bounds[last][2];
8467    let tolerance = size * PEEL_EDGE_EM;
8468    let figure_right = bounds[figure_end - 1][2];
8469    if figure_right <= rest_right + tolerance {
8470        return None;
8471    }
8472    let on_edge = spans
8473        .iter()
8474        .enumerate()
8475        .filter(|(other, s)| *other != at && (s.measured_right() - rest_right).abs() <= tolerance)
8476        .count();
8477    if on_edge < MARGIN_COLUMN_MIN_BANDS {
8478        return None;
8479    }
8480    let cut = |v: &[[f64; 4]], lo: usize, hi: usize| {
8481        if v.len() == bounds.len() {
8482            v[lo..hi].to_vec()
8483        } else {
8484            Vec::new()
8485        }
8486    };
8487    let mut rest = span.clone();
8488    rest.text = rest_text.to_string();
8489    rest.width = (rest_right - span.x).max(0.0);
8490    rest.char_bounds = bounds[..=last].to_vec();
8491    rest.tight_char_bounds = cut(&span.tight_char_bounds, 0, last + 1);
8492    let mut figure = span.clone();
8493    figure.text = body[body.len() - digits..].to_string();
8494    figure.x = figure_left;
8495    figure.width = (figure_right - figure_left).max(0.0);
8496    figure.char_bounds = bounds[figure_start..figure_end].to_vec();
8497    figure.tight_char_bounds = cut(&span.tight_char_bounds, figure_start, figure_end);
8498    // The origin moves with the figure, as it does with a peeled rest.
8499    if let Some(t) = figure.transform.as_mut() {
8500        t[4] += bounds[figure_start][0] - bounds[0][0];
8501        t[5] += bounds[figure_start][1] - bounds[0][1];
8502    }
8503    if span.glyph_advances.len() == bounds.len() {
8504        rest.glyph_advances = span.glyph_advances[..=last].to_vec();
8505        figure.glyph_advances = span.glyph_advances[figure_start..figure_end].to_vec();
8506    } else {
8507        rest.glyph_advances.clear();
8508        figure.glyph_advances.clear();
8509    }
8510    if span.glyph_bounds_sources.len() == bounds.len() {
8511        rest.glyph_bounds_sources = span.glyph_bounds_sources[..=last].to_vec();
8512        figure.glyph_bounds_sources = span.glyph_bounds_sources[figure_start..figure_end].to_vec();
8513    } else {
8514        rest.glyph_bounds_sources.clear();
8515        figure.glyph_bounds_sources.clear();
8516    }
8517    Some((rest, figure))
8518}
8519
8520/// Whether a column of candidates is a count of the page's lines: every member
8521/// a whole number, rising strictly from the top band down, all of them multiples
8522/// of one step of at least two -- `5`, `10`, `15`, with the members the scan did
8523/// not confirm simply missing (#574).
8524fn counts_the_lines(column: &[(usize, usize, f64)], bands: &[TextBand]) -> bool {
8525    let mut members: Vec<(usize, u32)> = Vec::with_capacity(column.len());
8526    for &(band, at, _) in column {
8527        // One to three figures, as a printed line number is. Nothing earlier
8528        // limits a candidate's length, and a column of years rises by a common
8529        // step as well: `2018`, `2020`, `2022` (codex, #2056).
8530        let text = bands[band].spans[at].text.trim();
8531        if text.chars().count() > MARGIN_FIGURE_MAX_CHARS {
8532            return false;
8533        }
8534        match text.parse::<u32>() {
8535            Ok(value) => members.push((band, value)),
8536            Err(_) => return false,
8537        }
8538    }
8539    if members.len() < MARGIN_COLUMN_MIN_BANDS {
8540        return false;
8541    }
8542    members.sort_unstable();
8543    let rising = members
8544        .windows(2)
8545        .all(|pair| pair[0].0 < pair[1].0 && pair[0].1 < pair[1].1);
8546    let step = members.iter().fold(0, |g, &(_, v)| gcd(g, v));
8547    rising && step >= 2
8548}
8549
8550fn gcd(a: u32, b: u32) -> u32 {
8551    if b == 0 {
8552        a
8553    } else {
8554        gcd(b, a % b)
8555    }
8556}
8557
8558/// How many bands at the top of a region may be set aside as its running head
8559/// when they are what fills the gutter (#1973): the head and the line of
8560/// column numbers a patent sets under it.
8561const HEAD_BANDS_MAX: usize = 2;
8562
8563/// The most runs a band set aside as a head may hold. A running head is a line
8564/// or two of a few runs; a band of more is a line of the page's own text, and
8565/// a region whose own first line crosses the gutter is not two columns.
8566const HEAD_BAND_MAX_SPANS: usize = 3;
8567
8568/// How near an edge a span has to start to be on it, in ems of the peeled
8569/// span's type: half an em, well inside a gutter and well over the jitter of
8570/// an OCR layer's left edges (a fifth of a point on the pages it was built for).
8571const PEEL_EDGE_EM: f64 = 0.5;
8572
8573/// Whether a run is a figure and nothing else, as a printed line number is.
8574fn only_figures(text: &str) -> bool {
8575    let t = text.trim();
8576    !t.is_empty() && t.chars().all(|c| c.is_ascii_digit())
8577}
8578
8579/// The most characters a member of a margin column may carry when it is taken
8580/// in by the column rather than by its own band (#1973): a line number is
8581/// three figures at most.
8582const MARGIN_FIGURE_MAX_CHARS: usize = 3;
8583
8584/// How wide a margin column may be, as a fraction of the region it stands in.
8585///
8586/// A tenth. A line number is two or three figures against a measure of sixty
8587/// characters; a column of a table is a third of its row, and this is what
8588/// refuses it before any of the other conditions is asked.
8589const MARGIN_COLUMN_MAX_WIDTH_FRACTION: f64 = 0.10;
8590
8591/// How much of its own band's text a margin column may carry.
8592///
8593/// COUNTED IN CHARACTERS AND NOT IN WIDTH, so that a proportional face does
8594/// not decide it: `10` beside a line of prose is two characters of sixty.
8595const MARGIN_COLUMN_MAX_CHAR_SHARE: f64 = 0.15;
8596
8597/// How much white a margin column needs on each side, in ems of its own type.
8598///
8599/// A fifth: under a word space in any face and over the fit between letters.
8600const MARGIN_COLUMN_MIN_CLEARANCE_EM: f64 = 0.2;
8601
8602/// How many bands a margin column has to stand on before it is one.
8603///
8604/// Three, for the reason the running-head rule of #571 asks for three pages: a
8605/// pair of short runs at one x is a coincidence a page cannot rule out.
8606const MARGIN_COLUMN_MIN_BANDS: usize = 3;
8607
8608/// The spans the gap scan may look past, per band (#582).
8609///
8610/// A statute and a US patent print their line numbers down the middle of the
8611/// page, INSIDE the gutter. The scan asks for an x free of every span, so such
8612/// a page has no free x at any depth and comes back read line by line across
8613/// both columns -- the whole of what the three patent pages of run
8614/// `structured-doclaynet-20260922T081244Z` lost, 1.227 of the 4.888 the
8615/// interleave class cost.
8616///
8617/// FOUR CONDITIONS, AND NONE OF THEM ABOUT WHAT THE SPAN SAYS. A rule that
8618/// read the text would fire on a verse number and on a marginal note, and
8619/// would have to be taught every language's figures:
8620///
8621/// 1. WHITE ON BOTH SIDES within its own band, measured against its OWN type
8622///    rather than against the gutter: a margin column stands inside a gutter
8623///    of twelve points and has three or four either side, so a threshold taken
8624///    from the gutter's width would refuse every real one.
8625/// 2. NARROW -- at most [`MARGIN_COLUMN_MAX_WIDTH_FRACTION`] of the region.
8626/// 3. A NEGLIGIBLE SHARE OF ITS BAND'S CHARACTERS.
8627/// 4. A COLUMN, AND EXACTLY ONE: the candidates stand at nearly the same x on
8628///    at least [`MARGIN_COLUMN_MIN_BANDS`] bands, and where two such columns
8629///    are found NOTHING is skipped.
8630///
8631/// The second and the fourth are what keep a table of figures out, and they
8632/// were asked for rather than assumed (T2, #577/#580): every cell of a bank of
8633/// figures has white on both sides, so the first condition alone would make
8634/// the boundary INSIDE a table findable and undo what #577 took out. A cell is
8635/// a third of its row, which the second refuses; and a table has several such
8636/// columns, which the fourth refuses.
8637/// Returns the spans to look past, one set per band, and whether the column
8638/// they form is FIGURES in every member its own bands found -- the evidence a
8639/// caller needs before it treats the page as one that numbers its lines
8640/// (codex, #1991): a column of `i`, `1`, `ii` is a list's markers.
8641fn a_margin_column(
8642    bands: &[TextBand],
8643    region_width: f64,
8644    min_gap: f64,
8645    word_space: f64,
8646) -> (Vec<HashSet<usize>>, bool) {
8647    let mut out: Vec<HashSet<usize>> = vec![HashSet::new(); bands.len()];
8648    if bands.len() < MARGIN_COLUMN_MIN_BANDS || region_width <= 0.0 {
8649        return (out, false);
8650    }
8651    let mut candidates: Vec<(usize, usize, f64)> = Vec::new();
8652    for (band, row) in bands.iter().enumerate() {
8653        let letters: usize = row.spans.iter().map(|s| s.text.chars().count()).sum();
8654        if letters == 0 {
8655            continue;
8656        }
8657        for (at, span) in row.spans.iter().enumerate() {
8658            let right = span.measured_right().max(span.x);
8659            if right - span.x > MARGIN_COLUMN_MAX_WIDTH_FRACTION * region_width {
8660                continue;
8661            }
8662            if span.text.chars().count() as f64 > MARGIN_COLUMN_MAX_CHAR_SHARE * letters as f64 {
8663                continue;
8664            }
8665            let before = row
8666                .spans
8667                .iter()
8668                .filter(|other| other.measured_right() <= span.x)
8669                .map(TextSpan::measured_right)
8670                .fold(f64::NEG_INFINITY, f64::max);
8671            let after = row
8672                .spans
8673                .iter()
8674                .filter(|other| other.x >= right)
8675                .map(|other| other.x)
8676                .fold(f64::INFINITY, f64::min);
8677            if !before.is_finite() || !after.is_finite() {
8678                continue;
8679            }
8680            let clear = (span.font_size * MARGIN_COLUMN_MIN_CLEARANCE_EM).max(word_space.min(1.0));
8681            if span.x - before <= clear || after - right <= clear {
8682                continue;
8683            }
8684            candidates.push((band, at, span.x + (right - span.x) * 0.5));
8685        }
8686    }
8687    if candidates.len() < MARGIN_COLUMN_MIN_BANDS {
8688        return (out, false);
8689    }
8690    candidates.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(Ordering::Equal));
8691    let mut columns: Vec<Vec<(usize, usize, f64)>> = Vec::new();
8692    for candidate in candidates {
8693        match columns.last_mut() {
8694            Some(column) if candidate.2 - column[column.len() - 1].2 <= min_gap => {
8695                column.push(candidate)
8696            }
8697            _ => columns.push(vec![candidate]),
8698        }
8699    }
8700    let mut found: Vec<&Vec<(usize, usize, f64)>> = columns
8701        .iter()
8702        .filter(|column| {
8703            let mut on: Vec<usize> = column.iter().map(|c| c.0).collect();
8704            on.sort_unstable();
8705            on.dedup();
8706            on.len() >= MARGIN_COLUMN_MIN_BANDS
8707        })
8708        .collect();
8709    // EXACTLY ONE. Two of them are the banks of a table, not a margin.
8710    //
8711    // UNLESS ONE OF THEM COUNTS THE PAGE'S LINES (#574). A US patent with a
8712    // table on it has the table's banks AND its gutter's line numbers, and the
8713    // rule above refused them all, so the page was read across both columns:
8714    // `6420562_AdobeOCR` p. 53, a bank of `0.0`, `4.0`, `64.9` beside the
8715    // gutter's `5`, `10` ... `65`. What a line-number column has and a bank of
8716    // figures does not is a count: whole numbers rising down the page by a
8717    // common step. A table's row numbers rise by one and are refused by the
8718    // step; a column of years has four figures and is refused by its width.
8719    if found.len() > 1 {
8720        found.retain(|column| counts_the_lines(column, bands));
8721    }
8722    if found.len() != 1 {
8723        return (out, false);
8724    }
8725    let column = found.pop().expect("one column");
8726    for (band, at, _) in column.iter() {
8727        out[*band].insert(*at);
8728    }
8729    // AND ITS MEMBERS THAT STAND BESIDE NO LINE (#1973). A line number beside
8730    // the first line of a paragraph in the right column, where the left
8731    // column's paragraph has just ended, has white after it and nothing before
8732    // it -- the first clause asks for both, and the figure stayed in the
8733    // gutter as ink. Once the column is found by the members that do have both,
8734    // a figure at the same x with white after it is one of them.
8735    //
8736    // A COLUMN OF FIGURES ONLY, where every member found is one. The same shape
8737    // with nothing before it is also the marker of a list hung out of its
8738    // column -- `i`, `ii`, `iii` down the left of a reconciliation table --
8739    // and taken into the column those markers let the cut through the table
8740    // (validation, OTC_SDXHF 0.774 -> 0.455). A column of line numbers says
8741    // what it is in every member it has.
8742    if !column
8743        .iter()
8744        .all(|&(band, at, _)| only_figures(&bands[band].spans[at].text))
8745    {
8746        return (out, false);
8747    }
8748    let centre = column.iter().map(|c| c.2).sum::<f64>() / column.len() as f64;
8749    for (band, row) in bands.iter().enumerate() {
8750        let letters: usize = row.spans.iter().map(|s| s.text.chars().count()).sum();
8751        for (at, span) in row.spans.iter().enumerate() {
8752            // Three characters at most, and not necessarily figures: the OCR
8753            // layer that draws these reads a `5` as an `s` often enough, and a
8754            // column that is figures in every member it was found by has said
8755            // what it holds (9017715_AdobeOCR, `hormone- s dependent`).
8756            if out[band].contains(&at) || span.text.trim().chars().count() > MARGIN_FIGURE_MAX_CHARS
8757            {
8758                continue;
8759            }
8760            let right = span.measured_right().max(span.x);
8761            if (span.x + (right - span.x) * 0.5 - centre).abs() > min_gap
8762                || right - span.x > MARGIN_COLUMN_MAX_WIDTH_FRACTION * region_width
8763                || span.text.chars().count() as f64 > MARGIN_COLUMN_MAX_CHAR_SHARE * letters as f64
8764            {
8765                continue;
8766            }
8767            let after = row
8768                .spans
8769                .iter()
8770                .filter(|other| other.x >= right)
8771                .map(|other| other.x)
8772                .fold(f64::INFINITY, f64::min);
8773            let clear = (span.font_size * MARGIN_COLUMN_MIN_CLEARANCE_EM).max(word_space.min(1.0));
8774            // AND WITH NOTHING AFTER IT (#574): where the right column has no
8775            // line -- a paragraph ends, a table stands in the other column --
8776            // the figure has the left column's line before it and white to the
8777            // page's edge. White before it is then what says it stands apart.
8778            let before = row
8779                .spans
8780                .iter()
8781                .filter(|other| other.measured_right() <= span.x)
8782                .map(TextSpan::measured_right)
8783                .fold(f64::NEG_INFINITY, f64::max);
8784            let apart = if after.is_finite() {
8785                after - right > clear
8786            } else {
8787                !before.is_finite() || span.x - before > clear
8788            };
8789            if apart {
8790                out[band].insert(at);
8791            }
8792        }
8793    }
8794    (out, true)
8795}
8796
8797/// Whether the gap being cut at is a boundary INSIDE a table rather than THE
8798/// gutter of a page set in columns.
8799///
8800/// `a_column_of_labels` above reads the table whose thin side is a column of
8801/// names. This is the other half of the same defect and the one shape none of
8802/// the guards here could see (#577): a table of figures whose two banks of
8803/// columns are both dense on every band. `region_is_ruled` cannot refuse it --
8804/// the document rules the head off and nothing else, which is one gap of eight
8805/// -- and `columns_are_dense` was written to catch a thin side, so a bank of
8806/// numbers passes it. Cut down the strip between the banks, the table comes out
8807/// as two tables side by side: every row of it is halved, and the figures of
8808/// row four stand under the figures of row one.
8809///
8810/// WHAT SEPARATES THE TWO CASES IS WHETHER THE CUT IS SPECIAL. A page set in
8811/// columns puts its widest strip of white between the columns, and a line of
8812/// prose inside one has nothing but word spaces in it -- so a side of that cut
8813/// holds bands with no gap anywhere near the gutter's width. A bank of a table
8814/// is columns of cells, so its own bands break at gaps as wide as the strip
8815/// between the banks or wider: the gutter that was cut at is not the widest
8816/// thing on the page, it is one of several the same size, and a table is what
8817/// that means.
8818///
8819/// BOTH SIDES, and that is the guard against the page this must not refuse. A
8820/// paper set in two columns with a table in ONE of them breaks the lines of
8821/// that column and not of the other, and its column cut is right; a run of
8822/// bands broken on both sides of the cut, most of the way down, is one grid.
8823///
8824/// AND A SPAN THE GAP SCAN SKIPPED IS NOT A CELL BOUNDARY. `ignored` names, per
8825/// band, the spans the caller left out of the scan that found this gap -- the
8826/// case it exists for is a thin column standing INSIDE a gutter, printed line
8827/// numbers down the middle of a statute or a patent, which makes every x in the
8828/// gutter occupied and the cut unfindable at any depth (#582). Those spans have
8829/// to be left out here too. Counted as ink, a column of numbers beside the
8830/// gutter puts a wide strip of white between itself and the rest of its side on
8831/// nearly every band -- which reads as a bank's own cell boundary, and this
8832/// guard would refuse exactly the cut that skipping them was meant to allow.
8833///
8834/// ONE SET PER BAND, parallel to `bands`, because a band's spans are numbered
8835/// within that band: the band that carries a line number holds three runs where
8836/// the band beside it holds two, so one set of indices for the whole region
8837/// would strike the number on one band and a column of text on the next (#582).
8838/// An empty slice ignores nothing, which is what `try_vertical_cut` passes; a
8839/// caller that skips spans builds its sets from the same banding the guard is
8840/// given.
8841fn the_cut_is_a_column_of_a_table(
8842    bands: &[TextBand],
8843    cut_x: f64,
8844    gap: f64,
8845    ignored: &[HashSet<usize>],
8846) -> bool {
8847    if gap <= 0.0 {
8848        return false;
8849    }
8850    // THE SAME EDGE ON BOTH SIDES OF THE DIVISION. `region_left` and
8851    // `region_right` are taken from `TextSpan::right()`, which is the greater of
8852    // the measured width and an estimate from the character count -- and on
8853    // prose that estimate overshoots the glyphs by tens of points. The extent
8854    // below is measured ink. Dividing one by the other inflates the denominator
8855    // and understates the fill, which is how a page of prose could be read as a
8856    // bank of cells and a legitimate column cut refused (codex, #577). So the
8857    // measure is the measured extent of the bands themselves.
8858    //
8859    // THE EXTENT IS OF THE BANDS AS THE GUARD SEES THEM, so `ignored` reaches it
8860    // too: a line-number column left out of the scan must not set the edge that
8861    // the fill is divided by, or the side it stands on reads as emptier than it is.
8862    let (ink_left, ink_right) = measured_extent(bands, ignored);
8863    let left = one_side_of_a_cut(
8864        bands,
8865        cut_x,
8866        true,
8867        (cut_x - ink_left).max(0.0),
8868        gap,
8869        ignored,
8870    );
8871    let right = one_side_of_a_cut(
8872        bands,
8873        cut_x,
8874        false,
8875        (ink_right - cut_x).max(0.0),
8876        gap,
8877        ignored,
8878    );
8879    let (left, right) = match (left, right) {
8880        (Some(left), Some(right)) => (left, right),
8881        _ => return false,
8882    };
8883    let banks = |side: &SideOfACut| (side.broken as f64) > BANK_BREAK_SHARE * side.inked as f64;
8884    if banks(&left) && banks(&right) {
8885        return true;
8886    }
8887    // ONE SIDE OF CELLS IS ENOUGH WHERE THE OTHER SIDE IS CELLS TOO. The other
8888    // half of this shape is a table whose first column is one cell a row --
8889    // names, dates, a label -- against a bank of figures. That side breaks
8890    // nowhere, because one cell cannot have a gap in it, so the test above sees
8891    // one bank and lets the cut through; `a_column_of_labels` does not refuse it
8892    // either, because it asks the thin side to have ink on far fewer bands than
8893    // the wide one and here both sides have ink on nearly all of them (#577).
8894    //
8895    // What decides is the measurement `region_is_a_grid` is built on: a line of
8896    // prose fills its measure and a cell does not. So a side that breaks
8897    // nowhere is still a column OF the table while its typical band leaves
8898    // white beside it -- and the page this must not refuse, a paper set in two
8899    // columns with a table in one of them, has prose on the other side, filling
8900    // it.
8901    (banks(&left) && right.fill <= GRID_SLICE_FILL)
8902        || (banks(&right) && left.fill <= GRID_SLICE_FILL)
8903}
8904
8905/// The measured ink of a set of bands, left and right.
8906///
8907/// `measured_right` rather than `right`, because the fill this feeds is computed
8908/// from measured ink and a measure taken from the estimate would not be the same
8909/// question (codex, #577).
8910///
8911/// `ignored` is the same per-band set the guard is given: a span left out of the
8912/// gap scan is left out of the extent too, or the edge it sets would be divided
8913/// into a fill it does not appear in (#582). An empty slice ignores nothing.
8914fn measured_extent(bands: &[TextBand], ignored: &[HashSet<usize>]) -> (f64, f64) {
8915    let mut left = f64::INFINITY;
8916    let mut right = f64::NEG_INFINITY;
8917    for (b, band) in bands.iter().enumerate() {
8918        let skip = ignored.get(b);
8919        for (i, span) in band.spans.iter().enumerate() {
8920            if skip.is_some_and(|set| set.contains(&i)) {
8921                continue;
8922            }
8923            left = left.min(span.x);
8924            right = right.max(span.measured_right());
8925        }
8926    }
8927    if left.is_finite() && right > left {
8928        (left, right)
8929    } else {
8930        (0.0, 0.0)
8931    }
8932}
8933
8934/// One side of a proposed cut: how many bands have ink on it, how many of them
8935/// break at a gap as wide as the cut's, and how much of the side its typical
8936/// band covers.
8937struct SideOfACut {
8938    inked: usize,
8939    broken: usize,
8940    fill: f64,
8941}
8942
8943/// `None` where the side has ink on too few bands to say anything.
8944fn one_side_of_a_cut(
8945    bands: &[TextBand],
8946    cut_x: f64,
8947    is_left: bool,
8948    measure: f64,
8949    gap: f64,
8950    ignored: &[HashSet<usize>],
8951) -> Option<SideOfACut> {
8952    if measure <= 0.0 {
8953        return None;
8954    }
8955    let mut inked = 0usize;
8956    let mut broken = 0usize;
8957    let mut fills: Vec<f64> = Vec::new();
8958    for (at, band) in bands.iter().enumerate() {
8959        let skipped = ignored.get(at);
8960        let mut ink: Vec<(f64, f64)> = band
8961            .spans
8962            .iter()
8963            .enumerate()
8964            .filter(|(span, _)| skipped.is_none_or(|set| !set.contains(span)))
8965            .map(|(_, span)| span)
8966            .filter(|span| {
8967                let middle = span.x + (span.measured_right() - span.x) * 0.5;
8968                (middle < cut_x) == is_left
8969            })
8970            .map(|span| (span.x, span.measured_right()))
8971            .collect();
8972        if ink.is_empty() {
8973            continue;
8974        }
8975        inked += 1;
8976        ink.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
8977        // The widest strip of white INSIDE this side of the band. A cell
8978        // boundary of a bank, where the side is a bank; a word space, where it
8979        // is a line of prose.
8980        let widest = ink
8981            .windows(2)
8982            .map(|pair| pair[1].0 - pair[0].1)
8983            .fold(0.0_f64, f64::max);
8984        if widest >= gap {
8985            broken += 1;
8986        }
8987        let from = ink.iter().map(|pair| pair.0).fold(f64::INFINITY, f64::min);
8988        let to = ink
8989            .iter()
8990            .map(|pair| pair.1)
8991            .fold(f64::NEG_INFINITY, f64::max);
8992        if to > from {
8993            fills.push(((to - from) / measure).min(1.0));
8994        }
8995    }
8996    if inked < BANK_MIN_ROWS || fills.is_empty() {
8997        return None;
8998    }
8999    fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9000    Some(SideOfACut {
9001        inked,
9002        broken,
9003        fill: fills[fills.len() / 2],
9004    })
9005}
9006
9007/// Whether the thin side of the cut is a column of LABELS for the other side:
9008/// a table whose cells hold sentences (#576).
9009///
9010/// `region_is_ruled` above says the same thing about a table the document drew
9011/// lines round. This is the commoner table -- no lines, a narrow first column
9012/// of names and a wide one of descriptions -- and cut down its gutter every
9013/// row of it is lost: the names come out as one paragraph, the descriptions as
9014/// another, and nothing says which name went with which description.
9015///
9016/// THE MEASUREMENT IS WHERE THE THIN SIDE'S INK SITS. A label stands against
9017/// the START of what it labels, so every band the thin side has ink on is a
9018/// band where the wide side BEGINS something: the wide side's step into that
9019/// band is wider than the leading it sets its own lines at. A note in the
9020/// margin -- the case this must not refuse -- falls where it falls, in the
9021/// middle of the running text beside it, and its bands are ordinary lines of
9022/// that text.
9023///
9024/// Every band, not most: the thin side is a handful of bands, and a share over
9025/// a handful is a coin toss. One label in the middle of a paragraph and this
9026/// is not a table.
9027fn a_column_of_labels(
9028    bands: &[TextBand],
9029    cut_x: f64,
9030    region_left: f64,
9031    region_right: f64,
9032    min_gap: f64,
9033) -> bool {
9034    fn on(band: &TextBand, left: bool, cut_x: f64) -> Vec<&TextSpan> {
9035        band.spans
9036            .iter()
9037            .filter(|span| {
9038                let middle = span.x + (span.measured_right() - span.x) * 0.5;
9039                (middle < cut_x) == left
9040            })
9041            .collect()
9042    }
9043    let inked = |left: bool| -> Vec<usize> {
9044        (0..bands.len())
9045            .filter(|at| !on(&bands[*at], left, cut_x).is_empty())
9046            .collect()
9047    };
9048    let (thin, wide, thin_is_left) = {
9049        let (l, r) = (inked(true), inked(false));
9050        if l.len() <= r.len() {
9051            (l, r, true)
9052        } else {
9053            (r, l, false)
9054        }
9055    };
9056    if thin.len() < LABEL_MIN_ROWS || thin.len() * 4 > wide.len() * 3 {
9057        return false;
9058    }
9059
9060    // AND A LABEL IS SHORT. The other way a side has ink on few bands is that
9061    // it is the shorter column of a page set in columns -- one that ends
9062    // higher than its neighbour -- and its lines fill their measure to the
9063    // last word, which is what a line of prose does and a cell does not. So
9064    // the typical line of the thin side has to leave white beside it before
9065    // any of this is read as a label at all.
9066    let measure = if thin_is_left {
9067        cut_x - region_left
9068    } else {
9069        region_right - cut_x
9070    };
9071    if measure <= 0.0 {
9072        return false;
9073    }
9074    // AND A LABEL IS ONE CELL: a thin side whose own bands break into several
9075    // runs of ink is not a column at all but more columns, which is what a
9076    // page of glyph samples laid out in a grid looks like from here.
9077    let mut fills: Vec<f64> = Vec::with_capacity(thin.len());
9078    for at in &thin {
9079        let slice = on(&bands[*at], thin_is_left, cut_x);
9080        let broken = slice
9081            .windows(2)
9082            .any(|pair| pair[1].x - pair[0].measured_right() >= min_gap);
9083        if broken {
9084            return false;
9085        }
9086        let from = slice.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
9087        let to = slice
9088            .iter()
9089            .map(|s| s.measured_right())
9090            .fold(f64::NEG_INFINITY, f64::max);
9091        if to > from {
9092            fills.push(((to - from) / measure).min(1.0));
9093        }
9094    }
9095    if fills.is_empty() {
9096        return false;
9097    }
9098    fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9099    if fills[fills.len() / 2] > GRID_SLICE_FILL {
9100        return false;
9101    }
9102
9103    // The wide side's own leading: the middle step between the bands it has
9104    // ink on. A step wider than `LABEL_PITCH_JUMP` of it is where it begins
9105    // something rather than carrying on.
9106    let mut steps: Vec<f64> = wide
9107        .windows(2)
9108        .map(|pair| bands[pair[0]].y - bands[pair[1]].y)
9109        .filter(|step| *step > 0.0)
9110        .collect();
9111    if steps.len() < 2 {
9112        return false;
9113    }
9114    steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9115    let leading = steps[steps.len() / 2];
9116    if leading <= 0.0 {
9117        return false;
9118    }
9119
9120    // A LABEL MAY WRAP. `Community resources` set over two lines is one label
9121    // and its second line stands against nothing, so a band the thin side
9122    // carries on into -- one leading under a thin band of its own -- is as
9123    // much part of a label as the band that opened it.
9124    let opens_the_wide_side = |at: usize| match wide.iter().position(|w| *w >= at) {
9125        Some(0) => true,
9126        Some(where_) if wide[where_] == at => {
9127            bands[wide[where_ - 1]].y - bands[at].y > leading * LABEL_PITCH_JUMP
9128        }
9129        _ => false,
9130    };
9131    thin.iter().all(|at| {
9132        *at == 0
9133            || opens_the_wide_side(*at)
9134            || (thin.binary_search(&(at - 1)).is_ok() && bands[at - 1].y - bands[*at].y <= leading)
9135    })
9136}
9137
9138/// ANN[r17/TEX2] Reject a vertical cut when any band sits on only one
9139/// side of the cut AND occupies more than ~70% of that side's column
9140/// width. Such bands are almost certainly full-width paragraphs that
9141/// happened to align with the left margin of one column, and forcing
9142/// them into that column re-orders them relative to text that follows.
9143fn columns_are_band_aligned(
9144    spans: &[TextSpan],
9145    cut_x: f64,
9146    region_left: f64,
9147    region_right: f64,
9148    stats: &PageStats,
9149) -> bool {
9150    let left_width = (cut_x - region_left).max(1.0);
9151    let right_width = (region_right - cut_x).max(1.0);
9152
9153    // Threshold chosen empirically: paragraph bodies in columnar
9154    // layouts usually fill ~60-70% of their column; anything wider
9155    // than 0.7× is a page-level element masquerading as column
9156    // content.
9157    const MAX_SINGLE_SIDE_FRACTION: f64 = 0.70;
9158
9159    let _ = (left_width, right_width, MAX_SINGLE_SIDE_FRACTION);
9160    // ONE SPAN'S INK REACHING ACROSS THE CUT is what "a full-width element
9161    // masquerading as column content" actually looks like. The guard used to
9162    // ask a different question -- is a band that sits on one side wider than
9163    // 0.70 of that side -- and on a real two-column page the answer is yes for
9164    // any ordinary line of body text, because a line fills about 0.9 of its
9165    // column. Every such page has single-sided bands (a heading, a column that
9166    // ends short, a figure), so the old test refused the canonical academic
9167    // page every time (#429).
9168    //
9169    // Width was a proxy for crossing, and it is a proxy that cannot tell a
9170    // banner from a column line. Its own fixture shows why: the banner runs
9171    // x 40..320 against a cut at 200, which is ink on both sides. It counted as
9172    // "one side" only because sides were decided on each span's midpoint.
9173    for span in spans {
9174        if span.x < cut_x && span.measured_right() > cut_x {
9175            return false;
9176        }
9177    }
9178
9179    // A LINE CANNOT BE HALF AGAIN AS WIDE AS THE TYPICAL LINE IN ITS OWN COLUMN
9180    // and still be a line of that column. That is the second way a full-width
9181    // element hides: `Intro paragraph` and `Outro paragraph` in
9182    // mixed_single_and_multi_column_regions_preserve_shared_bands are wider
9183    // than the column they sit above but still stop short of the gutter, so no
9184    // ink of theirs crosses the cut. Swallowed into the left column they would
9185    // read Intro, L1..L3, Outro, R1..R3 -- the outro before the right column it
9186    // precedes.
9187    //
9188    // Measured against the MEDIAN LINE OF THAT SIDE, not against the width of
9189    // the side. The old test used cut_x - region_left, which is a column plus
9190    // half a gutter, so an ordinary body line at ~0.9 of its column tripped it
9191    // and the canonical two-column page was refused every time (#429). The
9192    // median calibrates itself: on a real column most lines run full width, so
9193    // the median IS the column width and nothing legitimate exceeds it.
9194    const OUTLIER: f64 = 1.3;
9195    // EVERY band's per-side extent, not only the single-sided ones. Measuring
9196    // the typical line from the single-sided bands alone compares the outliers
9197    // with themselves: in the Intro/Outro fixture those two ARE the only
9198    // single-sided bands, so their median is their own width and nothing ever
9199    // stands out.
9200    let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
9201    let mut widths: (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
9202    let mut single: Vec<(bool, f64)> = Vec::new();
9203    for band in &bands {
9204        let mut left_lo = f64::INFINITY;
9205        let mut left_hi = f64::NEG_INFINITY;
9206        let mut right_lo = f64::INFINITY;
9207        let mut right_hi = f64::NEG_INFINITY;
9208        for span in &band.spans {
9209            let r = span.measured_right();
9210            if span.x + (r - span.x) * 0.5 < cut_x {
9211                left_lo = left_lo.min(span.x);
9212                left_hi = left_hi.max(r);
9213            } else {
9214                right_lo = right_lo.min(span.x);
9215                right_hi = right_hi.max(r);
9216            }
9217        }
9218        let has_left = left_hi > left_lo || left_lo.is_finite();
9219        let has_right = right_hi > right_lo || right_lo.is_finite();
9220        if has_left {
9221            widths.0.push((left_hi - left_lo).max(0.0));
9222        }
9223        if has_right {
9224            widths.1.push((right_hi - right_lo).max(0.0));
9225        }
9226        match (has_left, has_right) {
9227            (true, false) => single.push((true, (left_hi - left_lo).max(0.0))),
9228            (false, true) => single.push((false, (right_hi - right_lo).max(0.0))),
9229            _ => {}
9230        }
9231    }
9232    // A PATENT'S FRONT PAGE: THE COLUMN'S WIDTH IS ITS LONG LINES. Its
9233    // bibliographic column is mostly short lines -- `(22) Filed: Nov. 8,
9234    // 2002` over `(58) Field of Search ..... 252/500; 528/422` -- so the
9235    // median stood at 152 pt where the column runs 199, the column's own
9236    // longest line read as a banner over both columns, and every line joined
9237    // its neighbour across the gutter (#574, the rig-like proxy's patents).
9238    // Where the page numbers its fields with INID codes -- three or more
9239    // lines opening `(21)`, `(75)`, `(58)` at the region's left edge -- the
9240    // column is measured by its
9241    // ninetieth percentile. Only there: taken for every page, the same
9242    // measure cut financial tables and scientific pages that the median
9243    // keeps whole (tables -0.010, geometric order -0.012 on the proxy).
9244    let inid = spans
9245        .iter()
9246        .filter(|span| {
9247            let t = span.text.trim_start();
9248            let b = t.as_bytes();
9249            // At the column's LEFT EDGE: an equation's number `(23)` stands
9250            // at the right of a scientific page and is not a field.
9251            span.x <= region_left + 2.0 * span.font_size.max(1.0)
9252                && b.len() >= 4
9253                && b[0] == b'('
9254                && b[1].is_ascii_digit()
9255                && b[2].is_ascii_digit()
9256                && b[3] == b')'
9257        })
9258        .count()
9259        >= 3;
9260    let median = |v: &mut Vec<f64>| -> Option<f64> {
9261        if v.is_empty() {
9262            return None;
9263        }
9264        v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9265        Some(if inid {
9266            v[(v.len() * 9 / 10).min(v.len() - 1)]
9267        } else {
9268            v[v.len() / 2]
9269        })
9270    };
9271    let left_median = median(&mut widths.0);
9272    let right_median = median(&mut widths.1);
9273    for (is_left, w) in single {
9274        let m = if is_left { left_median } else { right_median };
9275        if let Some(m) = m {
9276            if m > 0.0 && w > m * OUTLIER {
9277                return false;
9278            }
9279        }
9280    }
9281    true
9282}
9283
9284/// Density guard — reject column splits that look like tables (few,
9285/// short spans per column). A column is "dense" when it has at least
9286/// MIN_SPANS_PER_COLUMN spans and the average character count per band
9287/// exceeds MIN_CHARS_PER_BAND.
9288fn columns_are_dense(left: &[TextSpan], right: &[TextSpan], stats: &PageStats) -> bool {
9289    for col in [left, right] {
9290        if col.len() < XY_CUT_MIN_SPANS_PER_COLUMN {
9291            return false;
9292        }
9293        let bands = group_spans_into_bands_with_stats(col.to_vec(), stats);
9294        if bands.is_empty() {
9295            return false;
9296        }
9297        let total_chars: usize = col.iter().map(|s| s.text.chars().count()).sum();
9298        let chars_per_band = total_chars as f64 / bands.len() as f64;
9299        if chars_per_band < XY_CUT_MIN_CHARS_PER_BAND {
9300            return false;
9301        }
9302    }
9303    true
9304}
9305
9306/// Attempt a horizontal (zone / paragraph) cut. Unlike vertical cuts
9307/// this does NOT need a density guard — splitting top-from-bottom
9308/// cannot re-order content.
9309fn try_horizontal_cut(spans: &[TextSpan], stats: &PageStats) -> Option<(Vec<Vec<TextSpan>>, f64)> {
9310    if spans.len() < 2 {
9311        return None;
9312    }
9313    // Sort by descending y (PDF y grows upward).
9314    let mut sorted = spans.to_vec();
9315    sorted.sort_by(|a, b| {
9316        b.y.partial_cmp(&a.y)
9317            .unwrap_or(Ordering::Equal)
9318            .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
9319    });
9320
9321    // ANN[r17/TEX4] Paragraph / zone cuts scale with MEDIAN LINE
9322    // SPACING when available — this is the typographically correct
9323    // baseline (paragraph break ≈ 1.8 × line-spacing). When the page
9324    // has only one band, or stats haven't observed spacing yet, fall
9325    // back to the font-size multiple the legacy path used.
9326    let min_gap = if stats.median_line_spacing > 0.0 {
9327        stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
9328    } else {
9329        stats.median_font_size * XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER
9330    };
9331    // AND NEVER BELOW THE LEADING THE PAGE IS SET ON (#580). The threshold
9332    // above scales with `median_line_spacing`, which is the FIRST QUARTILE of
9333    // the band steps -- so a page carrying anything tighter than its body (a
9334    // table, a stack of figure labels, a run of sub- and superscripts) puts the
9335    // quartile on that tighter thing and the threshold lands BELOW the body's
9336    // own leading. Every line break on the page then clears it.
9337    //
9338    // WHAT THAT COSTS IS THE WHOLE PAGE, not one cut, and the reason is in
9339    // `blocks::paragraphs`: its stretches are bounded PER REGION, so lines that
9340    // the cut put in two regions can never be merged back. On
9341    // `PMC6019521-p0007` the threshold sat at 10.82 against a leading of 12.00
9342    // and the page came out as 83 blocks, 72 of them a single line of prose --
9343    // one block per line, unrecoverable downstream.
9344    //
9345    // A FLOOR AND NOT A DIFFERENT STATISTIC. Replacing the quartile with the
9346    // leading was measured too and it is wrong: the threshold then rises above
9347    // the paragraph break as well, no horizontal cut fires, the vertical cut is
9348    // refused on the full-width band that is left, and a two-column page reads
9349    // letter-perfect nonsense -- left and right column lines glued alternately.
9350    // The horizontal cut firing at paragraph-break scale is what makes the
9351    // vertical cut findable at all. So the window the threshold has to sit in is
9352    // (the leading, the paragraph break], and the floor is the only end of it
9353    // that was missing.
9354    let leading = leading_of_a_region(spans, stats);
9355    let min_gap = if leading > 0.0 {
9356        min_gap.max(leading * HORIZONTAL_CUT_LEADING_FLOOR)
9357    } else {
9358        min_gap
9359    };
9360
9361    // Look for the largest gap between consecutive span y-values.
9362    // Once, before the scan: every candidate is asked the same question about the
9363    // same bands, and building them per candidate would make the guard's cost
9364    // quadratic in the gaps a page has.
9365    let bands = group_spans_into_bands(sorted.clone());
9366    let mut best: Option<(f64, f64)> = None; // (gap_size, cut_y)
9367    let tolerance = stats.median_font_size * BAND_Y_FRACTION;
9368    let mut band_bottom = sorted[0].y;
9369
9370    for span in sorted.iter().skip(1) {
9371        if (band_bottom - span.y).abs() <= tolerance {
9372            band_bottom = band_bottom.min(span.y);
9373            continue;
9374        }
9375        let gap = band_bottom - span.y;
9376        if gap >= min_gap {
9377            let cut_y = (band_bottom + span.y) * 0.5;
9378            match best {
9379                Some((best_gap, _)) if best_gap >= gap => {}
9380                _ => best = Some((gap, cut_y)),
9381            }
9382        }
9383        band_bottom = span.y;
9384    }
9385
9386    let (gap_size, cut_y) = best?;
9387
9388    // AND NOT INSIDE ONE GRID (#596): a table whose rows are grouped under labels
9389    // leaves more white between the groups than between its rows, and the
9390    // threshold above is a multiple of the ROW pitch.
9391    //
9392    // THE WHOLE REGION IS REFUSED, and the review asked for the other shape:
9393    // skip the grid-internal candidates while scanning and take the largest gap
9394    // left, so that a SMALLER qualifying gap which separates the table from its
9395    // caption is not lost with them. The hazard is real -- a table left merged
9396    // with prose above it is a region `tables::find` refuses outright -- but the
9397    // cure measured worse and the numbers are why it is not taken: over the 43
9398    // annotated pages this machine may hold, refusing the region leaves a summed
9399    // distance of 25 from the annotated table counts, and scanning past the
9400    // grid-internal gaps leaves 32 against 50 unguarded. `PMC3037919-p0007` goes
9401    // back from 1 table to 4 and `PMC2268700-p0008` from 1 to 3.
9402    //
9403    // The reason is this guard's own blind spots, which are counted on #596: where
9404    // the region has fewer than two bands on a side, or the widest band shows
9405    // fewer than two separations, a gap INSIDE the grid is not recognised as one.
9406    // Scanning on therefore does not find the caption's gap, it finds the next
9407    // unrecognised gap inside the table and cuts there. Refusing the region keeps
9408    // the table whole; scanning past cuts it somewhere else.
9409    if the_cut_is_inside_a_grid(&bands, cut_y, gap_size) {
9410        return None;
9411    }
9412
9413    let mut top_group = Vec::new();
9414    let mut bottom_group = Vec::new();
9415    for span in spans {
9416        if span.y > cut_y {
9417            top_group.push(span.clone());
9418        } else {
9419            bottom_group.push(span.clone());
9420        }
9421    }
9422    if top_group.is_empty() || bottom_group.is_empty() {
9423        return None;
9424    }
9425    Some((vec![top_group, bottom_group], gap_size))
9426}
9427
9428/// Legacy band+column-detection path, kept for reference and as the
9429/// fallback inside `band_based_blocks` test coverage. Not currently
9430/// used — XY-Cut supersedes it.
9431#[allow(dead_code)]
9432fn group_spans_into_blocks_legacy(spans: Vec<TextSpan>) -> Vec<TextBlock> {
9433    let bands = group_spans_into_bands(spans);
9434    group_spans_into_blocks_legacy_from_bands(bands, &[])
9435}
9436
9437fn group_spans_into_blocks_legacy_with_stats(
9438    spans: Vec<TextSpan>,
9439    stats: &PageStats,
9440    rules: &[HorizontalRule],
9441) -> Vec<TextBlock> {
9442    let bands = group_spans_into_bands_with_stats(spans, stats);
9443    group_spans_into_blocks_legacy_from_bands(bands, rules)
9444}
9445
9446fn group_spans_into_blocks_legacy_from_bands(
9447    bands: Vec<TextBand>,
9448    rules: &[HorizontalRule],
9449) -> Vec<TextBlock> {
9450    if bands.is_empty() {
9451        return Vec::new();
9452    }
9453
9454    let column_gap_threshold = compute_adaptive_column_gap(&bands);
9455
9456    let mut blocks = Vec::new();
9457    let mut idx = 0;
9458
9459    while idx < bands.len() {
9460        let gap_midpoints = bands[idx].gap_midpoints(column_gap_threshold);
9461        if gap_midpoints.is_empty() {
9462            idx = push_row(&bands, idx, &mut blocks);
9463            continue;
9464        }
9465
9466        let mut boundaries = gap_midpoints.clone();
9467        let mut band_indices = vec![idx];
9468        let mut gapped_band_count = 1usize;
9469        let mut region_left = bands[idx].left();
9470        let mut region_right = bands[idx].right();
9471        let mut next_idx = idx + 1;
9472
9473        while next_idx < bands.len() {
9474            let next_band = &bands[next_idx];
9475            let next_gap_midpoints = next_band.gap_midpoints(column_gap_threshold);
9476            if next_gap_midpoints.is_empty() {
9477                if next_band
9478                    .fits_single_column(&boundaries, region_left, region_right)
9479                    .is_some()
9480                {
9481                    band_indices.push(next_idx);
9482                    next_idx += 1;
9483                    continue;
9484                }
9485                break;
9486            }
9487
9488            if !boundaries_match(&boundaries, &next_gap_midpoints, column_gap_threshold) {
9489                break;
9490            }
9491
9492            update_boundaries(&mut boundaries, &next_gap_midpoints, gapped_band_count);
9493            gapped_band_count += 1;
9494            band_indices.push(next_idx);
9495            region_left = region_left.min(next_band.left());
9496            region_right = region_right.max(next_band.right());
9497            next_idx += 1;
9498        }
9499
9500        if region_is_columnar(&bands, &band_indices, &boundaries, gapped_band_count)
9501            && !region_is_ruled(&bands, &band_indices, rules)
9502            && !region_is_a_grid(&bands, &band_indices, &boundaries)
9503        {
9504            append_column_region_blocks(&bands, &band_indices, &boundaries, &mut blocks);
9505            idx = next_idx;
9506        } else {
9507            idx = push_row(&bands, idx, &mut blocks);
9508        }
9509    }
9510
9511    blocks
9512}
9513
9514/// Emit the band at `idx` and return the index of the first band after what was
9515/// emitted.
9516///
9517/// A band is normally one line of the page and becomes one block. The exception
9518/// is the row of a table one of whose cells wraps: the wrapped cell's second
9519/// line is a band of its own, and emitting the bands in turn puts it after the
9520/// row's remaining cells instead of after the text it continues (#434).
9521fn push_row(bands: &[TextBand], idx: usize, blocks: &mut Vec<TextBlock>) -> usize {
9522    if let Some(next) = append_wrapped_table_row(bands, idx, blocks) {
9523        return next;
9524    }
9525    blocks.push(bands[idx].row_block());
9526    idx + 1
9527}
9528
9529/// The left edges of the cells the band at `idx` draws, as the row itself
9530/// states them: one edge per span start, and two starts within
9531/// `CELL_EDGE_TOLERANCE` are one edge.
9532///
9533/// Cell edges are taken from starts rather than from the gaps between spans
9534/// because a cell's measured extent is not a column boundary: `8.2.5.26-t05`
9535/// draws four of its five columns less than 16 pt apart, which no gutter
9536/// threshold that leaves ordinary word spacing alone would ever see.
9537fn cell_edges(band: &TextBand) -> Vec<f64> {
9538    let mut edges: Vec<f64> = band.spans.iter().map(|span| span.x).collect();
9539    edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9540    edges.dedup_by(|a, b| (*a - *b).abs() <= CELL_EDGE_TOLERANCE);
9541    edges
9542}
9543
9544/// The cell a span belongs to: the edge it starts on, when it also stops before
9545/// the next edge. A span that starts between two edges, or that reaches into the
9546/// next cell, belongs to no cell of this grid and refuses the whole row.
9547///
9548/// The extent asked for is the measured one. A span's nominal width is the sum
9549/// of the advances the font asks for, which regularly overshoots the ink and so
9550/// the cell: `t05`'s wrapped cell advances 78 pt past its own text and into the
9551/// column after it.
9552fn cell_of(span: &TextSpan, edges: &[f64]) -> Option<usize> {
9553    let idx = edges
9554        .iter()
9555        .rposition(|edge| span.x >= edge - CELL_EDGE_TOLERANCE)?;
9556    if (span.x - edges[idx]).abs() > CELL_EDGE_TOLERANCE {
9557        return None;
9558    }
9559    if let Some(next_edge) = edges.get(idx + 1) {
9560        if span.measured_right() > next_edge - CELL_EDGE_TOLERANCE {
9561            return None;
9562        }
9563    }
9564    Some(idx)
9565}
9566
9567/// The tallest glyph of the band, which is the unit both vertical distances
9568/// below are measured in.
9569fn band_line_height(band: &TextBand) -> f64 {
9570    band.spans
9571        .iter()
9572        .map(|span| span.height.max(span.font_size))
9573        .fold(0.0_f64, f64::max)
9574}
9575
9576/// Whether `band` is another row of the same grid: every one of its spans sits
9577/// in a cell of `edges`, and it carries at least `TABLE_GRID_MIN_COLUMNS` of
9578/// them.
9579///
9580/// This is what separates a table row from two lines of prose that happen to
9581/// break in the right place. One line's spans always align with themselves; a
9582/// grid is only a grid when a second row draws the same edges.
9583/// The single cell of the grid a band fills, when it fills exactly one.
9584///
9585/// This is what a wrapped line looks like from above, and what the next row of
9586/// a table does not look like: a row with an empty cell still fills two of
9587/// them, so it is not mistaken for the line of a cell that ran on.
9588fn band_fills_one_cell(band: &TextBand, edges: &[f64]) -> Option<usize> {
9589    let mut filled: Option<usize> = None;
9590    for span in &band.spans {
9591        let cell = cell_of(span, edges)?;
9592        match filled {
9593            Some(seen) if seen != cell => return None,
9594            _ => filled = Some(cell),
9595        }
9596    }
9597    filled
9598}
9599
9600fn band_matches_grid(band: &TextBand, edges: &[f64]) -> bool {
9601    let mut occupied = vec![false; edges.len()];
9602    for span in &band.spans {
9603        match cell_of(span, edges) {
9604            Some(cell) => occupied[cell] = true,
9605            None => return false,
9606        }
9607    }
9608    occupied.iter().filter(|seen| **seen).count() >= TABLE_GRID_MIN_COLUMNS
9609}
9610
9611/// Emit the row at `idx` cell by cell when one of its cells wraps, and return
9612/// the index of the first band after the row. `None` when the band is not such
9613/// a row, which is the ordinary case.
9614///
9615/// The row is emitted slice-major: every cell in turn, and within a cell its
9616/// own lines in the order they are drawn. That is what both readers do, and it
9617/// is the only order in which a wrapped cell reads as one piece of text.
9618///
9619/// A wrapped line and the next row of the table look alike from above -- both
9620/// are a band under the row with some of its cells filled -- and telling them
9621/// apart is the whole of the work here. Two things say "line" rather than
9622/// "row": it fills a single cell of the grid, and it sits closer to the row
9623/// than the rows of the grid sit to each other.
9624///
9625/// What it does not do, deliberately:
9626///   * a row two of whose cells wrap onto the same line is a line that fills
9627///     two cells, which is what the next row of the table looks like, so it
9628///     stays row-major;
9629///   * so does a row every one of whose cells has a line under it: nothing
9630///     there says which line continues the row and which one begins the next,
9631///     and it is also the shape a two-column region takes when one column runs
9632///     on alone -- reordering that would be the column cut of #389 class O-B
9633///     done badly;
9634///   * a row of two cells is left alone, because that is also what a page set
9635///     in columns looks like -- see `TABLE_ROW_MIN_CELLS`;
9636///   * the first row of a table is left alone, because a single line's cell
9637///     edges are also the word boundaries of a sentence and no row above it
9638///     corroborates them;
9639///   * a continuation that is indented, centred or right-aligned inside its
9640///     cell does not start on the cell's edge and is not recognised.
9641fn append_wrapped_table_row(
9642    bands: &[TextBand],
9643    idx: usize,
9644    blocks: &mut Vec<TextBlock>,
9645) -> Option<usize> {
9646    let head = &bands[idx];
9647    if head.spans.len() < TABLE_ROW_MIN_CELLS {
9648        return None;
9649    }
9650    let edges = cell_edges(head);
9651    if edges.len() < TABLE_ROW_MIN_CELLS {
9652        return None;
9653    }
9654    let line_height = band_line_height(head);
9655    if line_height <= 0.0 {
9656        return None;
9657    }
9658
9659    let head_cells: Vec<usize> = head
9660        .spans
9661        .iter()
9662        .map(|span| cell_of(span, &edges))
9663        .collect::<Option<Vec<usize>>>()?;
9664
9665    // The pitch of the grid, from the band directly above: one line plus
9666    // whatever the cells are padded by. Nothing below may sit that far from
9667    // what it continues, or it is the next row rather than a wrapped line.
9668    let above = idx.checked_sub(1)?;
9669    let row_pitch = bands[above].y - head.y;
9670    if row_pitch <= 0.0 {
9671        return None;
9672    }
9673    let max_line_gap = (line_height * WRAPPED_CELL_LINE_GAP_FACTOR)
9674        .min(row_pitch * WRAPPED_CELL_MAX_PITCH_FRACTION);
9675
9676    // The nearest row above that draws the same cell edges. It is what says
9677    // this is a grid at all: one line's spans always align with themselves, so
9678    // only a second row makes the edges evidence of anything. The bands stepped
9679    // over on the way are the wrapped lines of that row.
9680    let mut corroborator = above;
9681    loop {
9682        if band_matches_grid(&bands[corroborator], &edges) {
9683            break;
9684        }
9685        if band_fills_one_cell(&bands[corroborator], &edges).is_none()
9686            || idx - corroborator >= TABLE_GRID_MAX_LOOKBACK
9687            || corroborator == 0
9688        {
9689            return None;
9690        }
9691        corroborator -= 1;
9692    }
9693
9694    // The continuation lines: each fills one cell of the grid that the row
9695    // itself fills, and sits less than the grid's pitch below the last line of
9696    // *that cell*.
9697    //
9698    // Per cell, and not below the line before it whichever cell that was: a
9699    // cell's lines are consecutive, so its third line is two gaps below the row
9700    // while another cell's second line is one. Measuring against the last line
9701    // accepted would let the row after a wrapped one -- if it happens to fill a
9702    // single cell -- sit one gap below that wrapped line and be read as a third
9703    // line of the row it follows.
9704    let mut continuations: Vec<(&TextBand, usize)> = Vec::new();
9705    let mut last_line_in_cell = vec![head.y; edges.len()];
9706    let mut next_idx = idx + 1;
9707    while next_idx < bands.len() {
9708        let band = &bands[next_idx];
9709        // One cell's line cannot carry more runs than the whole row it
9710        // continues; a band that does is a row of its own.
9711        if band.spans.len() >= head.spans.len() {
9712            break;
9713        }
9714        let Some(cell) = band_fills_one_cell(band, &edges) else {
9715            break;
9716        };
9717        if !head_cells.contains(&cell) {
9718            break;
9719        }
9720        let line_gap = last_line_in_cell[cell] - band.y;
9721        if line_gap <= 0.0 || line_gap > max_line_gap {
9722            break;
9723        }
9724        last_line_in_cell[cell] = band.y;
9725        continuations.push((band, cell));
9726        next_idx += 1;
9727    }
9728    if continuations.is_empty() {
9729        return None;
9730    }
9731
9732    let wrapped_cells: Vec<usize> = continuations.iter().map(|(_, cell)| *cell).collect();
9733    if head_cells.iter().all(|cell| wrapped_cells.contains(cell)) {
9734        return None;
9735    }
9736
9737    for cell in 0..edges.len() {
9738        let row_spans: Vec<TextSpan> = head
9739            .spans
9740            .iter()
9741            .zip(&head_cells)
9742            .filter(|(_, span_cell)| **span_cell == cell)
9743            .map(|(span, _)| span.clone())
9744            .collect();
9745        if !row_spans.is_empty() {
9746            blocks.push(TextBlock { spans: row_spans });
9747        }
9748        for (band, wrapped_cell) in &continuations {
9749            if *wrapped_cell == cell {
9750                blocks.push(band.row_block());
9751            }
9752        }
9753    }
9754
9755    Some(next_idx)
9756}
9757
9758/// Legacy wrapper used by call sites that haven't been handed PageStats.
9759/// It derives stats locally. Prefer `group_spans_into_bands_with_stats`
9760/// inside the XY-Cut pipeline to avoid recomputing the stats per call.
9761fn group_spans_into_bands(spans: Vec<TextSpan>) -> Vec<TextBand> {
9762    let stats = PageStats::from_spans(&spans);
9763    group_spans_into_bands_with_stats(spans, &stats)
9764}
9765
9766fn group_spans_into_bands_with_stats(mut spans: Vec<TextSpan>, stats: &PageStats) -> Vec<TextBand> {
9767    if spans.is_empty() {
9768        return Vec::new();
9769    }
9770
9771    spans.sort_by(|a, b| {
9772        b.y.partial_cmp(&a.y)
9773            .unwrap_or(Ordering::Equal)
9774            .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
9775    });
9776
9777    // ANN[r17/TEX4] Band tolerance scales with this page's median font
9778    // size rather than a fixed 5pt floor. Single-page spreads with
9779    // huge display fonts (24pt+) previously merged unrelated lines; a
9780    // fractional threshold keeps that from happening without hurting
9781    // body-text pages.
9782    let page_tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
9783
9784    let mut bands: Vec<TextBand> = Vec::new();
9785
9786    for span in spans {
9787        let tolerance = (span.height * BAND_Y_FRACTION)
9788            .max(page_tolerance)
9789            .max(BAND_Y_TOLERANCE);
9790        if let Some(band) = bands
9791            .iter_mut()
9792            .find(|band| (band.y - span.y).abs() <= tolerance)
9793        {
9794            let span_count = band.spans.len() as f64;
9795            band.y = (band.y * span_count + span.y) / (span_count + 1.0);
9796            band.spans.push(span);
9797        } else {
9798            bands.push(TextBand::new(span));
9799        }
9800    }
9801
9802    for band in &mut bands {
9803        band.sort_spans();
9804    }
9805
9806    bands.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
9807    bands
9808}
9809
9810fn boundaries_match(boundaries: &[f64], gap_midpoints: &[f64], column_gap_threshold: f64) -> bool {
9811    let tolerance = (column_gap_threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
9812    boundaries.len() == gap_midpoints.len()
9813        && boundaries
9814            .iter()
9815            .zip(gap_midpoints)
9816            .all(|(lhs, rhs)| (lhs - rhs).abs() <= tolerance)
9817}
9818
9819fn update_boundaries(boundaries: &mut [f64], gap_midpoints: &[f64], seen_gapped_bands: usize) {
9820    for (boundary, midpoint) in boundaries.iter_mut().zip(gap_midpoints) {
9821        *boundary =
9822            (*boundary * seen_gapped_bands as f64 + midpoint) / (seen_gapped_bands as f64 + 1.0);
9823    }
9824}
9825
9826fn region_is_columnar(
9827    bands: &[TextBand],
9828    band_indices: &[usize],
9829    boundaries: &[f64],
9830    gapped_band_count: usize,
9831) -> bool {
9832    if boundaries.is_empty()
9833        || gapped_band_count < MIN_COLUMN_GAPPED_BANDS
9834        || band_indices.is_empty()
9835        || (gapped_band_count as f64 / band_indices.len() as f64) < MIN_COLUMN_GAP_SUPPORT
9836    {
9837        return false;
9838    }
9839
9840    let mut non_empty_slices = 0usize;
9841    let mut dense_slices = 0usize;
9842    let mut slices_per_column = vec![0usize; boundaries.len() + 1];
9843
9844    for &band_idx in band_indices {
9845        let slices = bands[band_idx].split_by_boundaries(boundaries);
9846        for (column_idx, slice) in slices.iter().enumerate() {
9847            if slice.is_empty() {
9848                continue;
9849            }
9850
9851            non_empty_slices += 1;
9852            slices_per_column[column_idx] += 1;
9853
9854            let char_count = slice
9855                .iter()
9856                .map(|span| span.text.chars().count())
9857                .sum::<usize>();
9858            if slice.len() >= 2 || char_count >= 8 {
9859                dense_slices += 1;
9860            }
9861        }
9862    }
9863
9864    if non_empty_slices < boundaries.len() + 2 {
9865        return false;
9866    }
9867
9868    if slices_per_column.contains(&0) {
9869        return false;
9870    }
9871
9872    // A COLUMN OF MARKERS IS A LIST (#574). A bullet set well apart from its
9873    // item -- ASX_MRM draws a 19 pt `•` 13 pt before each item's text -- is a
9874    // gap on every band at one x, and read as a column the page came back as
9875    // four bullets and then four items, which no list pass can put together
9876    // again. Where every cell of the first column is a list marker, the bands
9877    // are lines.
9878    //
9879    // AN ITEM OPENS WITH A WORD: a `–` for nil down the first column of a
9880    // block of amounts is a column of cells, and the band beside it holds
9881    // figures (DocLayNet validation, a statement of receivables).
9882    let mut marked = 0usize;
9883    let markers_first = band_indices.iter().all(|&band_idx| {
9884        let slices = bands[band_idx].split_by_boundaries(boundaries);
9885        let joined = |slice: &Vec<TextSpan>| -> String {
9886            slice
9887                .iter()
9888                .map(|span| span.text.as_str())
9889                .collect::<String>()
9890        };
9891        let Some(first) = slices.first() else {
9892            return true;
9893        };
9894        let text = joined(first);
9895        let text = text.trim();
9896        if text.is_empty() {
9897            return true;
9898        }
9899        let item_follows = slices
9900            .iter()
9901            .skip(1)
9902            .map(joined)
9903            .find(|t| !t.trim().is_empty())
9904            // Words, wherever they start: `“Revenue increased”`, `2024
9905            // results` are items; a cell of figures is not (codex, #2045).
9906            .is_some_and(|t| t.chars().any(char::is_alphabetic));
9907        marked += 1;
9908        is_bullet_or_list_marker(text) && item_follows
9909    });
9910    if markers_first && marked >= 2 {
9911        return false;
9912    }
9913
9914    (dense_slices as f64 / non_empty_slices as f64) >= MIN_DENSE_SLICE_RATIO
9915}
9916
9917/// Whether a cell's whole text is a list's marker: a bullet glyph, a pointer
9918/// or picture the font had no letter for, or a short ordinal like `1.`, `a)`.
9919fn is_bullet_or_list_marker(text: &str) -> bool {
9920    let mut chars = text.chars();
9921    match (chars.next(), chars.next()) {
9922        (Some(c), None) => {
9923            crate::lists::BULLET_CHARS.contains(&c) || crate::lists::is_a_pointer_or_a_picture(c)
9924        }
9925        _ => is_list_marker(text),
9926    }
9927}
9928
9929/// Whether this region is a TABLE rather than a page set in columns (#570).
9930///
9931/// `region_is_ruled` answers the same question from the document's own
9932/// graphics and says nothing at all about a table drawn without ruling lines --
9933/// which is the commonest kind there is, and which `region_is_columnar` reads
9934/// down its columns: the rows of it come back as one paragraph per column, and
9935/// every row of the table is lost. So there is a second answer, and it is made
9936/// of the one measurement that separates the two cases without a ruling line.
9937///
9938/// A LINE OF PROSE FILLS ITS COLUMN AND A CELL DOES NOT. That is the whole of
9939/// it. A paper set in two columns sets each of them to a measure and fills it:
9940/// the slice a band leaves in a column is as wide as the column, to the last
9941/// word of the last line. A table's cells are words and figures, ragged and
9942/// short, and the white to the right of them is what makes the thing look like
9943/// a table to a reader. So: a region whose columns are all filled in most of
9944/// its rows, and whose typical cell covers well under its column, is a grid.
9945///
9946/// Deliberately narrow. Three rows at least, every column present in three
9947/// quarters of them, and the MEDIAN fill under `GRID_SLICE_FILL` -- so one
9948/// short line in a column of prose, or one long cell in a table, decides
9949/// nothing.
9950fn region_is_a_grid(bands: &[TextBand], band_indices: &[usize], boundaries: &[f64]) -> bool {
9951    if band_indices.len() < GRID_MIN_ROWS || boundaries.is_empty() {
9952        return false;
9953    }
9954
9955    let mut left = f64::INFINITY;
9956    let mut right = f64::NEG_INFINITY;
9957    for &band_idx in band_indices {
9958        for span in &bands[band_idx].spans {
9959            left = left.min(span.x);
9960            right = right.max(span.measured_right());
9961        }
9962    }
9963    if !(left.is_finite() && right > left) {
9964        return false;
9965    }
9966    let edges: Vec<f64> = std::iter::once(left)
9967        .chain(boundaries.iter().copied())
9968        .chain(std::iter::once(right))
9969        .collect();
9970
9971    let mut present = vec![0usize; boundaries.len() + 1];
9972    let mut fills: Vec<f64> = Vec::new();
9973    for &band_idx in band_indices {
9974        for (column, slice) in bands[band_idx]
9975            .split_by_boundaries(boundaries)
9976            .iter()
9977            .enumerate()
9978        {
9979            if slice.is_empty() {
9980                continue;
9981            }
9982            present[column] += 1;
9983            let slice_left = slice
9984                .iter()
9985                .map(|span| span.x)
9986                .fold(f64::INFINITY, f64::min);
9987            let slice_right = slice
9988                .iter()
9989                .map(TextSpan::measured_right)
9990                .fold(f64::NEG_INFINITY, f64::max);
9991            let width = edges[column + 1] - edges[column];
9992            if width > 0.0 && slice_right > slice_left {
9993                fills.push(((slice_right - slice_left) / width).min(1.0));
9994            }
9995        }
9996    }
9997
9998    let needed = GRID_COLUMN_SUPPORT * band_indices.len() as f64;
9999    if present.iter().any(|seen| (*seen as f64) < needed) {
10000        return false;
10001    }
10002    if fills.is_empty() {
10003        return false;
10004    }
10005    fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
10006    fills[fills.len() / 2] <= GRID_SLICE_FILL
10007}
10008
10009/// Whether the page rules this region into rows (#545).
10010///
10011/// A rule counts when it crosses the region -- `RULE_SPAN_FRACTION` of the
10012/// region's own width, so a line under a single cell does not speak for the
10013/// table -- and lies between the baselines of two bands that follow each other
10014/// in the region. Enough of the region's band gaps carrying such a rule is the
10015/// document saying, in its own graphics, that these bands are rows; and rows
10016/// are read across, never down.
10017///
10018/// What it does not claim: a table drawn without ruling lines is invisible
10019/// here, and this is deliberately the whole of the claim. The alternative
10020/// offered for that case was a width heuristic, and a width heuristic cannot
10021/// tell a table from a paper set in two columns -- it fixed the Isartor
10022/// appendix and cost `tracemonkey` p2 and p13, which is the class O-B danger
10023/// the #389 report names for exactly this defect.
10024fn region_is_ruled(bands: &[TextBand], band_indices: &[usize], rules: &[HorizontalRule]) -> bool {
10025    if rules.is_empty() || band_indices.len() < MIN_RULED_ROW_SEPARATORS + 1 {
10026        return false;
10027    }
10028
10029    let mut left = f64::INFINITY;
10030    let mut right = f64::NEG_INFINITY;
10031    for &band_idx in band_indices {
10032        for span in &bands[band_idx].spans {
10033            left = left.min(span.x);
10034            right = right.max(span.measured_right());
10035        }
10036    }
10037    let width = right - left;
10038    if width <= 0.0 {
10039        return false;
10040    }
10041
10042    let mut gaps = 0usize;
10043    let mut ruled = 0usize;
10044    for pair in band_indices.windows(2) {
10045        let upper = bands[pair[0]].y;
10046        let lower = bands[pair[1]].y;
10047        if upper <= lower {
10048            continue;
10049        }
10050        gaps += 1;
10051        let crossed = rules.iter().any(|rule| {
10052            rule.y > lower
10053                && rule.y < upper
10054                && (rule.right.min(right) - rule.left.max(left)) >= width * RULE_SPAN_FRACTION
10055        });
10056        if crossed {
10057            ruled += 1;
10058        }
10059    }
10060
10061    gaps > 0
10062        && ruled >= MIN_RULED_ROW_SEPARATORS
10063        && (ruled as f64 / gaps as f64) >= MIN_RULED_GAP_SUPPORT
10064}
10065
10066fn append_column_region_blocks(
10067    bands: &[TextBand],
10068    band_indices: &[usize],
10069    boundaries: &[f64],
10070    blocks: &mut Vec<TextBlock>,
10071) {
10072    let column_count = boundaries.len() + 1;
10073    let mut column_bands = vec![Vec::<TextSpan>::new(); column_count];
10074
10075    for &band_idx in band_indices {
10076        let slices = bands[band_idx].split_by_boundaries(boundaries);
10077        for (column_idx, slice) in slices.into_iter().enumerate() {
10078            if slice.is_empty() {
10079                continue;
10080            }
10081            column_bands[column_idx].push(TextSpan::default());
10082            let marker_idx = column_bands[column_idx].len() - 1;
10083            column_bands[column_idx][marker_idx] = TextSpan {
10084                x: f64::NEG_INFINITY,
10085                y: bands[band_idx].y,
10086                ..TextSpan::default()
10087            };
10088            column_bands[column_idx].extend(slice);
10089        }
10090    }
10091
10092    for spans in column_bands {
10093        let mut current: Vec<TextSpan> = Vec::new();
10094        for span in spans {
10095            if span.x == f64::NEG_INFINITY {
10096                if !current.is_empty() {
10097                    current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
10098                    blocks.push(TextBlock {
10099                        spans: std::mem::take(&mut current),
10100                    });
10101                }
10102                continue;
10103            }
10104            current.push(span);
10105        }
10106        if !current.is_empty() {
10107            current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
10108            blocks.push(TextBlock { spans: current });
10109        }
10110    }
10111}
10112
10113/// Join per-block lines, stitching end-of-line hyphenated word-wraps the
10114/// way pdftotext / MuPDF / PDFBox do.
10115///
10116/// Trigger conditions (all must hold):
10117/// 1. Previous line ends with `-` preceded by an alphabetic character.
10118/// 2. The alphabetic suffix before the `-` has >= 3 characters.
10119/// 3. The next line (trimmed) starts with an ASCII lowercase letter.
10120/// 4. The lowercase prefix of the next line has >= 3 characters.
10121///
10122/// When triggered, the trailing `-` is removed and the two halves are
10123/// concatenated without a space or newline.
10124///
10125/// This avoids false positives on compound words ("real-time"), bullet
10126/// lists, numeric ranges ("42-"), and short fragments.
10127fn stitch_hyphenated_lines(lines: &[String]) -> String {
10128    let mut out = String::new();
10129    for (idx, line) in lines.iter().enumerate() {
10130        if idx == 0 {
10131            out.push_str(line);
10132            continue;
10133        }
10134
10135        let next_trimmed = line.trim_start();
10136
10137        // Check the accumulated output for end-of-line hyphen pattern
10138        let should_merge = is_hyphen_wrap_candidate(&out, next_trimmed);
10139
10140        if should_merge {
10141            out.pop(); // drop the trailing '-'
10142            out.push_str(next_trimmed);
10143        } else {
10144            out.push('\n');
10145            out.push_str(line);
10146        }
10147    }
10148    out
10149}
10150
10151/// Check if the accumulated text ends with a hyphen-wrap pattern and the
10152/// continuation is a valid merge target.
10153pub(crate) fn is_hyphen_wrap_candidate(accumulated: &str, next_trimmed: &str) -> bool {
10154    // Must end with '-'
10155    if !accumulated.ends_with('-') {
10156        return false;
10157    }
10158
10159    // Character before '-' must be alphabetic
10160    let before_hyphen = accumulated.chars().rev().nth(1);
10161    if !before_hyphen.is_some_and(|c| c.is_alphabetic()) {
10162        return false;
10163    }
10164
10165    // Count consecutive alphabetic chars before the '-' (the word fragment)
10166    let alpha_prefix_len = accumulated
10167        .chars()
10168        .rev()
10169        .skip(1) // skip the '-'
10170        .take_while(|c| c.is_alphabetic())
10171        .count();
10172    if alpha_prefix_len < 3 {
10173        return false;
10174    }
10175
10176    // Next line must start with lowercase ASCII
10177    let first_next = next_trimmed.chars().next();
10178    if !first_next.is_some_and(|c| c.is_ascii_lowercase()) {
10179        return false;
10180    }
10181
10182    // Count consecutive lowercase chars at start of next line
10183    let next_alpha_len = next_trimmed
10184        .chars()
10185        .take_while(|c| c.is_ascii_lowercase())
10186        .count();
10187    if next_alpha_len < 3 {
10188        return false;
10189    }
10190
10191    true
10192}
10193
10194/// Normalize extracted text to match pdftotext conventions.
10195///
10196/// 1. Trim trailing whitespace from each line.
10197/// 2. Collapse runs of more than two consecutive newlines into exactly two.
10198/// 3. Preserve form-feed characters (`\x0C`) as page separators.
10199/// 4. End with a single trailing newline (or empty for empty input).
10200pub(crate) fn normalize_text_output(text: &str) -> String {
10201    if text.is_empty() {
10202        return String::new();
10203    }
10204
10205    let mut lines: Vec<&str> = Vec::new();
10206    for line in text.split('\n') {
10207        lines.push(line.trim_end());
10208    }
10209
10210    // Remove trailing empty lines (we'll add exactly one \n at the end)
10211    while lines.last() == Some(&"") {
10212        lines.pop();
10213    }
10214
10215    if lines.is_empty() {
10216        return String::new();
10217    }
10218
10219    let mut result = String::with_capacity(text.len());
10220    let mut consecutive_empty = 0u32;
10221
10222    for (i, line) in lines.iter().enumerate() {
10223        if line.is_empty() || *line == "\x0C" {
10224            if line.is_empty() {
10225                consecutive_empty += 1;
10226                // Collapse >2 consecutive blank lines to 2
10227                if consecutive_empty <= 2 {
10228                    result.push('\n');
10229                }
10230            } else {
10231                // Bare form-feed line
10232                consecutive_empty = 0;
10233                result.push_str(line);
10234                if i + 1 < lines.len() {
10235                    result.push('\n');
10236                }
10237            }
10238        } else {
10239            // Both form-feed-prefixed and regular lines are emitted as-is.
10240            consecutive_empty = 0;
10241            result.push_str(line);
10242            if i + 1 < lines.len() {
10243                result.push('\n');
10244            }
10245        }
10246    }
10247
10248    // Ensure single trailing newline
10249    if !result.is_empty() && !result.ends_with('\n') {
10250        result.push('\n');
10251    }
10252
10253    result
10254}
10255
10256#[cfg(test)]
10257mod tests {
10258    use super::*;
10259
10260    fn span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
10261        TextSpan {
10262            text: text.into(),
10263            x,
10264            y,
10265            width,
10266            height: 12.0,
10267            font_size: 12.0,
10268            ..TextSpan::default()
10269        }
10270    }
10271
10272    /// `span` with one 6 pt glyph box per character from `x` on.
10273    fn boxed(text: &str, x: f64) -> TextSpan {
10274        let n = text.chars().count();
10275        let mut s = span(text, x, 100.0, 6.0 * n as f64);
10276        s.char_bounds = (0..n)
10277            .map(|i| {
10278                let left = x + 6.0 * i as f64;
10279                [left, 100.0, left + 6.0, 112.0]
10280            })
10281            .collect();
10282        s
10283    }
10284
10285    /// A U+20DD that reaches the line assembly is never moved or left out:
10286    /// placing a row-derived mark is the interpreter's (C4 rule 6), so a
10287    /// circle from `ToUnicode` keeps its x order here.
10288    #[test]
10289    fn a_circle_from_another_source_keeps_its_place() {
10290        let mut mark = boxed("\u{20DD}", 98.0);
10291        mark.char_bounds = vec![[98.0, 100.0, 110.0, 112.0]];
10292        mark.width = 12.0;
10293        let enclosing = TextBlock {
10294            spans: vec![mark.clone(), boxed("c", 100.0)],
10295        };
10296        assert!(
10297            enclosing.text().starts_with('\u{20DD}'),
10298            "{:?}",
10299            enclosing.text()
10300        );
10301        let lone = TextBlock { spans: vec![mark] };
10302        assert_eq!(lone.text(), "\u{20DD}");
10303    }
10304
10305    /// A row as a letter-spaced line draws it: one span per glyph, letters
10306    /// `tracking` apart, a word space drawn as a span of its own where
10307    /// `words` part (#1973). `gap` puts that much before the word instead.
10308    fn tracked_row(words: &[&str], tracking: f64, gap: Option<(usize, f64)>) -> TextBlock {
10309        let mut spans = Vec::new();
10310        let mut x = 0.0;
10311        for (w, word) in words.iter().enumerate() {
10312            if w > 0 {
10313                match gap {
10314                    Some((at, wide)) if at == w => x += wide,
10315                    _ => {
10316                        spans.push(span(" ", x, 0.0, 3.0));
10317                        x += 3.0 + tracking;
10318                    }
10319                }
10320            }
10321            for c in word.chars() {
10322                spans.push(span(&c.to_string(), x, 0.0, 8.0));
10323                x += 8.0 + tracking;
10324            }
10325        }
10326        TextBlock { spans }
10327    }
10328
10329    #[test]
10330    fn a_row_tracked_by_drawn_spaces_reads_as_its_words() {
10331        // `P O W E L L  I N D U S T R I E S`: five points of tracking on
10332        // twelve-point capitals is over the word-space threshold, so each
10333        // letter read as a word (#1973). The drawn space says where the words
10334        // part.
10335        let row = tracked_row(&["POWELL", "INDUSTRIES,", "INC."], 5.0, None);
10336        assert_eq!(row.text(), "POWELL INDUSTRIES, INC.");
10337    }
10338
10339    #[test]
10340    fn a_row_of_letters_with_no_drawn_space_is_left_alone() {
10341        // No mark says where words part: letters set apart on purpose.
10342        let row = tracked_row(&["ABCDE"], 5.0, None);
10343        assert_eq!(row.text(), "A B C D E");
10344        // Nor lower case, a formula or a listing, whatever is drawn between.
10345        let row = tracked_row(&["xyzw", "abcd"], 5.0, None);
10346        assert_eq!(row.text(), "x y z w   a b c d");
10347    }
10348
10349    #[test]
10350    fn lower_case_between_drawn_spaces_does_not_make_the_capitals_tracked() {
10351        // Capitals, lower-case letters one apart, capitals again, a space
10352        // drawn between each -- a formula's variables between two labels.
10353        // Only capitals make a tracked row, so neither space is the boundary
10354        // of one.
10355        let row = tracked_row(&["ABCD", "xyzw", "EFGH"], 5.0, None);
10356        assert_eq!(row.text(), "A B C D   x y z w   E F G H");
10357    }
10358
10359    #[test]
10360    fn a_single_letter_after_the_drawn_space_is_not_a_tracked_word() {
10361        // A figure's label `F C F C`, a space, and a one-letter word: nothing
10362        // here is a tracked line of words.
10363        let row = tracked_row(&["FCFC", "S"], 5.0, None);
10364        assert_eq!(row.text(), "F C F C   S");
10365    }
10366
10367    #[test]
10368    fn a_gap_far_wider_than_the_tracking_keeps_its_space() {
10369        // `NEW YORK` and then, with no drawn space but a wide gap, `DESK`: the
10370        // gap is a word boundary the page did not draw.
10371        let row = tracked_row(&["NEW", "YORK", "DESK"], 5.0, Some((2, 40.0)));
10372        assert_eq!(row.text(), "NEW YORK DESK");
10373    }
10374
10375    #[test]
10376    fn letter_spacing_by_space_characters_reads_as_its_words() {
10377        // The same line when the page draws it in one span, a space after
10378        // every capital and two where the words part (#1973).
10379        let mut tracked = span(
10380            "P O W E L L  I N D U S T R I E S ,  I N C .",
10381            0.0,
10382            0.0,
10383            200.0,
10384        );
10385        untrack_spaced_letters(&mut tracked);
10386        assert_eq!(tracked.text, "POWELL INDUSTRIES, INC.");
10387        // A single space everywhere says nothing about the words: left alone.
10388        let mut apart = span("A B C D E F G", 0.0, 0.0, 100.0);
10389        untrack_spaced_letters(&mut apart);
10390        assert_eq!(apart.text, "A B C D E F G");
10391        // Lower case is a formula or a listing.
10392        let mut formula = span("x y z w  a b c d", 0.0, 0.0, 100.0);
10393        untrack_spaced_letters(&mut formula);
10394        assert_eq!(formula.text, "x y z w  a b c d");
10395        // A label beside one letter is not two tracked words (codex, #1974).
10396        let mut label = span("F C F C  S", 0.0, 0.0, 100.0);
10397        untrack_spaced_letters(&mut label);
10398        assert_eq!(label.text, "F C F C  S");
10399    }
10400
10401    /// A span as the consensus leaves a tracked word: every letter a glyph
10402    /// `advance` wide and `gaps[k]` before letter `k + 1`, and a space in the
10403    /// text between each of them (#1973).
10404    fn consensus_spaced(letters: &str, advance: f64, gaps: &[f64], size: f64) -> TextSpan {
10405        let mut x = 0.0;
10406        let mut bounds = Vec::new();
10407        for (k, _) in letters.chars().enumerate() {
10408            bounds.push([x, 0.0, x + advance, size]);
10409            x += advance + gaps.get(k).copied().unwrap_or(0.0);
10410        }
10411        let text = letters
10412            .chars()
10413            .map(String::from)
10414            .collect::<Vec<_>>()
10415            .join(" ");
10416        TextSpan {
10417            char_bounds: bounds,
10418            font_size: size,
10419            ..span(&text, 0.0, 0.0, x)
10420        }
10421    }
10422
10423    #[test]
10424    fn a_short_tracked_word_reads_as_one() {
10425        // `P E N N` at 6.2 points, 0.9 apart: three gaps, never the four #582
10426        // waits for, and a new span at the word space (#1973).
10427        let mut penn = consensus_spaced("PENN", 3.0, &[0.9, 0.9, 0.9], 6.2);
10428        untrack_spaced_letters(&mut penn);
10429        assert_eq!(penn.text, "PENN");
10430        // Kerned pairs sit tighter than the tracking, and are inside the word.
10431        let mut kerned = consensus_spaced(
10432            "OUTSTANDING",
10433            30.0,
10434            &[3.0, 3.0, 3.0, 3.0, 0.6, 3.0, 3.0, 3.0, 1.5, 3.0],
10435            60.0,
10436        );
10437        untrack_spaced_letters(&mut kerned);
10438        assert_eq!(kerned.text, "OUTSTANDING");
10439    }
10440
10441    #[test]
10442    fn a_gap_wider_than_the_tracking_is_a_word_boundary() {
10443        let mut two = consensus_spaced("ABCDEF", 8.0, &[1.0, 1.0, 3.0, 1.0, 1.0], 12.0);
10444        untrack_spaced_letters(&mut two);
10445        assert_eq!(two.text, "ABC DEF");
10446        // As many word gaps as letter gaps (codex, #1980).
10447        let mut short = consensus_spaced("IAMOK", 8.0, &[3.0, 1.0, 3.0, 1.0], 12.0);
10448        untrack_spaced_letters(&mut short);
10449        assert_eq!(short.text, "I AM OK");
10450    }
10451
10452    #[test]
10453    fn a_finished_span_is_left_alone_unless_it_is_tracked_capitals() {
10454        // Set solid: the boxes touch, and the consensus's spaces are words.
10455        let mut solid = consensus_spaced("ABCDE", 8.0, &[0.0, 0.0, 0.0, 0.0], 12.0);
10456        untrack_spaced_letters(&mut solid);
10457        assert_eq!(solid.text, "A B C D E");
10458        // Lower case: a formula's variables.
10459        let mut lower = consensus_spaced("xyzwv", 8.0, &[1.0, 1.0, 1.0, 1.0], 12.0);
10460        untrack_spaced_letters(&mut lower);
10461        assert_eq!(lower.text, "x y z w v");
10462        // Nor capitals beside lower case one apart, a label and a formula.
10463        let mut mixed = consensus_spaced("ABCDxyz", 8.0, &[1.0; 6], 12.0);
10464        untrack_spaced_letters(&mut mixed);
10465        assert_eq!(mixed.text, "A B C D x y z");
10466        // A glyph more than letters: a drawn space or a ligature, whose place
10467        // in the text this cannot know.
10468        let mut drawn = consensus_spaced("ABCDE", 8.0, &[1.0, 1.0, 1.0, 1.0], 12.0);
10469        drawn.char_bounds.push([100.0, 0.0, 104.0, 12.0]);
10470        untrack_spaced_letters(&mut drawn);
10471        assert_eq!(drawn.text, "A B C D E");
10472    }
10473
10474    fn sized(text: &str, x: f64, y: f64, width: f64, size: f64) -> TextSpan {
10475        TextSpan {
10476            font_size: size,
10477            height: size,
10478            ..span(text, x, y, width)
10479        }
10480    }
10481
10482    #[test]
10483    fn a_label_drawn_again_as_its_shadow_is_one_label() {
10484        // FAA-H-8083-6 (DocLayNet train), a moving map: `KFTC` drawn twice,
10485        // the second copy 0.94 right and 0.85 lower at 4.9 points, and read
10486        // `KFTCKFTC` (#1973).
10487        let first = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
10488        let shadow = sized("KFTC", 264.13, 656.97, 12.88, 4.9);
10489        assert!(spans_are_overprint_duplicates(&first, &shadow));
10490        // A size larger, as a halo is: overlapping 77 per cent.
10491        let small = sized("NRST", 385.24, 476.85, 10.93, 4.90);
10492        let large = sized("NRST", 387.79, 477.16, 12.33, 5.52);
10493        assert!(spans_are_overprint_duplicates(&small, &large));
10494    }
10495
10496    #[test]
10497    fn a_column_head_set_twice_half_over_itself_is_one_head() {
10498        // NASDAQ_SHEN_2003 (DocLayNet): `2003` and its copy 7.99 right, 0.86
10499        // higher, overlapping 60 per cent -- read `20032003` (#623).
10500        let first = sized("2003", 346.22, 549.77, 20.16, 10.08);
10501        let copy = sized("2003", 354.21, 550.63, 20.16, 10.08);
10502        assert!(spans_are_overprint_duplicates(&first, &copy));
10503        // Less than half over each other is two words, even of one text.
10504        let left = sized("2003", 346.22, 549.77, 20.16, 10.08);
10505        let right = sized("2003", 358.0, 549.77, 20.16, 10.08);
10506        assert!(!spans_are_overprint_duplicates(&left, &right));
10507        // Two characters keep the stricter share.
10508        let a = sized("12", 100.0, 100.0, 10.0, 10.0);
10509        let b = sized("12", 104.0, 100.3, 10.0, 10.0);
10510        assert!(!spans_are_overprint_duplicates(&a, &b));
10511    }
10512
10513    #[test]
10514    fn a_shadow_needs_the_same_text_of_two_characters_close_by() {
10515        // One character: the two `1`s of a matrix, the `ll` of a word.
10516        let one = sized("1", 100.0, 100.0, 2.5, 5.0);
10517        let other = sized("1", 100.5, 100.9, 2.5, 5.0);
10518        assert!(!spans_are_overprint_duplicates(&one, &other));
10519        // Other text.
10520        let a = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
10521        let b = sized("KCLN", 264.13, 656.97, 12.88, 4.9);
10522        assert!(!spans_are_overprint_duplicates(&a, &b));
10523        // Further off than a quarter em: the next line, not a shadow.
10524        let line = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
10525        let below = sized("KFTC", 263.19, 654.50, 12.88, 4.9);
10526        assert!(!spans_are_overprint_duplicates(&line, &below));
10527    }
10528
10529    #[test]
10530    fn a_tracked_span_that_loses_its_spaces_says_what_its_letters_are() {
10531        // Rich geometry marks a span holding a drawn space an estimate: a space
10532        // has no outline. With the spaces taken out the span is as tight as
10533        // its letters (codex, #1974).
10534        let text = "A B C D  E F G H";
10535        let mut tracked = span(text, 0.0, 0.0, 100.0);
10536        tracked.glyph_bounds_sources = text
10537            .chars()
10538            .map(|c| {
10539                if c == ' ' {
10540                    BoundsSource::Estimate
10541                } else {
10542                    BoundsSource::Tight
10543                }
10544            })
10545            .collect();
10546        tracked.bounds_source = BoundsSource::Estimate;
10547        untrack_spaced_letters(&mut tracked);
10548        assert_eq!(tracked.text, "ABCD EFGH");
10549        assert_eq!(tracked.glyph_bounds_sources.len(), 9);
10550        assert_eq!(
10551            tracked.bounds_source,
10552            BoundsSource::Estimate,
10553            "the one space kept is still a space"
10554        );
10555        let mut all_tight = span("A B C D  E F G H", 0.0, 0.0, 100.0);
10556        all_tight.glyph_bounds_sources = vec![BoundsSource::Tight; 16];
10557        all_tight.bounds_source = BoundsSource::Estimate;
10558        untrack_spaced_letters(&mut all_tight);
10559        assert_eq!(all_tight.bounds_source, BoundsSource::Tight);
10560    }
10561
10562    /// A two-column academic page, the shape #429 is about: a full-width title,
10563    /// then two columns of body text whose right column ends one line short of
10564    /// the left. Every line is a realistic fraction of its column width.
10565    ///
10566    /// Returned in reading order — title, all of column one, then all of column
10567    /// two — so the test can say what the right answer is instead of guessing.
10568    ///
10569    /// The lines are prose-length on purpose. `estimated_width()` is
10570    /// `font_size * 0.5 * chars`, so a realistic line of about fifty characters
10571    /// estimates wider than it measures, and `right()` — which takes the larger
10572    /// of the two — then reaches across the gutter. Short labels would make a
10573    /// tidier fixture that never reproduces the defect.
10574    fn two_column_page() -> Vec<TextSpan> {
10575        const LEFT_X: f64 = 40.0;
10576        const RIGHT_X: f64 = 320.0;
10577        const COLUMN_WIDTH: f64 = 250.0;
10578        const TOP: f64 = 700.0;
10579        const LEADING: f64 = 14.0;
10580        const LINES: usize = 20;
10581
10582        let mut out = vec![span(
10583            "A Trace-based Just-in-Time Compiler",
10584            40.0,
10585            740.0,
10586            530.0,
10587        )];
10588        for i in 0..LINES {
10589            out.push(span(
10590                &format!("left line {i:02} of the two-column academic body text"),
10591                LEFT_X,
10592                TOP - (i as f64) * LEADING,
10593                COLUMN_WIDTH,
10594            ));
10595        }
10596        // One line short: the right column ends above the left, which leaves the
10597        // left column's last band alone on its side of the gutter. Every real
10598        // two-column page has such bands -- a heading, a column that ends early,
10599        // a figure -- and the guard this test is about used to refuse the cut
10600        // for exactly that reason.
10601        for i in 0..(LINES - 1) {
10602            out.push(span(
10603                &format!("right line {i:02} of the two-column academic body text"),
10604                RIGHT_X,
10605                TOP - (i as f64) * LEADING,
10606                COLUMN_WIDTH,
10607            ));
10608        }
10609        out
10610    }
10611
10612    /// #429: a two-column body is read column by column.
10613    ///
10614    /// The whole page in one assertion, because reading order is a property of
10615    /// the whole page and a per-block check would pass while the columns were
10616    /// interleaved. Revert either half of the fix and this goes red: with the
10617    /// gap scan on `right()` no gutter is found at all, and with the old
10618    /// band-aligned guard the cut is refused once it is.
10619    #[test]
10620    fn a_two_column_body_is_read_column_by_column() {
10621        let spans = two_column_page();
10622        let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
10623
10624        let produced: Vec<String> = group_spans_into_blocks(spans, &[])
10625            .into_iter()
10626            .flat_map(|b| b.spans.into_iter().map(|s| s.text))
10627            .collect();
10628
10629        assert_eq!(produced, expected);
10630    }
10631
10632    /// A JOURNAL GUTTER IS NARROWER THAN FOUR PERCENT OF THE PAGE (#389).
10633    ///
10634    /// The shape that the region-width rail refused: 9 pt type, a 493 pt text
10635    /// block, and the 13.2 pt gutter tracemonkey is actually set with. Four
10636    /// percent of that block is 19.7 pt, which is wider than the gutter of any
10637    /// journal, and the cut was refused on pages 2 and 13 of tracemonkey,
10638    /// highlights and tracemonkey_with_editable_annotations -- six of the eleven
10639    /// pages left under 95 % order agreement on the gate set.
10640    ///
10641    /// Returned in reading order, so the test says what the right answer is.
10642    fn narrow_gutter_page() -> Vec<TextSpan> {
10643        narrow_gutter_page_with_gutter(13.2)
10644    }
10645
10646    /// The same page with the gutter set by the caller, so a test can ask for a
10647    /// gap that is not a gutter.
10648    fn narrow_gutter_page_with_gutter(gutter: f64) -> Vec<TextSpan> {
10649        const LEFT_X: f64 = 54.0;
10650        const COLUMN_WIDTH: f64 = 240.0;
10651        const FONT_SIZE: f64 = 9.0;
10652        const TOP: f64 = 700.0;
10653        const LEADING: f64 = 11.0;
10654        const LINES: usize = 20;
10655
10656        let right_x = LEFT_X + COLUMN_WIDTH + gutter;
10657        let line = |text: &str, x: f64, y: f64| TextSpan {
10658            text: text.into(),
10659            x,
10660            y,
10661            width: COLUMN_WIDTH,
10662            height: FONT_SIZE,
10663            font_size: FONT_SIZE,
10664            ..TextSpan::default()
10665        };
10666
10667        let mut out = vec![TextSpan {
10668            text: "Trace-based Just-in-Time Type Specialization".into(),
10669            x: LEFT_X,
10670            y: 740.0,
10671            width: 490.0,
10672            height: 14.0,
10673            font_size: 14.0,
10674            ..TextSpan::default()
10675        }];
10676        // PROSE-LENGTH LINES, for the same reason `two_column_page` gives them:
10677        // `estimated_width()` is font_size * 0.5 * chars, so a real line of
10678        // sixty characters overshoots its measured width and the overshoot
10679        // swallows the gutter -- which is how a page ends up with no measurable
10680        // word gaps at all and the adaptive threshold falls back to 20 pt.
10681        for i in 0..LINES {
10682            out.push(line(
10683                &format!("left line {i:02} of the two-column academic body of this paper"),
10684                LEFT_X,
10685                TOP - (i as f64) * LEADING,
10686            ));
10687        }
10688        for i in 0..(LINES - 1) {
10689            out.push(line(
10690                &format!("right line {i:02} of the two-column academic body of this paper"),
10691                right_x,
10692                TOP - (i as f64) * LEADING,
10693            ));
10694        }
10695        out
10696    }
10697
10698    /// Red the moment a gutter has to be a fraction of the region again.
10699    ///
10700    /// Asked of `try_vertical_cut` itself, so that it names the cut and not the
10701    /// page: the reading order below is the consequence and this is the cause.
10702    #[test]
10703    fn a_thirteen_point_gutter_in_nine_point_type_is_a_gutter() {
10704        let spans = narrow_gutter_page();
10705        let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
10706        let stats = PageStats::from_spans(&body);
10707
10708        let (groups, gap) = try_vertical_cut(&body, &stats, &[])
10709            .expect("no column cut found on a 13.2 pt gutter in 9 pt type");
10710
10711        assert_eq!(groups.len(), 2);
10712        assert!(
10713            (gap - 13.2).abs() < 0.5,
10714            "the cut was taken on a gap of {gap}, not on the gutter"
10715        );
10716        assert!(
10717            groups[0].iter().all(|s| s.text.starts_with("left")),
10718            "the left group holds text from the right column"
10719        );
10720        assert!(
10721            groups[1].iter().all(|s| s.text.starts_with("right")),
10722            "the right group holds text from the left column"
10723        );
10724    }
10725
10726    /// And the whole page, in reading order, through the path a document takes.
10727    /// Red with the rail back as well: the leaf's own band-by-band gutter
10728    /// detector does not rescue a page whose lines overshoot their measured
10729    /// width, which is every page set in a proportional face.
10730    #[test]
10731    fn the_narrow_gutter_page_reads_column_by_column() {
10732        let spans = narrow_gutter_page();
10733        let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
10734
10735        let produced: Vec<String> = group_spans_into_blocks(spans, &[])
10736            .into_iter()
10737            .flat_map(|b| b.spans.into_iter().map(|s| s.text))
10738            .collect();
10739
10740        assert_eq!(produced, expected);
10741    }
10742
10743    /// And the floor still does its work: the same page whose columns are six
10744    /// points apart is not two columns, because six points is a word space in
10745    /// nine point type. Red if the floor is dropped along with the rail.
10746    #[test]
10747    fn a_gap_narrower_than_the_type_is_not_a_gutter() {
10748        let spans = narrow_gutter_page_with_gutter(6.0);
10749        let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
10750        let stats = PageStats::from_spans(&body);
10751
10752        assert!(
10753            try_vertical_cut(&body, &stats, &[]).is_none(),
10754            "a six point gap in nine point type was taken for a column gutter"
10755        );
10756    }
10757
10758    fn nine(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
10759        TextSpan {
10760            height: 9.0,
10761            font_size: 9.0,
10762            ..span(text, x, y, width)
10763        }
10764    }
10765
10766    /// Two columns whose text is 23 pt apart, with the right column's bullets
10767    /// hung 8 pt from the left column's measure and the space after each
10768    /// drawn as a run of its own (NYSE_MSA, #574). Counted as ink, they leave
10769    /// no x free of every span.
10770    fn columns_with_hung_markers() -> Vec<TextSpan> {
10771        let mut out = Vec::new();
10772        for i in 0..12 {
10773            let y = 700.0 - 12.0 * i as f64;
10774            out.push(nine(
10775                &format!("left line {i:02} of the running text of this annual report"),
10776                72.0,
10777                y,
10778                225.0,
10779            ));
10780            if i % 4 == 0 {
10781                out.push(nine("t", 305.0, y, 3.3));
10782                out.push(nine("  ", 308.3, y, 5.0));
10783            }
10784            out.push(nine(
10785                &format!("right line {i:02} of the running text of this annual report"),
10786                320.0,
10787                y,
10788                225.0,
10789            ));
10790        }
10791        out
10792    }
10793
10794    #[test]
10795    fn a_gutter_is_found_past_the_markers_hung_into_it() {
10796        let body = columns_with_hung_markers();
10797        let stats = PageStats::from_spans(&body);
10798        let (groups, _) = try_vertical_cut(&body, &stats, &[])
10799            .expect("no cut past the hung markers and their space runs");
10800        assert_eq!(groups.len(), 2);
10801        assert!(
10802            groups[0].iter().all(|s| s.text.starts_with("left")),
10803            "a hung marker went to the left column"
10804        );
10805    }
10806
10807    /// The looking-past is for columns of text. NYSE_JWN's exhibit index has a
10808    /// right column of `Filed herewith electronically` on every row and one
10809    /// space drawn in the strip on its head row; looked past, the strip is
10810    /// white, and cutting there read every filing method after every exhibit.
10811    #[test]
10812    fn a_column_of_cells_found_past_a_space_is_not_cut_off() {
10813        let mut body = vec![
10814            nine("Exhibit", 192.0, 728.0, 28.0),
10815            nine(" ", 326.0, 728.0, 2.0),
10816            nine("Method of Filing", 413.0, 728.0, 63.0),
10817        ];
10818        for i in 0..10 {
10819            let y = 713.0 - 18.0 * i as f64;
10820            if i % 3 == 2 {
10821                // `101.SCH XBRL Taxonomy ...`: number and description one run,
10822                // across the white that would otherwise part them.
10823                body.push(nine(
10824                    "101.SCH XBRL Taxonomy Extension Schema Document",
10825                    52.0,
10826                    y,
10827                    213.0,
10828                ));
10829            } else {
10830                body.push(nine(&format!("{i}.1"), 52.0, y, 16.0));
10831                body.push(nine(
10832                    "Certification of Chief Financial Officer required",
10833                    92.0,
10834                    y,
10835                    226.0,
10836                ));
10837            }
10838            body.push(nine("Filed herewith electronically", 330.0, y, 100.0));
10839        }
10840        let stats = PageStats::from_spans(&body);
10841        assert!(
10842            try_vertical_cut(&body, &stats, &[]).is_none(),
10843            "the cell column of a table was cut off as a column of text"
10844        );
10845    }
10846
10847    /// The last glyph of a line is not a marker hung into the gutter (codex,
10848    /// #2041). A left column whose lines close on `)` in a run of their own,
10849    /// 9 pt from the right column: looked past, the `)` would open a 15 pt
10850    /// strip and take a gutter the page does not have.
10851    #[test]
10852    fn a_closing_glyph_is_not_a_hung_marker() {
10853        let mut body = Vec::new();
10854        for i in 0..10 {
10855            let y = 700.0 - 12.0 * i as f64;
10856            // Ragged, so the closing glyphs stand at no one x.
10857            let width = [221.0, 204.0, 213.0, 196.0, 218.0][i % 5];
10858            body.push(nine(
10859                &format!("left line {i:02} of the running text of this annual report (see"),
10860                72.0,
10861                y,
10862                width,
10863            ));
10864            body.push(nine(")", 72.0 + width + 1.0, y, 3.0));
10865            body.push(nine(
10866                &format!("right line {i:02} of the running text of this annual report"),
10867                305.0,
10868                y,
10869                225.0,
10870            ));
10871        }
10872        let stats = PageStats::from_spans(&body);
10873        assert!(
10874            try_vertical_cut(&body, &stats, &[]).is_none(),
10875            "a line's closing glyph was looked past as a hung marker"
10876        );
10877    }
10878
10879    /// A statement whose cells are parted by tab runs is not two columns of
10880    /// text (DocLayNet validation, #574). Every gap between its amounts holds
10881    /// a `\t` drawn as a run of its own; looked past as word spaces they left
10882    /// every column gap clear, and rows of figures as long as a line of prose
10883    /// passed for running text -- the statement was cut down its middle. A
10884    /// column of text is counted in letters.
10885    #[test]
10886    fn a_statement_parted_by_tab_runs_is_not_cut() {
10887        let mut body = Vec::new();
10888        for i in 0..10 {
10889            let y = 700.0 - 12.0 * i as f64;
10890            body.push(nine(
10891                &format!("Corporate, other and eliminations ({i:02})"),
10892                15.0,
10893                y,
10894                106.0,
10895            ));
10896            let mut x = 125.0;
10897            for cell in [
10898                "$ 438,082",
10899                "$ 9,836,950",
10900                "$ 3,304,152",
10901                "$ 5,919,819",
10902                "$ 429,405",
10903                "$ 3,692,551",
10904                "$ 840,333",
10905                "$ 1,049,328",
10906                "$ 6,086,009",
10907            ] {
10908                body.push(nine(cell, x, y, 38.0));
10909                body.push(nine("\t", x + 44.0, y, 2.4));
10910                x += 51.0;
10911            }
10912        }
10913        let stats = PageStats::from_spans(&body);
10914        assert!(
10915            try_vertical_cut(&body, &stats, &[]).is_none(),
10916            "a statement parted by tab runs was cut as two columns of text"
10917        );
10918    }
10919
10920    /// A `–` for nil down a column of amounts is a cell, not a list's marker
10921    /// (DocLayNet validation, #574): read as a list the statement's figures
10922    /// lost their columns.
10923    #[test]
10924    fn a_column_of_nil_dashes_is_not_a_list() {
10925        let mut spans = Vec::new();
10926        for i in 0..5 {
10927            let y = 700.0 - 14.0 * i as f64;
10928            spans.push(nine("\u{2013}", 300.0, y, 5.0));
10929            spans.push(nine(&format!("22,795,60{i}"), 360.0, y, 40.0));
10930            spans.push(nine(&format!("22,788,10{i}"), 430.0, y, 40.0));
10931        }
10932        assert!(
10933            !is_bullet_or_list_marker("22,795,604"),
10934            "the fixture's figures read as markers"
10935        );
10936        let bands = group_spans_into_bands(spans);
10937        let indices: Vec<usize> = (0..bands.len()).collect();
10938        let boundaries = vec![330.0, 415.0];
10939        let gaps = indices.len();
10940        // A column of dashes beside figures is still columnar.
10941        assert!(
10942            region_is_columnar(&bands, &indices, &boundaries, gaps),
10943            "a column of nil dashes beside amounts was taken for a list"
10944        );
10945    }
10946
10947    /// Rows of amounts count as rows with no letters (codex, #2045): left out
10948    /// of the sample, one long header on each side would stand for a whole
10949    /// statement of figures and pass it off as two columns of text.
10950    #[test]
10951    fn rows_of_amounts_keep_a_statement_from_reading_as_text() {
10952        let mut body = vec![
10953            nine(
10954                "Consolidated statement of financial position for the year",
10955                15.0,
10956                712.0,
10957                240.0,
10958            ),
10959            nine(
10960                "Consolidated statement of comprehensive income for the year",
10961                300.0,
10962                712.0,
10963                240.0,
10964            ),
10965        ];
10966        for i in 0..9 {
10967            let y = 700.0 - 12.0 * i as f64;
10968            let mut x = 15.0;
10969            for cell in [
10970                "438,082",
10971                "9,836,950",
10972                "3,304,152",
10973                "5,919,819",
10974                "429,405",
10975                "3,692,551",
10976                "840,333",
10977                "1,049,328",
10978                "6,086,009",
10979                "1,360,471",
10980            ] {
10981                body.push(nine(cell, x, y, 44.0));
10982                body.push(nine("\t", x + 48.0, y, 2.4));
10983                x += 54.0;
10984            }
10985        }
10986        let stats = PageStats::from_spans(&body);
10987        assert!(
10988            try_vertical_cut(&body, &stats, &[]).is_none(),
10989            "a statement of figures under two long headers was cut as two columns of text"
10990        );
10991    }
10992
10993    /// An item may open with a quotation mark or a figure (codex, #2045):
10994    /// `“Revenue increased”` beside a column of bullets is still an item, and
10995    /// the bullets are still a list's.
10996    #[test]
10997    fn an_item_that_opens_with_punctuation_keeps_its_bullet() {
10998        let sized = |text: &str, x: f64, y: f64, width: f64, size: f64| TextSpan {
10999            height: size,
11000            font_size: size,
11001            ..span(text, x, y, width)
11002        };
11003        let mut spans = vec![sized(
11004            "The following items were reported during the course of the Financial Year:",
11005            86.8,
11006            720.0,
11007            313.3,
11008            10.2,
11009        )];
11010        for (k, (item, width)) in [
11011            ("\u{201c}Revenue increased\u{201d} across the group;", 190.0),
11012            ("(Formerly the Northern Division) was sold;", 180.0),
11013            ("2024 results were restated for the change.", 175.0),
11014            (
11015                "\u{201c}Margins improved\u{201d} in the second half.",
11016                170.0,
11017            ),
11018        ]
11019        .iter()
11020        .enumerate()
11021        {
11022            let y = 685.6 - 34.4 * k as f64;
11023            spans.push(sized("\u{2022}", 104.9, y - 4.5, 13.0, 19.0));
11024            spans.push(sized(item, 131.5, y, *width, 10.2));
11025        }
11026        for k in 0..4 {
11027            spans.push(sized(
11028                "Other than detailed in the Chairman's Report set out at pages 1 and 2 of this report",
11029                86.8,
11030                548.0 - 17.2 * k as f64,
11031                394.8,
11032                10.2,
11033            ));
11034        }
11035        let blocks = group_spans_into_blocks(spans, &[]);
11036        let bare = blocks
11037            .iter()
11038            .filter(|b| b.text().trim() == "\u{2022}")
11039            .count();
11040        assert_eq!(
11041            bare,
11042            0,
11043            "items opening with punctuation lost their bullets: {:?}",
11044            blocks.iter().map(|b| b.text()).collect::<Vec<_>>()
11045        );
11046    }
11047
11048    /// A column of bullets is a list, not a column (#574).
11049    ///
11050    /// ASX_MRM's spans as the engine reads them: a 19 pt bullet 13 pt before
11051    /// each 10 pt item, its baseline 4.5 pt under the item's, and body lines
11052    /// round it. Every band of the list has a gap at one x, and read as two
11053    /// columns the list came back as four bullets and then four items.
11054    #[test]
11055    fn a_column_of_bullets_is_read_with_its_items() {
11056        let sized = |text: &str, x: f64, y: f64, width: f64, size: f64| TextSpan {
11057            height: size,
11058            font_size: size,
11059            ..span(text, x, y, width)
11060        };
11061        let mut spans = vec![
11062            sized(
11063                "Mermaid's principal activities during the course of the Financial Year were:",
11064                86.8,
11065                720.0,
11066                313.3,
11067                10.2,
11068            ),
11069            sized("PRINCIPAL ACTIVITIES", 416.8, 718.6, 123.3, 9.1),
11070        ];
11071        for (k, (item, width)) in [
11072            ("Operating crewed vessel charters;", 138.4),
11073            ("Vessel manning, management and logistics;", 179.8),
11074            ("Operating supply base facilities; and", 149.1),
11075            ("Equipment hire.", 67.1),
11076        ]
11077        .iter()
11078        .enumerate()
11079        {
11080            let y = 685.6 - 34.4 * k as f64;
11081            spans.push(sized("\u{2022}", 104.9, y - 4.5, 13.0, 19.0));
11082            spans.push(sized(item, 131.5, y, *width, 10.2));
11083        }
11084        for k in 0..4 {
11085            spans.push(sized(
11086                "Other than detailed in the Chairman's Report set out at pages 1 and 2 of this report",
11087                86.8,
11088                548.0 - 17.2 * k as f64,
11089                394.8,
11090                10.2,
11091            ));
11092        }
11093        let blocks = group_spans_into_blocks(spans, &[]);
11094        let bare = blocks
11095            .iter()
11096            .filter(|b| b.text().trim() == "\u{2022}")
11097            .count();
11098        assert_eq!(
11099            bare,
11100            0,
11101            "the bullets were read as a column of their own: {:?}",
11102            blocks.iter().map(|b| b.text()).collect::<Vec<_>>()
11103        );
11104    }
11105
11106    /// A line number peeled off the line it was drawn into leaves a line whose
11107    /// transform still locates its own first glyph (codex, #1991).
11108    #[test]
11109    fn a_peeled_line_keeps_its_origin_on_its_first_glyph() {
11110        let glued = TextSpan {
11111            text: "10 example, aluminum and steel".into(),
11112            x: 298.8,
11113            y: 100.0,
11114            width: 229.4,
11115            height: 8.5,
11116            font_size: 8.5,
11117            transform: Some([8.5, 0.0, 0.0, 8.5, 298.8, 100.0]),
11118            char_bounds: std::iter::once([298.8, 100.0, 302.0, 108.5])
11119                .chain(std::iter::once([302.0, 100.0, 305.2, 108.5]))
11120                .chain((0..27).map(|k| {
11121                    let x = 310.9 + k as f64 * 4.0;
11122                    [x, 100.0, x + 4.0, 108.5]
11123                }))
11124                .collect(),
11125            ..TextSpan::default()
11126        };
11127        let mut spans = vec![glued];
11128        for i in 0..3 {
11129            spans.push(TextSpan {
11130                text: "a line of the right column".into(),
11131                x: 310.9,
11132                y: 90.0 - i as f64 * 10.0,
11133                width: 200.0,
11134                height: 8.5,
11135                font_size: 8.5,
11136                ..TextSpan::default()
11137            });
11138        }
11139        let (figure, rest) = peel_one(&spans[0], 0, &spans).expect("the figure is peeled");
11140        assert_eq!(figure.text, "10");
11141        assert_eq!(rest.text, "example, aluminum and steel");
11142        assert!((rest.x - 310.9).abs() < 1e-9);
11143        let t = rest.transform.expect("a transform");
11144        assert!(
11145            (t[4] - rest.x).abs() < 1e-9 && (t[5] - 100.0).abs() < 1e-9,
11146            "the rest's transform still points at the figure: {t:?}"
11147        );
11148    }
11149
11150    /// A column the margin scan finds is a column of line numbers only when
11151    /// every member it found is a figure (codex, #1991): `i`, `1`, `ii` down a
11152    /// list is a list's markers, and one digit among them numbers no page.
11153    #[test]
11154    fn a_margin_column_of_mixed_labels_is_not_figures() {
11155        let band = |y: f64, label: &str| {
11156            let mut b = TextBand::new(TextSpan {
11157                text: "a line of the left column running its measure".into(),
11158                x: 77.0,
11159                y,
11160                width: 200.0,
11161                height: 9.0,
11162                font_size: 9.0,
11163                ..TextSpan::default()
11164            });
11165            b.spans.push(TextSpan {
11166                text: label.into(),
11167                x: 288.0,
11168                y,
11169                width: 6.0,
11170                height: 9.0,
11171                font_size: 9.0,
11172                ..TextSpan::default()
11173            });
11174            b.spans.push(TextSpan {
11175                text: "a line of the right column running its measure".into(),
11176                x: 300.0,
11177                y,
11178                width: 200.0,
11179                height: 9.0,
11180                font_size: 9.0,
11181                ..TextSpan::default()
11182            });
11183            b
11184        };
11185        let column = |labels: &[&str]| {
11186            let bands: Vec<TextBand> = labels
11187                .iter()
11188                .enumerate()
11189                .map(|(i, l)| band(700.0 - i as f64 * 11.0, l))
11190                .collect();
11191            a_margin_column(&bands, 423.0, 10.0, 2.5)
11192        };
11193        let (skipped, figures) = column(&["5", "10", "15", "20"]);
11194        assert!(figures && skipped.iter().all(|b| b.len() == 1));
11195        let (_, figures) = column(&["i", "1", "ii", "iii"]);
11196        assert!(!figures, "a mixed column was read as a column of figures");
11197    }
11198
11199    /// Beside a bank of figures, the gutter's column is still the margin when it
11200    /// COUNTS the lines, and not when it is a table's row numbers (#574).
11201    #[test]
11202    fn a_counting_column_beside_a_bank_of_figures_is_the_margin() {
11203        let span = |text: &str, x: f64, width: f64, y: f64| TextSpan {
11204            text: text.into(),
11205            x,
11206            y,
11207            width,
11208            height: 9.0,
11209            font_size: 9.0,
11210            ..TextSpan::default()
11211        };
11212        let band = |y: f64, figure: &str, label: &str| {
11213            let mut b = TextBand::new(span("a line of the", 77.0, 60.0, y));
11214            b.spans.push(span(figure, 150.0, 12.0, y));
11215            b.spans
11216                .push(span("left column running its measure", 170.0, 107.0, y));
11217            b.spans.push(span(label, 288.0, 6.0, y));
11218            b.spans.push(span(
11219                "a line of the right column running its measure",
11220                300.0,
11221                200.0,
11222                y,
11223            ));
11224            b
11225        };
11226        let column = |labels: &[&str]| {
11227            let bands: Vec<TextBand> = ["0.0", "4.0", "64.9", "3.8"]
11228                .iter()
11229                .zip(labels)
11230                .enumerate()
11231                .map(|(i, (f, l))| band(700.0 - i as f64 * 11.0, f, l))
11232                .collect();
11233            a_margin_column(&bands, 423.0, 10.0, 2.5)
11234        };
11235        let (skipped, figures) = column(&["5", "10", "15", "20"]);
11236        assert!(
11237            figures && skipped.iter().all(|b| b.len() == 1 && b.contains(&3)),
11238            "the line numbers beside a bank were not the margin: {skipped:?}"
11239        );
11240        let (skipped, _) = column(&["1", "2", "3", "4"]);
11241        assert!(
11242            skipped.iter().all(HashSet::is_empty),
11243            "a table's row numbers were taken for line numbers: {skipped:?}"
11244        );
11245        let (skipped, _) = column(&["2018", "2020", "2022", "2024"]);
11246        assert!(
11247            skipped.iter().all(HashSet::is_empty),
11248            "a column of years was taken for line numbers: {skipped:?}"
11249        );
11250    }
11251
11252    /// A span the gap scan skipped is not a cell boundary of a bank (#582).
11253    ///
11254    /// The caller that skips a thin column standing IN a gutter -- printed line
11255    /// numbers down the middle of a statute, which occupy every x of the gutter
11256    /// and make the cut unfindable -- has to say so here too. Counted as ink, a
11257    /// column beside the gutter puts a wide strip of white between itself and
11258    /// the rest of its side on every band, which is exactly the shape
11259    /// `the_cut_is_a_column_of_a_table` refuses a cut for: the guard would
11260    /// refuse the cut that skipping the column was meant to allow.
11261    #[test]
11262    fn a_span_the_scan_skipped_is_not_a_cell_boundary() {
11263        // Four bands. Each carries a line of the left column, a line number
11264        // sitting in the gutter, and a line of the right column.
11265        let mut spans = Vec::new();
11266        for band in 0..4 {
11267            let y = 700.0 - band as f64 * 14.0;
11268            spans.push(span("the committee considered the whole", 72.0, y, 150.0));
11269            spans.push(span("12", 240.0, y, 10.0));
11270            spans.push(span("of the proposals put before it and", 300.0, y, 150.0));
11271        }
11272        let stats = PageStats::from_spans(&spans);
11273        let bands = group_spans_into_bands_with_stats(spans.clone(), &stats);
11274        let cut_x = 270.0;
11275        let gap = 50.0;
11276
11277        // With the line numbers counted as ink, the left side of the cut holds
11278        // two runs a band -- the text and the number -- with 18 points of white
11279        // between them, and each side reads as a bank of cells.
11280        let counted = one_side_of_a_cut(&bands, cut_x, true, cut_x - 72.0, 18.0, &[])
11281            .expect("the left side has ink on every band");
11282        assert_eq!(
11283            (counted.inked, counted.broken),
11284            (4, 4),
11285            "the line numbers are not being read as a second run of ink"
11286        );
11287
11288        // Skipped, the side is one run of ink a band and breaks nowhere, so the
11289        // guard has nothing to refuse the cut for.
11290        // One set per band, which is the whole point: the index of the line
11291        // number is not the same number on every band.
11292        let skipped: Vec<HashSet<usize>> = bands
11293            .iter()
11294            .map(|band| {
11295                band.spans
11296                    .iter()
11297                    .enumerate()
11298                    .filter(|(_, s)| s.text.trim() == "12")
11299                    .map(|(i, _)| i)
11300                    .collect()
11301            })
11302            .collect();
11303        let without = one_side_of_a_cut(&bands, cut_x, true, cut_x - 72.0, 18.0, &skipped)
11304            .expect("the left side still has ink on every band");
11305        assert_eq!(
11306            (without.inked, without.broken),
11307            (4, 0),
11308            "a span the scan skipped is still being measured as a cell boundary"
11309        );
11310        assert!(
11311            !the_cut_is_a_column_of_a_table(&bands, cut_x, gap, &skipped),
11312            "the cut was refused for a column the scan had already skipped"
11313        );
11314    }
11315
11316    /// The gutter is only there in the MEASURED edges. `right()` returns
11317    /// x + width.max(estimated_width()), and on tracemonkey's abstract that
11318    /// estimate overshoots the measured edge by 61pt -- wider than the gutter
11319    /// it then covers, so the scan finds no corridor and the guards never run.
11320    #[test]
11321    fn a_column_gap_is_found_on_measured_edges_not_estimated_ones() {
11322        let spans = two_column_page();
11323        let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
11324        let stats = PageStats::from_spans(&body);
11325
11326        let overshoots = body.iter().any(|s| s.right() > s.measured_right() + 1.0);
11327        assert!(
11328            overshoots,
11329            "fixture no longer exercises the overshoot; right() must exceed measured_right()              for this test to mean anything"
11330        );
11331
11332        let cut = try_vertical_cut(&body, &stats, &[]);
11333        assert!(
11334            cut.is_some(),
11335            "no column cut found on a clean two-column body"
11336        );
11337    }
11338
11339    fn block_texts(spans: Vec<TextSpan>) -> Vec<String> {
11340        block_texts_with_rules(spans, &[])
11341    }
11342
11343    /// The same, with the horizontal rules the page drew (#545).
11344    fn block_texts_with_rules(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<String> {
11345        group_spans_into_blocks(spans, rules)
11346            .into_iter()
11347            .map(|block| block.text())
11348            .collect()
11349    }
11350
11351    /// The same, but only the leaf of the XY-cut: the bands as
11352    /// `group_spans_into_blocks_legacy_from_bands` sees them.
11353    ///
11354    /// Some of the shapes below are refused by the leaf and taken by the column
11355    /// cut above it instead, which proves nothing about the leaf. This says what
11356    /// the leaf does with them.
11357    fn leaf_texts(spans: Vec<TextSpan>) -> Vec<String> {
11358        let bands = group_spans_into_bands(spans);
11359        group_spans_into_blocks_legacy_from_bands(bands, &[])
11360            .iter()
11361            .map(TextBlock::text)
11362            .collect()
11363    }
11364
11365    #[test]
11366    fn empty_device_produces_empty_text() {
11367        let dev = TextExtractionDevice::new();
11368        assert!(dev.into_text().is_empty());
11369    }
11370
11371    #[test]
11372    fn single_column_stays_row_major() {
11373        let texts = block_texts(vec![
11374            span("Single Column Line 1", 40.0, 700.0, 140.0),
11375            span("Single Column Line 2", 40.0, 684.0, 140.0),
11376            span("Single Column Line 3", 40.0, 668.0, 140.0),
11377        ]);
11378
11379        assert_eq!(
11380            texts,
11381            vec![
11382                "Single Column Line 1",
11383                "Single Column Line 2",
11384                "Single Column Line 3",
11385            ]
11386        );
11387    }
11388
11389    /// A header, three bands each of which draws two slices with a gutter
11390    /// between them, and a footer. Read as columns unless the page says
11391    /// otherwise, which is what the three tests after this one are about.
11392    fn two_column_region_spans() -> Vec<TextSpan> {
11393        vec![
11394            span("Header", 200.0, 740.0, 80.0),
11395            span("Left column line one", 40.0, 700.0, 115.0),
11396            span("Right column line one", 320.0, 700.0, 120.0),
11397            span("Left column line two", 40.0, 684.0, 115.0),
11398            span("Right column line two", 320.0, 684.0, 120.0),
11399            span("Left column line three", 40.0, 668.0, 125.0),
11400            span("Right column line three", 320.0, 668.0, 130.0),
11401            span("Footer", 200.0, 620.0, 80.0),
11402        ]
11403    }
11404
11405    #[test]
11406    fn two_column_region_reads_column_major() {
11407        let texts = block_texts(two_column_region_spans());
11408
11409        assert_eq!(
11410            texts,
11411            vec![
11412                "Header",
11413                "Left column line one",
11414                "Left column line two",
11415                "Left column line three",
11416                "Right column line one",
11417                "Right column line two",
11418                "Right column line three",
11419                "Footer",
11420            ]
11421        );
11422    }
11423
11424    /// The rules the page draws between its rows, for the fixture above: a
11425    /// horizontal line across the whole table, in every gap between two rows.
11426    fn rules_between_the_rows() -> Vec<HorizontalRule> {
11427        [692.0, 676.0]
11428            .into_iter()
11429            .map(|y| HorizontalRule {
11430                left: 35.0,
11431                right: 445.0,
11432                y,
11433            })
11434            .collect()
11435    }
11436
11437    /// The same three bands that read column by column above, with a ruling
11438    /// line between each pair of them, read across instead (#545). This is the
11439    /// Isartor appendix: two columns of text that are the two cells of a row,
11440    /// and nothing but the page's own graphics says so.
11441    #[test]
11442    fn a_ruled_two_column_region_is_read_row_by_row() {
11443        let texts = block_texts_with_rules(two_column_region_spans(), &rules_between_the_rows());
11444
11445        assert_eq!(
11446            texts,
11447            vec![
11448                "Header",
11449                "Left column line one Right column line one",
11450                "Left column line two Right column line two",
11451                "Left column line three Right column line three",
11452                "Footer",
11453            ]
11454        );
11455    }
11456
11457    /// A rule under one cell is not a rule between two rows. Widening the test
11458    /// to any line that happens to sit in the gap turns every underlined cell
11459    /// into a table, so the rule has to cross the region it claims to divide.
11460    #[test]
11461    fn a_rule_that_crosses_only_one_column_leaves_the_columns_alone() {
11462        let short: Vec<HorizontalRule> = rules_between_the_rows()
11463            .into_iter()
11464            .map(|rule| HorizontalRule {
11465                right: 160.0,
11466                ..rule
11467            })
11468            .collect();
11469
11470        let texts = block_texts_with_rules(two_column_region_spans(), &short);
11471
11472        assert_eq!(
11473            texts,
11474            vec![
11475                "Header",
11476                "Left column line one",
11477                "Left column line two",
11478                "Left column line three",
11479                "Right column line one",
11480                "Right column line two",
11481                "Right column line three",
11482                "Footer",
11483            ]
11484        );
11485    }
11486
11487    /// One rule is a headline's underline, a footnote's separator, the line
11488    /// above a page number -- every document has them, and none of them makes
11489    /// the text below a table.
11490    ///
11491    /// Asked of the predicate rather than of the page, because a region of
11492    /// three bands with one rule in it is refused by the column cut above the
11493    /// leaf for its own reasons, and a page-level assertion would be green
11494    /// whatever this rule said.
11495    #[test]
11496    fn a_single_rule_does_not_make_a_region_a_table() {
11497        let bands = group_spans_into_bands(two_column_region_spans());
11498        let rows: Vec<usize> = bands
11499            .iter()
11500            .enumerate()
11501            .filter(|(_, band)| band.y < 720.0 && band.y > 640.0)
11502            .map(|(idx, _)| idx)
11503            .collect();
11504        assert_eq!(
11505            rows.len(),
11506            3,
11507            "the fixture's three rows are the region here"
11508        );
11509
11510        let both = rules_between_the_rows();
11511        assert!(
11512            region_is_ruled(&bands, &rows, &both),
11513            "a rule in every gap of the region is a ruled table"
11514        );
11515        assert!(
11516            !region_is_ruled(&bands, &rows, &both[..1]),
11517            "one rule is not a table"
11518        );
11519    }
11520
11521    /// One path may hold every rule a table draws, so a cap asked once per
11522    /// `draw_path` bounds nothing. The vector it would grow is walked per band
11523    /// by `region_is_ruled`, which is why the bound is on the vector and not on
11524    /// the call.
11525    #[test]
11526    fn one_path_of_many_rules_still_stops_at_the_cap() {
11527        let mut path = BezPath::new();
11528        for row in 0..(MAX_RECORDED_RULES * 2) {
11529            let y = 700.0 - row as f64;
11530            path.move_to((40.0, y));
11531            path.line_to((440.0, y));
11532            path.close_path();
11533        }
11534
11535        let mut device = TextExtractionDevice::new();
11536        device.record_rule(&path, Affine::IDENTITY);
11537
11538        assert_eq!(device.rules.len(), MAX_RECORDED_RULES);
11539    }
11540
11541    /// A Form XObject's `/BBox` reaches this device as a clip, with the
11542    /// geometry inside it unclipped. A line ruled across one cell of such a
11543    /// form therefore arrives as a line across the page, and two of those are
11544    /// enough to refuse a column cut that should have been taken.
11545    #[test]
11546    fn a_rule_is_recorded_at_the_width_the_clip_leaves_it() {
11547        let mut wide = BezPath::new();
11548        wide.move_to((40.0, 600.0));
11549        wide.line_to((440.0, 600.0));
11550
11551        let mut device = TextExtractionDevice::new();
11552        device.push_clip_path(&ClipPath {
11553            path: kurbo::Rect::new(40.0, 500.0, 160.0, 700.0).to_path(0.1),
11554            fill: pdf_render::pdf_interpret::FillRule::NonZero,
11555        });
11556        device.record_rule(&wide, Affine::IDENTITY);
11557
11558        assert_eq!(device.rules.len(), 1, "the visible part is still a rule");
11559        assert!(
11560            device.rules[0].right < 161.0,
11561            "the rule was recorded past its clip: {:?}",
11562            device.rules[0]
11563        );
11564
11565        let mut above = BezPath::new();
11566        above.move_to((40.0, 800.0));
11567        above.line_to((440.0, 800.0));
11568        device.record_rule(&above, Affine::IDENTITY);
11569        assert_eq!(
11570            device.rules.len(),
11571            1,
11572            "a rule the clip cuts away entirely was recorded: {:?}",
11573            device.rules
11574        );
11575
11576        device.pop_clip_path();
11577        device.record_rule(&wide, Affine::IDENTITY);
11578        assert_eq!(
11579            device.rules[1].right, 440.0,
11580            "the clip outlived the pop: {:?}",
11581            device.rules[1]
11582        );
11583    }
11584
11585    /// A table rules most of its gaps and a page of prose rules a couple: the
11586    /// share is what separates them, and it is measured rather than assumed.
11587    /// The Isartor appendix rules 26 of its 28 gaps; the two it leaves are the
11588    /// rows whose cell wraps, which is why the share is a half and not all.
11589    #[test]
11590    fn a_region_with_a_rule_in_a_minority_of_its_gaps_is_not_a_table() {
11591        let mut spans = Vec::new();
11592        for row in 0..6 {
11593            let y = 700.0 - row as f64 * 16.0;
11594            spans.push(span("Left column line", 40.0, y, 115.0));
11595            spans.push(span("Right column line", 320.0, y, 120.0));
11596        }
11597        let bands = group_spans_into_bands(spans);
11598        let rows: Vec<usize> = (0..bands.len()).collect();
11599        assert_eq!(bands.len(), 6, "six rows, so five gaps between them");
11600
11601        let rule_at = |y: f64| HorizontalRule {
11602            left: 35.0,
11603            right: 445.0,
11604            y,
11605        };
11606        let two = vec![rule_at(692.0), rule_at(676.0)];
11607        let three = vec![rule_at(692.0), rule_at(676.0), rule_at(660.0)];
11608
11609        assert!(
11610            !region_is_ruled(&bands, &rows, &two),
11611            "two of five is prose"
11612        );
11613        assert!(
11614            region_is_ruled(&bands, &rows, &three),
11615            "three of five is a table"
11616        );
11617    }
11618
11619    #[test]
11620    fn mixed_single_and_multi_column_regions_preserve_shared_bands() {
11621        let texts = block_texts(vec![
11622            span("Intro paragraph", 40.0, 740.0, 180.0),
11623            span("L1 words here", 40.0, 700.0, 110.0),
11624            span("R1 words here", 320.0, 700.0, 110.0),
11625            span("L2 words here", 40.0, 684.0, 110.0),
11626            span("R2 words here", 320.0, 684.0, 110.0),
11627            span("L3 words here", 40.0, 668.0, 110.0),
11628            span("R3 words here", 320.0, 668.0, 110.0),
11629            span("Outro paragraph", 40.0, 620.0, 180.0),
11630        ]);
11631
11632        assert_eq!(
11633            texts,
11634            vec![
11635                "Intro paragraph",
11636                "L1 words here",
11637                "L2 words here",
11638                "L3 words here",
11639                "R1 words here",
11640                "R2 words here",
11641                "R3 words here",
11642                "Outro paragraph",
11643            ]
11644        );
11645    }
11646
11647    /// A patent's front page: fields numbered by INID codes down the left,
11648    /// mostly short, with one long field line; references down the right on
11649    /// the same baselines. The long line is the column's measure, not a banner
11650    /// over both columns, so the page is read column by column (#574).
11651    #[test]
11652    fn a_patent_front_page_is_read_column_by_column() {
11653        // The measurements of 6942822's front page: a 14 pt gutter, the
11654        // column's long field line reaching into it, the rest ~150 pt.
11655        let fields = [
11656            ("(21) Appl. No.: 10/290,458", 150.0),
11657            ("(22) Filed: Nov. 8, 2002", 140.0),
11658            ("(51) Int. Cl. H01B 1/12", 160.0),
11659            ("(52) U.S. Cl. 252/500; 528/210", 150.0),
11660            ("(58) Field of Search .......... 252/500; 528/422", 219.0),
11661            ("(56) References Cited", 140.0),
11662        ];
11663        let mut spans = Vec::new();
11664        for (k, (field, width)) in fields.iter().enumerate() {
11665            let y = 700.0 - k as f64 * 16.0;
11666            spans.push(span(field, 77.0, y, *width));
11667            // The long field stands on a band of its own, as on the page:
11668            // the reference beside it ended a line higher.
11669            if k != 4 {
11670                spans.push(span(
11671                    "Journal of Polymer Science, vol. 38, pp. 3390",
11672                    310.0,
11673                    y,
11674                    220.0,
11675                ));
11676            }
11677        }
11678        let texts = block_texts(spans);
11679        assert!(
11680            texts
11681                .iter()
11682                .all(|t| !(t.contains(")") && t.contains("Journal"))),
11683            "a field was joined to the reference beside it: {texts:?}"
11684        );
11685    }
11686
11687    #[test]
11688    fn short_table_like_rows_fall_back_to_row_major() {
11689        let texts = block_texts(vec![
11690            span("Name", 40.0, 700.0, 30.0),
11691            span("Age", 320.0, 700.0, 20.0),
11692            span("Alice", 40.0, 684.0, 35.0),
11693            span("30", 320.0, 684.0, 15.0),
11694            span("Bob", 40.0, 668.0, 24.0),
11695            span("25", 320.0, 668.0, 15.0),
11696        ]);
11697
11698        assert_eq!(texts, vec!["Name Age", "Alice 30", "Bob 25"]);
11699    }
11700
11701    /// A three-column table row whose middle cell wraps, at the block level:
11702    /// the reordering happens in the leaf of the XY-cut, and this is the leaf's
11703    /// own view of `8.2.5.26-t05-pass-d.pdf`'s second row (#434).
11704    #[test]
11705    fn a_wrapped_cell_is_emitted_with_its_own_cell() {
11706        let texts = block_texts(vec![
11707            span("Index", 60.0, 700.0, 30.0),
11708            span("Failure condition", 200.0, 700.0, 100.0),
11709            span("Section", 400.0, 700.0, 42.0),
11710            span("15-003", 60.0, 680.0, 36.0),
11711            span("In a table not organized", 200.0, 680.0, 144.0),
11712            span("UA1:7.5-2", 400.0, 680.0, 54.0),
11713            span("a cell without a Scope", 200.0, 668.0, 132.0),
11714        ]);
11715
11716        assert_eq!(
11717            texts,
11718            vec![
11719                "Index Failure condition Section",
11720                "15-003",
11721                "In a table not organized",
11722                "a cell without a Scope",
11723                "UA1:7.5-2",
11724            ]
11725        );
11726    }
11727
11728    /// The failure case the fix names: when every cell of the row has a line
11729    /// under it, nothing says which of those lines continues the row and which
11730    /// one begins the next -- the same shape a two-column region takes when one
11731    /// column runs on alone. Such a row keeps today's row-major order rather
11732    /// than being guessed at.
11733    /// The leading is deliberately tight, so that all three lines pass the
11734    /// distance test in their own cell and it is the last guard -- every cell
11735    /// of the row having a line under it -- that has to refuse the row. At that
11736    /// leading the column cut above the leaf takes the page, so this one asks
11737    /// the leaf directly.
11738    #[test]
11739    fn a_row_whose_every_cell_wraps_stays_row_major() {
11740        let texts = leaf_texts(vec![
11741            span("Index", 60.0, 700.0, 30.0),
11742            span("Failure condition", 200.0, 700.0, 100.0),
11743            span("Section", 400.0, 700.0, 42.0),
11744            span("15-003", 60.0, 680.0, 36.0),
11745            span("In a table not organized", 200.0, 680.0, 144.0),
11746            span("UA1:7.5-2", 400.0, 680.0, 54.0),
11747            span("is the index", 60.0, 674.0, 72.0),
11748            span("a cell without a Scope", 200.0, 668.0, 132.0),
11749            span("of the suite", 400.0, 662.0, 72.0),
11750        ]);
11751
11752        assert_eq!(
11753            texts,
11754            vec![
11755                "Index Failure condition Section",
11756                "15-003 In a table not organized UA1:7.5-2",
11757                "is the index",
11758                "a cell without a Scope",
11759                "of the suite",
11760            ]
11761        );
11762    }
11763
11764    /// The next row of a table with an empty cell also has fewer spans than the
11765    /// row above it and also starts on the same edges. It is the next row all
11766    /// the same, and what says so is that it fills two cells where a wrapped
11767    /// line fills one. The header of this table is padded, so the body's rows
11768    /// sit closer together than the pitch under the header and the distance
11769    /// alone would let this one pass.
11770    #[test]
11771    fn a_row_with_an_empty_cell_is_a_row_and_not_a_wrap() {
11772        let texts = block_texts(vec![
11773            span("Index", 60.0, 700.0, 30.0),
11774            span("Type", 200.0, 700.0, 24.0),
11775            span("How", 400.0, 700.0, 18.0),
11776            span("15-003", 60.0, 680.0, 36.0),
11777            span("Object", 200.0, 680.0, 36.0),
11778            span("Machine", 400.0, 680.0, 42.0),
11779            span("15-004", 60.0, 666.0, 36.0),
11780            span("Rope", 400.0, 666.0, 24.0),
11781        ]);
11782
11783        assert_eq!(
11784            texts,
11785            vec!["Index Type How", "15-003 Object Machine", "15-004 Rope"]
11786        );
11787    }
11788
11789    /// A row that fills a single cell, one line under a wrapped one. Measured
11790    /// against the wrapped line it is a line's distance away; measured against
11791    /// the cell it would join -- which has no line under it yet, so the row
11792    /// itself -- it is two, and it is a row.
11793    #[test]
11794    fn a_single_cell_row_under_a_wrapped_line_is_still_a_row() {
11795        let texts = block_texts(vec![
11796            span("Index", 60.0, 700.0, 30.0),
11797            span("Type", 200.0, 700.0, 24.0),
11798            span("How", 400.0, 700.0, 18.0),
11799            span("15-003", 60.0, 680.0, 36.0),
11800            span("In a table not organized", 200.0, 680.0, 144.0),
11801            span("UA1:7.5-2", 400.0, 680.0, 54.0),
11802            span("a cell without a Scope", 200.0, 668.0, 132.0),
11803            span("15-004", 60.0, 656.0, 36.0),
11804        ]);
11805
11806        assert_eq!(
11807            texts,
11808            vec![
11809                "Index Type How",
11810                "15-003",
11811                "In a table not organized",
11812                "a cell without a Scope",
11813                "UA1:7.5-2",
11814                "15-004",
11815            ]
11816        );
11817    }
11818
11819    /// The same, with a row that fills a single cell: nothing but the pitch
11820    /// separates it from a wrapped line, and the pitch is what says it.
11821    #[test]
11822    fn a_line_a_whole_row_pitch_down_is_a_row_and_not_a_wrap() {
11823        let texts = block_texts(vec![
11824            span("Index", 60.0, 700.0, 30.0),
11825            span("Type", 200.0, 700.0, 24.0),
11826            span("How", 400.0, 700.0, 18.0),
11827            span("15-003", 60.0, 680.0, 36.0),
11828            span("Object", 200.0, 680.0, 36.0),
11829            span("Machine", 400.0, 680.0, 42.0),
11830            span("Rope", 200.0, 660.0, 24.0),
11831        ]);
11832
11833        assert_eq!(
11834            texts,
11835            vec!["Index Type How", "15-003 Object Machine", "Rope"]
11836        );
11837    }
11838
11839    #[test]
11840    fn three_column_regions_are_supported() {
11841        let texts = block_texts(vec![
11842            span("Column one line one", 40.0, 700.0, 105.0),
11843            span("Column two line one", 220.0, 700.0, 105.0),
11844            span("Column three line one", 400.0, 700.0, 120.0),
11845            span("Column one line two", 40.0, 684.0, 105.0),
11846            span("Column two line two", 220.0, 684.0, 105.0),
11847            span("Column three line two", 400.0, 684.0, 120.0),
11848            span("Column one line three", 40.0, 668.0, 120.0),
11849            span("Column two line three", 220.0, 668.0, 120.0),
11850            span("Column three line three", 400.0, 668.0, 135.0),
11851        ]);
11852
11853        assert_eq!(
11854            texts,
11855            vec![
11856                "Column one line one",
11857                "Column one line two",
11858                "Column one line three",
11859                "Column two line one",
11860                "Column two line two",
11861                "Column two line three",
11862                "Column three line one",
11863                "Column three line two",
11864                "Column three line three",
11865            ]
11866        );
11867    }
11868
11869    #[test]
11870    fn text_block_concatenation_spaced() {
11871        let block = TextBlock {
11872            spans: vec![span("A", 0.0, 0.0, 6.0), span("B", 20.0, 0.0, 6.0)],
11873        };
11874        assert_eq!(block.text(), "A B");
11875    }
11876
11877    #[test]
11878    fn a_space_drawn_over_a_ligature_is_no_word_space() {
11879        // `Specifi` then ` cally`, the second run starting 0.3 em before the
11880        // first ends (#574): the page draws one word in two runs, and a real
11881        // word space never starts left of the letter before it.
11882        let block = TextBlock {
11883            spans: vec![
11884                span("Specifi", 0.0, 0.0, 60.0),
11885                span(" cally", 56.4, 0.0, 36.0),
11886            ],
11887        };
11888        assert_eq!(block.text(), "Specifically");
11889        assert_eq!(crate::ligatures::fold(&block.text()), "Specifically");
11890    }
11891
11892    #[test]
11893    fn a_space_drawn_over_an_initial_is_no_word_space() {
11894        // `T` then ` ransition`, the second run starting 0.6 em back: the
11895        // same signature without a ligature (#574).
11896        let block = TextBlock {
11897            spans: vec![span("T", 0.0, 0.0, 8.0), span(" ransition", 0.8, 0.0, 60.0)],
11898        };
11899        assert_eq!(block.text(), "Transition");
11900    }
11901
11902    #[test]
11903    fn a_real_word_space_stays() {
11904        // `There` then ` is`, the second run starting 0.2 em AFTER the first
11905        // ends: a gap in reading direction is a word space (#574).
11906        let block = TextBlock {
11907            spans: vec![span("There", 0.0, 0.0, 30.0), span(" is", 32.4, 0.0, 15.0)],
11908        };
11909        assert_eq!(block.text(), "There is");
11910    }
11911
11912    #[test]
11913    fn an_overlapping_run_that_starts_with_a_capital_or_digit_keeps_its_space() {
11914        // Formulas and tables overlap runs that open with a capital or a
11915        // digit; only a lower-case letter joins (#574).
11916        let digit = TextBlock {
11917            spans: vec![span("v", 0.0, 0.0, 8.0), span(" 2", 4.4, 0.0, 10.0)],
11918        };
11919        assert_eq!(digit.text(), "v 2");
11920        let capital = TextBlock {
11921            spans: vec![span("v", 0.0, 0.0, 8.0), span(" N", 4.4, 0.0, 10.0)],
11922        };
11923        assert_eq!(capital.text(), "v N");
11924    }
11925
11926    #[test]
11927    fn adaptive_column_gap_fallback_for_no_gaps() {
11928        // Single-span bands produce no measurable gaps → fallback
11929        let bands = vec![
11930            TextBand::new(span("Hello", 40.0, 700.0, 80.0)),
11931            TextBand::new(span("World", 40.0, 684.0, 80.0)),
11932        ];
11933        let threshold = compute_adaptive_column_gap(&bands);
11934        assert!((threshold - COLUMN_GAP_THRESHOLD_FALLBACK).abs() < 0.01);
11935    }
11936
11937    #[test]
11938    fn adaptive_column_gap_uses_median() {
11939        // Three bands with word gaps of ~4pt each → median ≈ 4, threshold = 12
11940        let mut bands = Vec::new();
11941        for y in [700.0, 684.0, 668.0] {
11942            let mut band = TextBand::new(span("word1", 40.0, y, 30.0));
11943            band.spans.push(span("word2", 74.0, y, 30.0)); // gap = 4
11944            band.spans.push(span("word3", 108.0, y, 30.0)); // gap = 4
11945            bands.push(band);
11946        }
11947        let threshold = compute_adaptive_column_gap(&bands);
11948        // median gap = 4, × 3 = 12, clamped to [10, 40] → 12
11949        assert!(
11950            (10.0..=14.0).contains(&threshold),
11951            "expected ~12, got {threshold}"
11952        );
11953    }
11954
11955    #[test]
11956    fn adaptive_column_gap_clamps_to_min() {
11957        // Tight gaps (2pt) across many bands → median = 2, 3×2 = 6 → clamped to 10
11958        let mut bands = Vec::new();
11959        for y in [700.0, 684.0, 668.0, 652.0] {
11960            let mut band = TextBand::new(span("abc", 0.0, y, 18.0));
11961            // right of "abc" = max(18, 12*0.5*3=18) = 18; gap = 20-18 = 2
11962            band.spans.push(span("def", 20.0, y, 18.0));
11963            bands.push(band);
11964        }
11965        let threshold = compute_adaptive_column_gap(&bands);
11966        assert!(
11967            (threshold - COLUMN_GAP_THRESHOLD_MIN).abs() < 0.01,
11968            "expected {COLUMN_GAP_THRESHOLD_MIN}, got {threshold}"
11969        );
11970    }
11971
11972    #[test]
11973    fn adaptive_column_gap_all_large_gaps_uses_fraction_of_min() {
11974        // When all gaps are large (> MIN), threshold = 0.75 × min_gap.
11975        let mut band = TextBand::new(span("Left", 0.0, 700.0, 30.0));
11976        band.spans.push(span("Right", 80.0, 700.0, 30.0)); // gap = 50
11977        let bands = vec![band];
11978        let threshold = compute_adaptive_column_gap(&bands);
11979        assert!(
11980            (threshold - 37.5).abs() < 0.01,
11981            "expected 37.5 (0.75×50), got {threshold}"
11982        );
11983    }
11984
11985    #[test]
11986    fn normalize_trims_trailing_whitespace_per_line() {
11987        assert_eq!(
11988            normalize_text_output("hello   \nworld  \n"),
11989            "hello\nworld\n"
11990        );
11991    }
11992
11993    #[test]
11994    fn normalize_collapses_excess_newlines() {
11995        // >2 blank lines collapse to 2 (meaning 3 \n in a row: line, blank, blank)
11996        assert_eq!(
11997            normalize_text_output("hello\n\n\n\n\nworld\n"),
11998            "hello\n\n\nworld\n"
11999        );
12000    }
12001
12002    #[test]
12003    fn normalize_preserves_double_newline() {
12004        assert_eq!(
12005            normalize_text_output("paragraph one\n\nparagraph two\n"),
12006            "paragraph one\n\nparagraph two\n"
12007        );
12008    }
12009
12010    #[test]
12011    fn normalize_preserves_form_feed() {
12012        assert_eq!(
12013            normalize_text_output("page1\n\n\x0Cpage2\n"),
12014            "page1\n\n\x0Cpage2\n"
12015        );
12016    }
12017
12018    #[test]
12019    fn normalize_adds_trailing_newline() {
12020        assert_eq!(normalize_text_output("hello"), "hello\n");
12021    }
12022
12023    #[test]
12024    fn normalize_empty_input() {
12025        assert_eq!(normalize_text_output(""), "");
12026    }
12027
12028    #[test]
12029    fn normalize_only_whitespace() {
12030        assert_eq!(normalize_text_output("   \n  \n"), "");
12031    }
12032
12033    // --- Hyphen stitching tests ---
12034
12035    #[test]
12036    fn hyphen_stitch_joins_wrapped_word() {
12037        let lines = vec!["the aver-".into(), "age rainfall".into()];
12038        assert_eq!(stitch_hyphenated_lines(&lines), "the average rainfall");
12039    }
12040
12041    #[test]
12042    fn hyphen_stitch_handles_leading_whitespace() {
12043        let lines = vec!["pre-".into(), "   dict the outcome".into()];
12044        // "pre" is only 3 chars → meets >= 3 guard
12045        assert_eq!(stitch_hyphenated_lines(&lines), "predict the outcome");
12046    }
12047
12048    #[test]
12049    fn hyphen_stitch_capital_continuation_not_stitched() {
12050        let lines = vec!["Section three-".into(), "Summary here".into()];
12051        assert_eq!(
12052            stitch_hyphenated_lines(&lines),
12053            "Section three-\nSummary here"
12054        );
12055    }
12056
12057    #[test]
12058    fn hyphen_stitch_bullet_dash_not_stitched() {
12059        // "-" alone: char before hyphen is not alphabetic
12060        let lines = vec!["Items:".into(), "-".into(), "milk".into()];
12061        assert_eq!(stitch_hyphenated_lines(&lines), "Items:\n-\nmilk");
12062    }
12063
12064    #[test]
12065    fn hyphen_stitch_numeric_range_not_stitched() {
12066        // "42-" — char before hyphen is digit, not alphabetic
12067        let lines = vec!["page 42-".into(), "seventy".into()];
12068        assert_eq!(stitch_hyphenated_lines(&lines), "page 42-\nseventy");
12069    }
12070
12071    #[test]
12072    fn hyphen_stitch_short_prefix_not_stitched() {
12073        // "re-" only 2 alpha chars before hyphen → below 3-char guard
12074        let lines = vec!["re-".into(), "organize".into()];
12075        assert_eq!(stitch_hyphenated_lines(&lines), "re-\norganize");
12076    }
12077
12078    #[test]
12079    fn hyphen_stitch_short_continuation_not_stitched() {
12080        // Next line starts with "an" (2 chars) → below 3-char guard
12081        let lines = vec!["counter-".into(), "an example".into()];
12082        assert_eq!(stitch_hyphenated_lines(&lines), "counter-\nan example");
12083    }
12084
12085    #[test]
12086    fn hyphen_stitch_compound_word_midline_preserved() {
12087        // "real-time" is mid-line, not end-of-line — no stitching applies
12088        // because stitch only operates on line boundaries
12089        let lines = vec!["real-time system".into()];
12090        assert_eq!(stitch_hyphenated_lines(&lines), "real-time system");
12091    }
12092
12093    #[test]
12094    fn hyphen_stitch_single_line_unchanged() {
12095        let lines = vec!["only line".into()];
12096        assert_eq!(stitch_hyphenated_lines(&lines), "only line");
12097    }
12098
12099    #[test]
12100    fn hyphen_stitch_empty_input() {
12101        let lines: Vec<String> = vec![];
12102        assert_eq!(stitch_hyphenated_lines(&lines), "");
12103    }
12104
12105    // --- TEX1 multi-signal space consensus tests ---
12106
12107    fn make_device_with_median(median: f64) -> TextExtractionDevice {
12108        let mut dev = TextExtractionDevice::new();
12109        // Seed enough samples for the median to resolve to `median`.
12110        for _ in 0..MEDIAN_REFRESH {
12111            dev.glyph_widths.push(median);
12112        }
12113        dev.refresh_median_char_width();
12114        assert!((dev.cached_median_char_width - median).abs() < 1e-9);
12115        dev
12116    }
12117
12118    #[test]
12119    fn consensus_inserts_space_on_strong_tj_offset_alone() {
12120        // Gap is below the geometric threshold, but the TJ offset is large
12121        // enough that the consensus must still fire.
12122        let mut dev = make_device_with_median(6.0);
12123        dev.pending_tj_offset = -250.0; // full em-space, to the right
12124        assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
12125    }
12126
12127    #[test]
12128    fn a_pair_kern_of_any_size_is_not_a_word_space() {
12129        // `[(FINANCIAL RESUL)134.6(TS)] TJ`: the positive TJ pulls the `T`
12130        // back under the `L`, and the gap it leaves is below zero.
12131        let mut dev = make_device_with_median(6.0);
12132        dev.pending_tj_offset = 134.6;
12133        assert!(!dev.evaluate_space_consensus(-1.15, 8.5, "RESUL", "TS"));
12134    }
12135
12136    #[test]
12137    fn consensus_inserts_space_on_geometric_gap_alone() {
12138        // No TJ, no character transition, but a clearly wide geometric gap.
12139        let dev = make_device_with_median(6.0);
12140        // gap > 0.3 * 6.0 = 1.8 → fires gap signal (0.80), below threshold
12141        // on its own? 0.80 < 0.75 threshold? No, 0.80 > 0.75, so it fires.
12142        assert!(dev.evaluate_space_consensus(2.5, 12.0, "hello", "world"));
12143    }
12144
12145    #[test]
12146    fn consensus_no_space_on_kerning_gap() {
12147        // Small kerning-size gap with no other signals must not inject a
12148        // space (regression guard against false-positive spaces inside
12149        // tightly kerned words).
12150        let dev = make_device_with_median(6.0);
12151        assert!(!dev.evaluate_space_consensus(0.5, 12.0, "fi", "lm"));
12152    }
12153
12154    #[test]
12155    fn consensus_inserts_space_on_camel_case_plus_gap() {
12156        // CamelCase heuristic (0.60) alone doesn't reach threshold, but a
12157        // moderate gap (0.60 gap + 0.60 heuristic if gap fires) should.
12158        // Here gap = 2.5 > 1.8 → gap fires → total 0.80 + 0.60 = 1.40.
12159        let dev = make_device_with_median(6.0);
12160        assert!(dev.evaluate_space_consensus(2.5, 12.0, "helloWorld", "Inc"));
12161    }
12162
12163    #[test]
12164    fn consensus_inserts_space_on_digit_letter_transition_with_gap() {
12165        let dev = make_device_with_median(6.0);
12166        assert!(dev.evaluate_space_consensus(2.5, 12.0, "123", "abc"));
12167    }
12168
12169    #[test]
12170    fn consensus_heuristic_alone_is_insufficient() {
12171        // Heuristic (0.60) on its own is below the 0.75 threshold — the
12172        // design deliberately requires a second corroborating signal to
12173        // avoid gluing spaces into existing CamelCase identifiers that
12174        // have no geometric break.
12175        let dev = make_device_with_median(6.0);
12176        assert!(!dev.evaluate_space_consensus(0.5, 12.0, "camel", "Case"));
12177    }
12178
12179    #[test]
12180    fn consensus_falls_back_to_font_size_when_no_median() {
12181        // No samples → median is 0; geometric reference uses font-size.
12182        let dev = TextExtractionDevice::new();
12183        // gap 1.9 > 0.15 * 12.0 = 1.8 → gap signal fires
12184        assert!(dev.evaluate_space_consensus(1.9, 12.0, "a", "b"));
12185        // gap 1.5 < 1.8 → no signal
12186        assert!(!dev.evaluate_space_consensus(1.5, 12.0, "a", "b"));
12187    }
12188
12189    #[test]
12190    fn consensus_ignores_tiny_tj_offsets() {
12191        // TJ offsets below the threshold are kerning, not word breaks.
12192        let mut dev = make_device_with_median(6.0);
12193        dev.pending_tj_offset = 50.0;
12194        assert!(!dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
12195    }
12196
12197    #[test]
12198    fn consensus_accepts_negative_tj_offsets() {
12199        // A negative TJ offset moves the next glyph right, an explicit
12200        // inter-substring shift, and counts toward the consensus.
12201        let mut dev = make_device_with_median(6.0);
12202        dev.pending_tj_offset = -250.0;
12203        assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
12204    }
12205
12206    #[test]
12207    fn text_adjustment_accumulates_until_glyph() {
12208        let mut dev = TextExtractionDevice::new();
12209        dev.text_adjustment(120.0);
12210        dev.text_adjustment(140.0);
12211        assert!((dev.pending_tj_offset - 260.0).abs() < 1e-6);
12212    }
12213
12214    // --- TEX2 XY-Cut tests ---
12215
12216    #[test]
12217    fn xy_cut_header_body_footer_with_two_columns() {
12218        // Header and footer sit in the mid-x range that would
12219        // accidentally fall into a left-column bucket with a naive
12220        // vertical-first cut. The largest-gap-first rule plus the
12221        // alignment guard ensure header and footer bracket the
12222        // columnar body.
12223        let texts = block_texts(vec![
12224            span("HEADLINE TITLE", 180.0, 760.0, 120.0),
12225            span("Left col line A", 40.0, 700.0, 110.0),
12226            span("Right col line A", 320.0, 700.0, 115.0),
12227            span("Left col line B", 40.0, 684.0, 110.0),
12228            span("Right col line B", 320.0, 684.0, 115.0),
12229            span("Left col line C", 40.0, 668.0, 110.0),
12230            span("Right col line C", 320.0, 668.0, 115.0),
12231            span("FOOTER LINE TEXT", 180.0, 600.0, 120.0),
12232        ]);
12233        assert_eq!(texts.first().map(String::as_str), Some("HEADLINE TITLE"));
12234        assert_eq!(texts.last().map(String::as_str), Some("FOOTER LINE TEXT"));
12235        // Left column lines all come before right column lines.
12236        let left_c_idx = texts.iter().position(|s| s == "Left col line C").unwrap();
12237        let right_a_idx = texts.iter().position(|s| s == "Right col line A").unwrap();
12238        assert!(
12239            left_c_idx < right_a_idx,
12240            "expected column-major ordering in body: {texts:?}"
12241        );
12242    }
12243
12244    #[test]
12245    fn xy_cut_rejects_column_split_on_table_rows() {
12246        // The density guard must still reject the 280pt inter-cell gap
12247        // in a short-cell table, preserving row-major reading order.
12248        let texts = block_texts(vec![
12249            span("Name", 40.0, 700.0, 30.0),
12250            span("Age", 320.0, 700.0, 20.0),
12251            span("Alice", 40.0, 684.0, 35.0),
12252            span("30", 320.0, 684.0, 15.0),
12253        ]);
12254        assert_eq!(texts, vec!["Name Age", "Alice 30"]);
12255    }
12256
12257    #[test]
12258    fn xy_cut_rejects_column_split_when_one_band_is_full_width() {
12259        // The alignment guard catches a full-width paragraph that
12260        // would otherwise be forced into the left column of a 2-column
12261        // region below it.
12262        let texts = block_texts(vec![
12263            span(
12264                "Full width intro spanning both columns here",
12265                40.0,
12266                740.0,
12267                360.0,
12268            ),
12269            span("Left A", 40.0, 700.0, 50.0),
12270            span("Right A", 320.0, 700.0, 50.0),
12271            span("Left B", 40.0, 684.0, 50.0),
12272            span("Right B", 320.0, 684.0, 50.0),
12273        ]);
12274        assert!(
12275            texts[0].contains("Full width intro"),
12276            "expected full-width intro first: {texts:?}"
12277        );
12278    }
12279
12280    #[test]
12281    fn xy_cut_horizontal_split_for_zone_boundaries() {
12282        // Pure horizontal cut on a single-column page with a big
12283        // vertical gap between paragraphs — the cut fires and both
12284        // paragraphs stay in their own blocks.
12285        let texts = block_texts(vec![
12286            span("First paragraph body text", 40.0, 740.0, 200.0),
12287            span("Second paragraph body", 40.0, 680.0, 180.0),
12288        ]);
12289        assert_eq!(texts.len(), 2);
12290        assert!(texts[0].starts_with("First"));
12291        assert!(texts[1].starts_with("Second"));
12292    }
12293
12294    #[test]
12295    fn xy_cut_recursion_terminates_with_single_span() {
12296        let texts = block_texts(vec![span("Only one span on the page", 40.0, 700.0, 180.0)]);
12297        assert_eq!(texts, vec!["Only one span on the page"]);
12298    }
12299
12300    #[test]
12301    fn median_font_size_handles_mixed_sizes() {
12302        let spans = vec![
12303            TextSpan {
12304                text: "small".into(),
12305                width: 10.0,
12306                height: 8.0,
12307                font_size: 8.0,
12308                ..TextSpan::default()
12309            },
12310            TextSpan {
12311                text: "medium".into(),
12312                width: 10.0,
12313                height: 12.0,
12314                font_size: 12.0,
12315                ..TextSpan::default()
12316            },
12317            TextSpan {
12318                text: "large".into(),
12319                width: 10.0,
12320                height: 24.0,
12321                font_size: 24.0,
12322                ..TextSpan::default()
12323            },
12324        ];
12325        assert!((median_font_size(&spans) - 12.0).abs() < 1e-9);
12326    }
12327
12328    #[test]
12329    fn columns_band_aligned_accepts_aligned_columns() {
12330        let spans = vec![
12331            span("L1", 40.0, 700.0, 60.0),
12332            span("R1", 300.0, 700.0, 60.0),
12333            span("L2", 40.0, 684.0, 60.0),
12334            span("R2", 300.0, 684.0, 60.0),
12335        ];
12336        let stats = PageStats::from_spans(&spans);
12337        // cut_x between 100 and 300 → 200. Every band straddles the cut.
12338        assert!(columns_are_band_aligned(&spans, 200.0, 40.0, 360.0, &stats));
12339    }
12340
12341    #[test]
12342    fn columns_band_aligned_rejects_wide_single_side_band() {
12343        let spans = vec![
12344            span("Wide banner line across top", 40.0, 740.0, 280.0),
12345            span("L1", 40.0, 700.0, 60.0),
12346            span("R1", 300.0, 700.0, 60.0),
12347        ];
12348        let stats = PageStats::from_spans(&spans);
12349        // cut_x = 200. Banner only in left group (midpoint < 200). Width
12350        // exceeds 0.7 × left column width → rejected.
12351        assert!(!columns_are_band_aligned(
12352            &spans, 200.0, 40.0, 360.0, &stats
12353        ));
12354    }
12355
12356    #[test]
12357    fn page_stats_computes_median_values() {
12358        let spans = vec![
12359            span("one", 40.0, 700.0, 30.0),
12360            span("two", 40.0, 680.0, 30.0),
12361            span("three", 40.0, 660.0, 50.0),
12362        ];
12363        let stats = PageStats::from_spans(&spans);
12364        assert!((stats.median_font_size - 12.0).abs() < 1e-9);
12365        // char width = width / chars. one=30/3=10, two=30/3=10, three=50/5=10. median=10.
12366        assert!((stats.median_char_width - 10.0).abs() < 1e-9);
12367        // line spacing: bands at 700, 680, 660. gaps = 20, 20. median = 20.
12368        assert!((stats.median_line_spacing - 20.0).abs() < 1e-9);
12369    }
12370
12371    #[test]
12372    fn page_stats_handles_empty_input() {
12373        let stats = PageStats::from_spans(&[]);
12374        assert!((stats.median_font_size - 12.0).abs() < 1e-9);
12375        assert!((stats.median_char_width - 6.0).abs() < 1e-9);
12376        assert_eq!(stats.median_line_spacing, 0.0);
12377    }
12378
12379    #[test]
12380    fn narrow_gutter_detected_with_adaptive_threshold() {
12381        // Academic paper layout: 12pt gutter between columns.
12382        // With old fixed 20pt threshold, this was not detected as columnar.
12383        // With adaptive: median word gap ~4pt, threshold = 12pt → detects 12pt gutter.
12384        let mut spans = Vec::new();
12385        for y in [700.0, 684.0, 668.0] {
12386            // Left column: two words with 4pt gap, ending at x=145
12387            spans.push(span("Lorem ipsum", 40.0, y, 100.0));
12388            spans.push(span("dolor sit", 144.0, y, 80.0));
12389            // Right column starts at 236 (gap = 12pt from 224)
12390            spans.push(span("amet consec", 236.0, y, 100.0));
12391            spans.push(span("tetur adipi", 340.0, y, 80.0));
12392        }
12393        let texts = block_texts(spans);
12394        // Should detect 2-column layout and read column-major
12395        assert!(
12396            texts.len() >= 6,
12397            "expected column-major output, got {texts:?}"
12398        );
12399        // First three blocks should be left column lines
12400        assert!(
12401            texts[0].contains("Lorem"),
12402            "first block should be left column: {texts:?}"
12403        );
12404    }
12405
12406    /// One table whose rows are grouped under labels is not cut between the
12407    /// groups (#596).
12408    ///
12409    /// The shape is the tuning set's commonest table: a label column whose cell is
12410    /// filled on the first row of a group and empty under it, with a little more
12411    /// white between the groups than between the rows. The threshold of
12412    /// `try_horizontal_cut` is a multiple of the ROW pitch, so the gap between
12413    /// groups clears it and the table is cut into one region per group -- on
12414    /// `PMC3037919-p0007` that is twelve tables where the page holds one.
12415    ///
12416    /// Red without the guard: `try_horizontal_cut` returns a cut.
12417    #[test]
12418    fn one_table_is_not_cut_between_its_label_groups() {
12419        // Four columns, with gaps wide enough to separate runs, and a label column
12420        // that is empty on every row but the first of each group.
12421        let row = |label: &str, second: &str, third: &str, fourth: &str, y: f64| {
12422            let mut spans = Vec::new();
12423            if !label.is_empty() {
12424                spans.push(span(label, 72.0, y, 58.0));
12425            }
12426            spans.push(span(second, 160.0, y, 90.0));
12427            spans.push(span(third, 300.0, y, 30.0));
12428            spans.push(span(fourth, 360.0, y, 40.0));
12429            spans
12430        };
12431        let mut spans = Vec::new();
12432        spans.extend(row("Study Design", "Case control", "7", "25.93", 700.0));
12433        spans.extend(row("", "Cohort", "9", "33.33", 688.0));
12434        spans.extend(row("", "Cross-sectional", "11", "40.74", 676.0));
12435        // 24 points to the next group, against a 12 point row pitch.
12436        spans.extend(row("Gender studied", "Both", "21", "77.78", 652.0));
12437        spans.extend(row("", "Men", "3", "11.11", 640.0));
12438
12439        let stats = PageStats::from_spans(&spans);
12440        let bands = group_spans_into_bands(spans.clone());
12441        let cut_y = (676.0 + 652.0) * 0.5;
12442        let gap = 676.0 - 652.0;
12443
12444        // The premises, so the fixture cannot quietly stop being the defect: the
12445        // gap clears the unguarded threshold, and the leading floor of #580 does
12446        // not already refuse it.
12447        let unguarded = stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER;
12448        assert!(
12449            gap >= unguarded,
12450            "the gap {gap} does not clear the unguarded threshold {unguarded}, so \
12451             this fixture would pass without the guard"
12452        );
12453        let floor = leading_of_a_region(&spans, &stats) * HORIZONTAL_CUT_LEADING_FLOOR;
12454        assert!(
12455            gap >= floor,
12456            "the #580 floor {floor} already refuses this gap {gap}, so the guard \
12457             is not what this test measures"
12458        );
12459
12460        assert!(
12461            the_cut_is_inside_a_grid(&bands, cut_y, gap),
12462            "the bands either side of the cut were not read as one grid"
12463        );
12464        assert!(
12465            try_horizontal_cut(&spans, &stats).is_none(),
12466            "the table was cut between its label groups"
12467        );
12468    }
12469
12470    /// Three one-line zones, equally spaced, are not a leading (codex on #580,
12471    /// round two).
12472    ///
12473    /// Their two gaps are every gap the region has, so counting occurrences
12474    /// cannot tell them from three lines of a paragraph. What can is the left
12475    /// edge: lines set together are flush, and a masthead over a centred title
12476    /// over an indented byline are not.
12477    ///
12478    /// Red without the alignment filter: the zone gap becomes the leading, the
12479    /// floor rises above it and both of the region's cuts are refused.
12480    #[test]
12481    fn three_zones_equally_spaced_are_not_a_leading() {
12482        let stats = PageStats::from_spans(&[span("body", 72.0, 700.0, 100.0)]);
12483        let em = stats.median_font_size;
12484        // Equally spaced by 20pt, which is inside [0.8, 3.0] ems of a 12pt line.
12485        let zones = vec![
12486            span("MASTHEAD", 72.0, 700.0, 120.0),
12487            span("A centred title", 200.0, 680.0, 120.0),
12488            span("by a named author", 140.0, 660.0, 120.0),
12489        ];
12490        assert!(
12491            20.0 >= LEADING_MIN_EM * em && 20.0 <= LEADING_MAX_EM * em,
12492            "the fixture's gap is outside the bounds, so it never reaches the rule"
12493        );
12494        assert_eq!(
12495            leading_of_a_region(&zones, &stats),
12496            0.0,
12497            "three zones that are not flush were read as a leading"
12498        );
12499
12500        // And the same three steps DO count when the bands are flush, or the
12501        // filter would refuse every real paragraph as well.
12502        let lines = vec![
12503            span("the first line of a paragraph", 72.0, 700.0, 180.0),
12504            span("the second line of the same one", 72.0, 680.0, 180.0),
12505            span("and the third line of it", 72.0, 660.0, 150.0),
12506        ];
12507        assert_eq!(
12508            leading_of_a_region(&lines, &stats),
12509            20.0,
12510            "three flush lines did not yield their own leading"
12511        );
12512    }
12513
12514    /// A region of two bands has no leading, so the floor does not refuse its
12515    /// only cut (codex on #580).
12516    ///
12517    /// The shape has to be a two-band REGION on a page that is not two bands,
12518    /// because `PageStats` is page-wide: a page of two bands would put its only
12519    /// gap in `median_line_spacing` too and the threshold would refuse the cut
12520    /// with or without a floor. What the review describes is a page whose
12521    /// quartile is pulled down by dense content -- a table -- and a sparse region
12522    /// further down that the recursion reaches with two bands in it.
12523    ///
12524    /// Here the table sets the quartile to 6, so the threshold is 10.8; the
12525    /// widest gap on the page is the 82 points above the banner, so the first cut
12526    /// separates the table from everything below it and the region left over is
12527    /// the banner and one line of body, 25 points apart. Without the occurrence
12528    /// rule that 25 is "the leading" of a two-band region on a count of one, the
12529    /// floor rises to 26.25, and the only cut that region has is refused: the
12530    /// banner is glued to the body it is not part of.
12531    ///
12532    /// Red without the occurrence rule.
12533    #[test]
12534    fn two_bands_have_no_leading_so_the_only_cut_survives() {
12535        let mut spans = Vec::new();
12536        // Dense rows, to pull the page-wide quartile under the gap below.
12537        for (row, y) in [700.0, 694.0, 688.0, 682.0].iter().enumerate() {
12538            spans.push(span(["SWA", "BHM", "SBS", "VBS"][row], 72.0, *y, 30.0));
12539            spans.push(span("2011", 150.0, *y, 30.0));
12540        }
12541        spans.push(span("Federal Register banner", 72.0, 600.0, 260.0));
12542        spans.push(span(
12543            "the body of the notice begins here",
12544            72.0,
12545            575.0,
12546            200.0,
12547        ));
12548
12549        let stats = PageStats::from_spans(&spans);
12550        let region = vec![
12551            span("Federal Register banner", 72.0, 600.0, 260.0),
12552            span("the body of the notice begins here", 72.0, 575.0, 200.0),
12553        ];
12554        assert_eq!(
12555            leading_of_a_region(&region, &stats),
12556            0.0,
12557            "a single gap was read as the leading of a two-band region"
12558        );
12559        // And the fixture is the review's case: the gap is inside the bounds, so
12560        // only the occurrence rule keeps it from becoming a floor above itself.
12561        let gap = 600.0 - 575.0;
12562        assert!(
12563            gap >= LEADING_MIN_EM * stats.median_font_size
12564                && gap <= LEADING_MAX_EM * stats.median_font_size,
12565            "the fixture's gap is refused by the bounds, so it would not reach \
12566             the occurrence rule"
12567        );
12568        assert!(
12569            gap > stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER,
12570            "the cut would not fire even unfloored, so this fixture proves nothing"
12571        );
12572
12573        let texts = block_texts(spans);
12574        assert!(
12575            texts.iter().any(|t| t == "Federal Register banner"),
12576            "the banner was glued to the body: {texts:?}"
12577        );
12578    }
12579
12580    /// A paragraph is not cut between its own lines because something tighter
12581    /// than the body is on the page too (#580).
12582    ///
12583    /// The shape is the one the tuning set is full of: a bank of table rows set
12584    /// on 6 points over a body set on 14. `median_line_spacing` is the FIRST
12585    /// QUARTILE of the band steps, so it lands on the table's pitch (6) and the
12586    /// paragraph-break threshold on 10.8 -- under the body's leading. Every line
12587    /// break then qualifies as a zone break, the recursion cuts the paragraph
12588    /// into one region per line, and `blocks::paragraphs` cannot put it back
12589    /// because its stretches are bounded per region.
12590    ///
12591    /// Red without the floor: the four lines come back as four blocks.
12592    #[test]
12593    fn a_paragraph_is_not_cut_at_its_own_leading_because_a_table_is_tighter() {
12594        fn line(text: &str, x: f64, y: f64) -> TextSpan {
12595            // The measured width kept equal to `estimated_width()` (font_size *
12596            // 0.5 * chars), so the line fills the measure by both readings and
12597            // the merge is deciding about the gap and nothing else.
12598            span(text, x, y, 6.0 * text.chars().count() as f64)
12599        }
12600
12601        let mut spans = Vec::new();
12602        // A bank of figures on a 6 point pitch: three columns, four rows.
12603        for (row, y) in [700.0, 694.0, 688.0, 682.0].iter().enumerate() {
12604            spans.push(line(["SWA", "BHM", "SBS", "VBS"][row], 72.0, *y));
12605            spans.push(line("2011", 150.0, *y));
12606            spans.push(line(["11820", "2618", "3504", "2215"][row], 230.0, *y));
12607        }
12608        // One paragraph of prose on a 14 point leading, every line but the last
12609        // filling the measure and none of them ending a sentence.
12610        let prose = [
12611            "the occurrence of brachygnathia inferior and hernias",
12612            "showed statistically highly significant differences be",
12613            "tween breeds and the most common defect was recorded",
12614            "in the flocks of the region.",
12615        ];
12616        for (text, y) in prose.iter().zip([640.0, 626.0, 612.0, 598.0]) {
12617            spans.push(line(text, 72.0, y));
12618        }
12619
12620        let stats = PageStats::from_spans(&spans);
12621        let leading = leading_of_a_region(&spans, &stats);
12622        assert!(
12623            stats.median_line_spacing < leading,
12624            "the fixture is not the defect: quartile {} is not under the leading {leading}",
12625            stats.median_line_spacing
12626        );
12627        assert!(
12628            stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER < leading,
12629            "the unfloored threshold {} is not under the leading {leading}, so this \
12630             fixture would pass without the fix",
12631            stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
12632        );
12633
12634        // `paragraph_blocks` and not `block_texts`: the merge is what this is
12635        // about, and `block_texts` stops at the bands.
12636        let texts: Vec<String> = paragraph_blocks(spans, &[], 0, 0, TableStrategy::Both)
12637            .into_iter()
12638            .map(|block| block.text)
12639            .collect();
12640        let whole = prose.join(" ");
12641        assert!(
12642            texts.iter().any(|block| block == &whole),
12643            "the paragraph was cut at its own leading: {texts:?}"
12644        );
12645    }
12646
12647    #[test]
12648    fn xy_cut_leaf_falls_back_to_legacy_columns_for_header_plus_three_columns() {
12649        let texts = block_texts(vec![
12650            span("73022", 45.0, 750.0, 70.0),
12651            span("Federal Register banner", 125.6, 750.0, 260.0),
12652            span("Left column line one", 45.0, 725.0, 140.0),
12653            span("Middle column line one", 222.0, 725.0, 140.0),
12654            span("Right column line one", 399.0, 725.0, 120.0),
12655            span("Left column line two", 45.0, 715.0, 140.0),
12656            span("Middle column line two", 210.0, 715.0, 152.0),
12657            span("Right column line two", 388.0, 715.0, 132.0),
12658            span("Left column line three", 45.0, 705.0, 140.0),
12659            span("Middle column line three", 235.0, 705.0, 135.0),
12660            span("Right column line three", 408.0, 705.0, 118.0),
12661        ]);
12662
12663        assert_eq!(
12664            texts,
12665            vec![
12666                "73022 Federal Register banner",
12667                "Left column line one",
12668                "Left column line two",
12669                "Left column line three",
12670                "Middle column line one",
12671                "Middle column line two",
12672                "Middle column line three",
12673                "Right column line one",
12674                "Right column line two",
12675                "Right column line three",
12676            ]
12677        );
12678    }
12679
12680    #[test]
12681    fn overlapping_fake_bold_spans_collapse_to_single_copy() {
12682        let texts = block_texts(vec![
12683            span("1 This is fakebold text.", 25.9, 785.3, 320.0),
12684            span("1 This is fakebold text.", 26.2, 785.3, 320.0),
12685            span("1 This is fakebold text.", 26.4, 785.3, 320.0),
12686            span("1 This is fakebold text.", 26.7, 785.3, 320.0),
12687            span("2 This is a fakebold", 27.0, 714.8, 142.0),
12688            span(" fakebold", 169.8, 714.8, 70.0),
12689            span(" fakebold", 170.1, 714.8, 70.0),
12690            span(" fakebold word.", 170.4, 714.8, 110.0),
12691        ]);
12692
12693        assert_eq!(
12694            texts,
12695            vec!["1 This is fakebold text.", "2 This is a fakebold word.",]
12696        );
12697    }
12698
12699    /// A span drawn at `degrees`, with the transform a text matrix at that
12700    /// angle composes and the origin the glyphs start from.
12701    fn turned_span(text: &str, degrees: f64, x: f64, y: f64, width: f64) -> TextSpan {
12702        let (sin, cos) = degrees.to_radians().sin_cos();
12703        let scale = 12.0 / 1000.0;
12704        TextSpan {
12705            transform: Some([cos * scale, sin * scale, -sin * scale, cos * scale, x, y]),
12706            ..span(text, x, y, width)
12707        }
12708    }
12709
12710    /// Two lines of one rotated block share the place the page's order held
12711    /// them, and they must come back in the order they are read in. Each is
12712    /// inserted at the same index, and an insertion there pushes the last one
12713    /// down -- so inserting them front to back would emit the block backwards
12714    /// (#435, review round 2).
12715    #[test]
12716    fn two_lines_at_one_place_come_back_in_reading_order() {
12717        // A 90-degree run reads up the page, and its next line is a leading
12718        // further along +x: text space moves down, and down is +x once the
12719        // matrix has turned a quarter turn.
12720        let mut spans = vec![span("the page itself", 100.0, 700.0, 80.0)];
12721        for (index, text) in ["first", "second"].iter().enumerate() {
12722            let x = 60.0 + 14.0 * index as f64;
12723            spans.push(turned_span(text, 90.0, x, 200.0, 30.0));
12724        }
12725        let blocks = vec![TextBlock { spans }];
12726
12727        let out = reassemble_cross_direction_runs(blocks);
12728
12729        let lines: Vec<String> = out.iter().map(|block| block.text()).collect();
12730        assert_eq!(
12731            lines,
12732            vec!["first", "second", "the page itself"],
12733            "a rotated block came back with its lines the wrong way round"
12734        );
12735    }
12736
12737    /// The page's direction is weighed in glyphs, and a span holds more
12738    /// characters than glyphs whenever a glyph decodes to several of them or
12739    /// the space consensus put a space between two -- neither of which was
12740    /// drawn. Counting characters lets a minority direction outvote the page's
12741    /// own (#435, review round 2).
12742    #[test]
12743    fn the_pages_direction_is_weighed_in_glyphs_and_not_characters() {
12744        // Eight characters drawn as two glyphs: a pair of ligatures.
12745        let mut upright = span("fifffifl", 72.0, 700.0, 24.0);
12746        upright.char_bounds = vec![[0.0; 4]; 2];
12747        // Five characters drawn as five glyphs.
12748        let mut turned = turned_span("TURNS", 90.0, 300.0, 300.0, 30.0);
12749        turned.char_bounds = vec![[0.0; 4]; 5];
12750
12751        let direction = page_direction(&[TextBlock {
12752            spans: vec![upright, turned],
12753        }])
12754        .expect("a direction");
12755
12756        assert!(
12757            (direction.1).abs() > 0.5,
12758            "two ligature glyphs outvoted five drawn ones, so the count is of characters"
12759        );
12760    }
12761
12762    /// A run drawn at an angle stops where its last glyph stops, and that is
12763    /// further along its baseline than its page-space width says: a 30-degree
12764    /// run advances `cos 30` of its length in x. Measuring the gap to the next
12765    /// span from `x + width` leaves an eighth of the run as phantom gap, which
12766    /// at a style boundary in the middle of a word puts a space there (#435,
12767    /// review round 1).
12768    #[test]
12769    fn a_turned_run_ends_where_its_last_glyph_ends() {
12770        const DEGREES: f64 = 30.0;
12771        const ADVANCE: f64 = 7.2; // Courier at 12 pt
12772        let (sin, cos) = DEGREES.to_radians().sin_cos();
12773
12774        // "HELLO", five glyphs from (100, 400) along the 30-degree baseline,
12775        // as `draw_glyph` accumulates them: page-space bounds per glyph, and a
12776        // width that is the widest reach in page x.
12777        let mut hello = turned_span("HELLO", DEGREES, 100.0, 400.0, 0.0);
12778        for index in 0..5 {
12779            let x = 100.0 + cos * ADVANCE * index as f64;
12780            let y = 400.0 + sin * ADVANCE * index as f64;
12781            hello.char_bounds.push([x, y, x + ADVANCE, y + 12.0]);
12782            hello.width = hello.width.max(x + ADVANCE - 100.0);
12783        }
12784        // "WORLD" begins exactly where the fifth glyph's advance ended.
12785        let world = turned_span(
12786            "WORLD",
12787            DEGREES,
12788            100.0 + cos * ADVANCE * 5.0,
12789            400.0 + sin * ADVANCE * 5.0,
12790            ADVANCE * 5.0,
12791        );
12792
12793        let block = TextBlock {
12794            spans: vec![hello, world],
12795        };
12796        assert_eq!(
12797            block.text(),
12798            "HELLOWORLD",
12799            "a space was invented at a seam the document draws closed"
12800        );
12801    }
12802
12803    /// A page written entirely at an angle is a page of upright text seen
12804    /// turned, and its own bands read it correctly. Nothing is taken out of
12805    /// them: the direction most of the glyphs are drawn in IS the page's,
12806    /// whatever that direction is (#435).
12807    #[test]
12808    fn a_page_written_entirely_at_an_angle_keeps_its_own_bands() {
12809        let blocks = vec![
12810            TextBlock {
12811                spans: vec![
12812                    turned_span("first line", 90.0, 100.0, 100.0, 60.0),
12813                    turned_span("carried on", 90.0, 100.0, 160.0, 60.0),
12814                ],
12815            },
12816            TextBlock {
12817                spans: vec![turned_span("second line", 90.0, 120.0, 100.0, 66.0)],
12818            },
12819        ];
12820
12821        let out = reassemble_cross_direction_runs(blocks.clone());
12822
12823        let shape: Vec<Vec<&str>> = out
12824            .iter()
12825            .map(|b| b.spans.iter().map(|s| s.text.as_str()).collect())
12826            .collect();
12827        assert_eq!(
12828            shape,
12829            vec![vec!["first line", "carried on"], vec!["second line"]],
12830            "a page in one direction was rearranged by a rule that is about two"
12831        );
12832    }
12833
12834    /// Turned glyphs on one baseline come back as one span, ordered along the
12835    /// baseline with a word space where the gap is one (#574).
12836    #[test]
12837    fn turned_glyphs_on_one_baseline_merge_into_one_span() {
12838        // `AB CD` at 90 degrees: the glyphs climb the page one span each,
12839        // with a word gap between the words wide enough to part the run at
12840        // the cut, which is what merges it.
12841        let spans = vec![
12842            turned_span("A", 90.0, 100.0, 100.0, 7.0),
12843            turned_span("B", 90.0, 100.0, 107.0, 7.0),
12844            turned_span("C", 90.0, 100.0, 128.0, 7.0),
12845            turned_span("D", 90.0, 100.0, 135.0, 7.0),
12846        ];
12847        let out = merge_rotated_runs(spans);
12848        assert_eq!(out.len(), 1, "the run was not joined: {out:?}");
12849        assert_eq!(out[0].text, "AB CD");
12850        assert_eq!((out[0].x, out[0].y), (100.0, 100.0));
12851    }
12852
12853    /// Two runs that share a baseline but stand more than a word gap apart
12854    /// stay two (#574). Each piece earns its own merge: the tight pair stays
12855    /// as drawn under the lone-tight rule, exactly as if it stood alone.
12856    #[test]
12857    fn turned_runs_split_where_the_white_is_wider_than_any_word_gap() {
12858        let spans = vec![
12859            turned_span("A", 90.0, 100.0, 100.0, 7.0),
12860            turned_span("B", 90.0, 100.0, 107.0, 7.0),
12861            turned_span("C", 90.0, 100.0, 127.0, 7.0),
12862            turned_span("D", 90.0, 100.0, 180.0, 7.0),
12863            turned_span("E", 90.0, 100.0, 187.0, 7.0),
12864        ];
12865        let out = merge_rotated_runs(spans);
12866        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12867        assert_eq!(texts, vec!["AB C", "D", "E"]);
12868    }
12869
12870    /// A style change still ends a turned run, the way it ends a span in the
12871    /// device: a bold word inside one keeps its face (#574).
12872    #[test]
12873    fn a_bold_word_inside_a_turned_run_keeps_its_own_span() {
12874        let lawful = turned_span("A", 90.0, 100.0, 100.0, 7.0);
12875        let mut bold = turned_span("B", 90.0, 100.0, 107.0, 7.0);
12876        bold.is_bold = true;
12877        let out = merge_rotated_runs(vec![lawful, bold]);
12878        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12879        assert_eq!(texts, vec!["A", "B"]);
12880        assert!(!out[0].is_bold && out[1].is_bold);
12881    }
12882
12883    /// A skew below ten degrees is not quarter-turned: its spans come back as
12884    /// they went in (#574).
12885    #[test]
12886    fn a_skew_below_ten_degrees_is_left_alone() {
12887        let spans = vec![
12888            turned_span("A", 3.0, 100.0, 100.0, 7.0),
12889            turned_span("B", 3.0, 107.0, 100.5, 7.0),
12890        ];
12891        assert!(!spans.iter().any(span_is_quarter_turned));
12892        let out = merge_rotated_runs(spans);
12893        assert_eq!(out.len(), 2);
12894        assert_eq!(out[0].text, "A");
12895        assert_eq!(out[1].text, "B");
12896    }
12897
12898    /// Upright spans are never candidates, however they stack (#574).
12899    #[test]
12900    fn upright_spans_come_back_untouched() {
12901        let spans = vec![
12902            span("upper", 100.0, 200.0, 35.0),
12903            span("lower", 100.0, 100.0, 35.0),
12904        ];
12905        let out = merge_rotated_runs(spans);
12906        assert_eq!(out.len(), 2);
12907        assert_eq!(out[0].text, "upper");
12908        assert_eq!(out[1].text, "lower");
12909    }
12910
12911    /// A turned span with no partner on its baseline keeps its place and its
12912    /// shape (#574).
12913    #[test]
12914    fn a_turned_span_with_no_partner_is_left_exactly_as_it_was() {
12915        let spans = vec![
12916            span("body", 72.0, 700.0, 30.0),
12917            turned_span("!", 90.0, 500.0, 300.0, 7.0),
12918        ];
12919        let out = merge_rotated_runs(spans);
12920        assert_eq!(out.len(), 2);
12921        assert_eq!(
12922            (out[1].text.as_str(), out[1].x, out[1].y),
12923            ("!", 500.0, 300.0)
12924        );
12925    }
12926
12927    /// A turned span's right edge is measured, not estimated from its
12928    /// characters: the estimate runs along the baseline, which for a run
12929    /// stood up the page is not an x extent at all (#574).
12930    #[test]
12931    fn a_turned_spans_right_edge_is_measured() {
12932        let turned = turned_span("a whole head", 90.0, 100.0, 540.0, 7.0);
12933        assert_eq!(turned.right(), 107.0);
12934        let upright = span("a whole head", 100.0, 540.0, 72.0);
12935        assert_eq!(upright.right(), 172.0);
12936    }
12937
12938    /// A lone run with tight gaps is left as drawn: #435 already reads it
12939    /// forwards, and merging it only changes the shape downstream counts
12940    /// (#574).
12941    #[test]
12942    fn a_lone_tight_run_is_left_as_drawn() {
12943        let spans = vec![
12944            turned_span("A", 90.0, 100.0, 100.0, 7.0),
12945            turned_span("B", 90.0, 100.0, 107.0, 7.0),
12946        ];
12947        let out = merge_rotated_runs(spans);
12948        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12949        assert_eq!(texts, vec!["A", "B"]);
12950    }
12951
12952    /// Capitals never join across the small digits set between them: a gap
12953    /// another turned run's glyph starts in is not a word gap of this run,
12954    /// however word-wide it is (#574: an axis label's `Delta Omega 01 W`).
12955    #[test]
12956    fn capitals_do_not_join_across_digits_set_between_them() {
12957        let capitals = [
12958            turned_span("O", 90.0, 100.0, 100.0, 7.0),
12959            turned_span("W", 90.0, 100.0, 128.0, 7.0),
12960        ];
12961        let mut zero = turned_span("0", 90.0, 101.8, 110.0, 7.0);
12962        zero.font_name = Some("SmallDigits".to_string());
12963        let mut one = turned_span("1", 90.0, 101.8, 117.0, 7.0);
12964        one.font_name = Some("SmallDigits".to_string());
12965        // The capitals 21 points apart would merge on the gap alone; the
12966        // digits starting inside it part them instead.
12967        let out = merge_rotated_runs(vec![capitals[0].clone(), zero, one, capitals[1].clone()]);
12968        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12969        assert_eq!(texts, vec!["O", "0", "1", "W"]);
12970        // And the control: nothing between the capitals, the run joins.
12971        let out = merge_rotated_runs(capitals.to_vec());
12972        assert_eq!(out.len(), 1, "the control run did not join: {out:?}");
12973        assert_eq!(out[0].text, "O W");
12974    }
12975
12976    /// Parts of one run split by a style change are consecutive, not beside
12977    /// each other: their reading-first baselines stand an advance apart, so
12978    /// neither is the other's sibling and both stay as drawn (#574).
12979    #[test]
12980    fn style_split_parts_of_one_run_are_not_siblings() {
12981        let mut bold_a = turned_span("A", 90.0, 100.0, 100.0, 7.0);
12982        bold_a.is_bold = true;
12983        let mut bold_b = turned_span("B", 90.0, 100.0, 107.0, 7.0);
12984        bold_b.is_bold = true;
12985        let spans = vec![
12986            bold_a,
12987            bold_b,
12988            turned_span("C", 90.0, 100.0, 114.0, 7.0),
12989            turned_span("D", 90.0, 100.0, 121.0, 7.0),
12990        ];
12991        let out = merge_rotated_runs(spans);
12992        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12993        assert_eq!(texts, vec!["A", "B", "C", "D"]);
12994    }
12995
12996    /// A run with a gap on the paragraph-break scale merges: the cut would
12997    /// part it there, and the pieces come back in page order (#574).
12998    #[test]
12999    fn a_run_with_a_cut_scale_gap_merges() {
13000        let spans = vec![
13001            turned_span("A", 90.0, 100.0, 100.0, 7.0),
13002            turned_span("B", 90.0, 100.0, 130.0, 7.0),
13003        ];
13004        let out = merge_rotated_runs(spans);
13005        assert_eq!(out.len(), 1);
13006        assert_eq!(out[0].text, "A B");
13007    }
13008
13009    /// Two turned runs beside each other merge: the grid would row them
13010    /// together, one-glyph cells across (#574). Two stacked runs, whose
13011    /// reading-first baselines stand rows apart, stay as drawn.
13012    #[test]
13013    fn sibling_runs_beside_each_other_merge() {
13014        let beside = vec![
13015            turned_span("A", 90.0, 100.0, 100.0, 7.0),
13016            turned_span("B", 90.0, 100.0, 107.0, 7.0),
13017            turned_span("C", 90.0, 200.0, 100.0, 7.0),
13018            turned_span("D", 90.0, 200.0, 107.0, 7.0),
13019        ];
13020        let out = merge_rotated_runs(beside);
13021        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
13022        assert_eq!(texts, vec!["AB", "CD"]);
13023
13024        let stacked = vec![
13025            turned_span("A", 90.0, 100.0, 100.0, 7.0),
13026            turned_span("B", 90.0, 100.0, 107.0, 7.0),
13027            turned_span("C", 90.0, 100.0, 200.0, 7.0),
13028            turned_span("D", 90.0, 100.0, 207.0, 7.0),
13029        ];
13030        let out = merge_rotated_runs(stacked);
13031        let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
13032        assert_eq!(texts, vec!["A", "B", "C", "D"]);
13033    }
13034
13035    /// The page's direction is the one most of its GLYPHS are drawn in, and a
13036    /// tie goes to the direction that was drawn first -- a rule the answer to
13037    /// which must not depend on which way an iterator happens to walk (#435).
13038    #[test]
13039    fn the_pages_direction_is_the_heaviest_and_a_tie_goes_to_the_first() {
13040        let upright = TextBlock {
13041            spans: vec![span("eight ch", 0.0, 700.0, 48.0)],
13042        };
13043        let turned = TextBlock {
13044            spans: vec![turned_span("ninety deg", 90.0, 300.0, 300.0, 60.0)],
13045        };
13046
13047        let heavier = page_direction(&[upright.clone(), turned.clone()]).expect("a direction");
13048        assert!(
13049            (heavier.1).abs() > 0.5,
13050            "ten glyphs at ninety degrees outweigh eight upright ones, and did not"
13051        );
13052
13053        let tie = TextBlock {
13054            spans: vec![span("ninety deg", 0.0, 700.0, 60.0)],
13055        };
13056        let first = page_direction(&[tie, turned]).expect("a direction");
13057        assert!(
13058            (first.1).abs() < 0.5,
13059            "a tie went to the direction that was not drawn first"
13060        );
13061    }
13062
13063    /// A page whose every line runs up it is read in the frame it was written
13064    /// in. Its bands lie across its lines, so without the turn the two lines
13065    /// share one band and come back as one (#546).
13066    #[test]
13067    fn a_turned_page_is_read_in_the_frame_it_was_written_in() {
13068        // A quarter-turned page reads up the page, and its next line is a
13069        // leading further along +x: text space moves down, and down is +x once
13070        // the matrix has turned a quarter turn.
13071        let spans: Vec<TextSpan> = ["the first line", "the second line"]
13072            .iter()
13073            .enumerate()
13074            .map(|(index, text)| turned_span(text, 90.0, 100.0 + 16.0 * index as f64, 100.0, 80.0))
13075            .collect();
13076
13077        let lines = block_texts(spans);
13078
13079        assert_eq!(
13080            lines,
13081            vec!["the first line", "the second line"],
13082            "the lines of a turned page were read in the bands of the frame it is drawn in"
13083        );
13084    }
13085
13086    /// The turn into the page's own frame and the turn back move no coordinate
13087    /// by a single bit: a quarter turn's sine and cosine are 0 and ±1, so what
13088    /// a caller reads off a span of a turned page is the page's own number and
13089    /// not a rounding of it (#546).
13090    #[test]
13091    fn the_turn_into_the_frame_and_back_leaves_every_number_as_it_was() {
13092        // Courier at 12 pt, and a run of six glyphs up the page.
13093        const ADVANCE: f64 = 7.2;
13094        const X: f64 = 123.75;
13095        const Y: f64 = 456.5;
13096        // The width is what `draw_glyph` accumulates: the furthest a glyph
13097        // reaches in page x, which for a quarter turn is one advance however
13098        // long the run is. Written as that subtraction and not as the advance,
13099        // because `x + advance - x` is not `advance` in binary and the number
13100        // the caller gets back is the number the device measured.
13101        let mut original = turned_span("TURNED", 90.0, X, Y, (X + ADVANCE) - X);
13102        for index in 0..6 {
13103            let y = Y + ADVANCE * index as f64;
13104            original.char_bounds.push([X, y, X + ADVANCE, y + 12.0]);
13105        }
13106
13107        for turn in [PageTurn::Quarter, PageTurn::Half, PageTurn::ThreeQuarter] {
13108            let spans = turn.into_frame(vec![original.clone()]);
13109            let blocks = turn.back(vec![TextBlock { spans }]);
13110            let span = &blocks[0].spans[0];
13111
13112            assert_eq!(
13113                (span.x, span.y),
13114                (original.x, original.y),
13115                "{turn:?} moved the span's origin"
13116            );
13117            assert_eq!(
13118                span.transform, original.transform,
13119                "{turn:?} left the baseline turned"
13120            );
13121            assert_eq!(
13122                span.char_bounds, original.char_bounds,
13123                "{turn:?} moved a glyph bound"
13124            );
13125            assert_eq!(
13126                span.width, original.width,
13127                "{turn:?} left a frame width behind"
13128            );
13129        }
13130    }
13131
13132    /// Only a page written in a quarter turn is turned: an upright one, a page
13133    /// drawn entirely at a free angle, and a page whose turned text is the
13134    /// minority all keep the bands they have (#546).
13135    #[test]
13136    fn a_page_is_turned_by_a_quarter_turn_or_by_nothing() {
13137        let cases = [
13138            (0.0, PageTurn::None),
13139            (90.0, PageTurn::Quarter),
13140            (180.0, PageTurn::Half),
13141            (270.0, PageTurn::ThreeQuarter),
13142            (45.0, PageTurn::None),
13143            (-52.0, PageTurn::None),
13144        ];
13145        for (degrees, expected) in cases {
13146            let spans = vec![turned_span("a line of it", degrees, 100.0, 100.0, 72.0)];
13147            assert_eq!(
13148                PageTurn::of(&spans),
13149                expected,
13150                "a page written at {degrees} degrees"
13151            );
13152        }
13153
13154        // The watermark of #435: turned, and outnumbered by the page it is on.
13155        let page = vec![
13156            span(
13157                "the body of the page is written upright",
13158                72.0,
13159                700.0,
13160                230.0,
13161            ),
13162            turned_span("SANDBOX", 90.0, 300.0, 200.0, 42.0),
13163        ];
13164        assert_eq!(
13165            PageTurn::of(&page),
13166            PageTurn::None,
13167            "a turned watermark turned the page it is drawn on"
13168        );
13169    }
13170
13171    /// A half-turned page keeps the ruling lines the page drew, because a half
13172    /// turn maps a horizontal line to a horizontal line; a quarter-turned page
13173    /// keeps none, because what the device recorded there runs the other way
13174    /// across the page (#546, review round 2).
13175    #[test]
13176    fn only_a_half_turn_keeps_the_pages_ruling_lines() {
13177        let rule = HorizontalRule {
13178            left: 72.0,
13179            right: 540.0,
13180            y: 400.0,
13181        };
13182
13183        let drawn = [rule];
13184        let upright = PageTurn::None.rules_in_frame(&drawn);
13185        assert_eq!(
13186            (
13187                upright.len(),
13188                upright[0].left,
13189                upright[0].right,
13190                upright[0].y
13191            ),
13192            (1, rule.left, rule.right, rule.y),
13193            "an upright page's rules were touched at all"
13194        );
13195
13196        let turned = PageTurn::Half.rules_in_frame(&drawn);
13197        let rotation = PageTurn::Half.rotation();
13198        let (left, y) = turn_point(rule.right, rule.y, rotation);
13199        let (right, _) = turn_point(rule.left, rule.y, rotation);
13200        assert_eq!(
13201            (turned.len(), turned[0].left, turned[0].right, turned[0].y),
13202            (1, left, right, y),
13203            "a half turn lost the page's rules, or left them where they were drawn"
13204        );
13205        assert!(
13206            turned[0].left < turned[0].right,
13207            "the ends of a mirrored rule did not swap, so it reads as empty"
13208        );
13209
13210        for turn in [PageTurn::Quarter, PageTurn::ThreeQuarter] {
13211            assert!(
13212                turn.rules_in_frame(&drawn).is_empty(),
13213                "{turn:?} kept a rule that runs across its reading direction"
13214            );
13215        }
13216    }
13217
13218    /// A page has to be WRITTEN in a direction before it is read in it: with
13219    /// its glyphs split four ways, as pdfbox `AcroFormsRotation` splits them
13220    /// over four blocks of form fields at the four rotations, the heaviest
13221    /// direction is a quarter of the page, and turning by it moves every block
13222    /// on a page that was never turned at all (#546).
13223    #[test]
13224    fn a_page_with_no_majority_direction_is_not_turned() {
13225        let quarter_each = vec![
13226            span("rotation zero", 72.0, 700.0, 78.0),
13227            turned_span("rotation ninety", 90.0, 200.0, 200.0, 90.0),
13228            turned_span("rotation oneeighty", 180.0, 400.0, 400.0, 108.0),
13229            turned_span("rotation twoseventy", 270.0, 500.0, 600.0, 114.0),
13230        ];
13231        assert_eq!(
13232            PageTurn::of(&quarter_each),
13233            PageTurn::None,
13234            "a plurality of a quarter of the glyphs turned the whole page"
13235        );
13236
13237        // The same page with the turned block carrying most of the glyphs is
13238        // a page written in that direction, and is read in it.
13239        let mut majority = quarter_each;
13240        majority[1] = turned_span(
13241            "a line of ninety degree text that is most of this page by a street",
13242            90.0,
13243            200.0,
13244            200.0,
13245            400.0,
13246        );
13247        assert_eq!(
13248            PageTurn::of(&majority),
13249            PageTurn::Quarter,
13250            "a direction carrying most of the page did not turn it"
13251        );
13252    }
13253
13254    /// A span drawn by a horizontal reflection, `[-1, 0, 0, 1, e, f]`. Its
13255    /// baseline runs in `(-1, 0)`, which is where a half turn puts one too --
13256    /// and its vertical axis is upright, which is where a half turn does not.
13257    fn mirrored_span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
13258        let scale = 12.0 / 1000.0;
13259        TextSpan {
13260            transform: Some([-scale, 0.0, 0.0, scale, x, y]),
13261            ..span(text, x, y, width)
13262        }
13263    }
13264
13265    /// A page drawn mirrored is not a page drawn upside down, and the baseline
13266    /// vector cannot tell them apart: both run in `(-1, 0)`. Turning it half a
13267    /// turn negates the vertical axis as well, so a block whose lines descend
13268    /// the page is read bottom to top -- an order invented from a page that was
13269    /// never turned. The determinant separates the two. (codex, review round 2)
13270    #[test]
13271    fn a_mirrored_page_is_not_a_half_turned_page() {
13272        let mirrored = vec![
13273            mirrored_span("the first line of a mirrored page", 400.0, 700.0, 300.0),
13274            mirrored_span("and the second line below it", 400.0, 686.0, 260.0),
13275        ];
13276        assert_eq!(
13277            PageTurn::of(&mirrored),
13278            PageTurn::None,
13279            "a reflection was read as a half turn, and turning it reverses the page"
13280        );
13281
13282        // The control, so the assertion above cannot pass by refusing every
13283        // page: the same two lines actually drawn upside down ARE a half turn.
13284        let half_turned: Vec<TextSpan> = vec![
13285            turned_span(
13286                "the first line of a turned page",
13287                180.0,
13288                400.0,
13289                700.0,
13290                300.0,
13291            ),
13292            turned_span("and the second line below it", 180.0, 400.0, 686.0, 260.0),
13293        ];
13294        assert_eq!(
13295            PageTurn::of(&half_turned),
13296            PageTurn::Half,
13297            "a page genuinely drawn at 180 degrees is no longer turned into its frame"
13298        );
13299
13300        // And a page that is mostly mirrored with an upright minority stays
13301        // untouched rather than being read in the minority's direction: the
13302        // share is of the whole page, not of the rotations on it.
13303        let mut mostly_mirrored = mirrored;
13304        mostly_mirrored.push(span("a short upright caption", 72.0, 100.0, 40.0));
13305        assert_eq!(
13306            PageTurn::of(&mostly_mirrored),
13307            PageTurn::None,
13308            "an upright minority carried the vote on a mirrored page"
13309        );
13310    }
13311
13312    /// The seam allowance of `second_run_carries_on_from_the_first` is one
13313    /// GLYPH of mean advance, and a glyph may decode to more than one
13314    /// character. Six characters drawn as five glyphs -- `office` with a `fi`
13315    /// ligature -- have a 6 pt allowance, where dividing the same advance by
13316    /// the six characters would leave 5 pt and reject a 5.5 pt seam that a
13317    /// re-drawn word arrives with (#432, review round 2).
13318    #[test]
13319    fn the_seam_allowance_counts_glyphs_and_not_characters() {
13320        let prev = span("first", 0.0, 700.0, 30.0);
13321        let mut curr = span("office", 24.5, 700.0, 30.0);
13322        curr.char_bounds = vec![[0.0; 4]; 5];
13323
13324        assert!(
13325            second_run_carries_on_from_the_first(&prev, &curr),
13326            "a ligature shrank the seam allowance to the character count"
13327        );
13328
13329        curr.char_bounds.clear();
13330        assert!(
13331            !second_run_carries_on_from_the_first(&prev, &curr),
13332            "the character count is the fallback, so it has to be the stricter one here"
13333        );
13334    }
13335
13336    // ---- G1: read-only metadata field tests ----
13337
13338    #[test]
13339    fn g1_default_text_span_has_empty_metadata() {
13340        let s = TextSpan::default();
13341        assert_eq!(s.font_name, None);
13342        assert!(!s.is_bold);
13343        assert!(!s.is_italic);
13344        assert_eq!(s.color, None);
13345    }
13346
13347    #[test]
13348    fn g1_strip_subset_prefix_handles_six_char_prefix() {
13349        assert_eq!(strip_subset_prefix("AAAAAA+Helvetica"), "Helvetica");
13350        // Non-6-char prefix → keep verbatim.
13351        assert_eq!(strip_subset_prefix("ABC+Helvetica"), "ABC+Helvetica");
13352        // No `+` → unchanged.
13353        assert_eq!(strip_subset_prefix("Helvetica-Bold"), "Helvetica-Bold");
13354    }
13355
13356    #[test]
13357    fn g1_name_style_hints_match_pdf_interpret_rules() {
13358        assert_eq!(name_style_hints("Helvetica-Bold"), (true, false));
13359        assert_eq!(name_style_hints("Times-Italic"), (false, true));
13360        assert_eq!(name_style_hints("MyFont-BoldOblique"), (true, true));
13361        assert_eq!(name_style_hints("Helvetica"), (false, false));
13362        // Semibold / Demi / Heavy / Black variants → bold.
13363        assert_eq!(name_style_hints("Roboto-DemiBold"), (true, false));
13364        assert_eq!(name_style_hints("Roboto-Black"), (true, false));
13365        // Oblique / slant variants → italic.
13366        assert_eq!(name_style_hints("Roboto-Oblique"), (false, true));
13367        assert_eq!(name_style_hints("MyFont-Slanted"), (false, true));
13368    }
13369
13370    // ---- G2: widthSource + char_bounds on TextSpan ----
13371
13372    #[test]
13373    fn g2_default_text_span_has_estimate_width_source() {
13374        let s = TextSpan::default();
13375        assert_eq!(s.width_source, WidthSource::Estimate);
13376        assert!(s.char_bounds.is_empty());
13377    }
13378
13379    /// Verify that a single-glyph span has exactly one char_bound entry
13380    /// and that the bound matches the span's x / width.
13381    #[test]
13382    fn g2_single_glyph_span_has_one_char_bound() {
13383        let s = TextSpan {
13384            text: "A".into(),
13385            x: 10.0,
13386            y: 100.0,
13387            width: 7.22,
13388            height: 10.0,
13389            font_size: 10.0,
13390            width_source: WidthSource::Metric,
13391            char_bounds: vec![[10.0, 100.0, 17.22, 110.0]],
13392            ..Default::default()
13393        };
13394
13395        assert_eq!(s.char_bounds.len(), 1);
13396        let [x0, y0, x1, y1] = s.char_bounds[0];
13397        assert!((x0 - 10.0).abs() < 0.001);
13398        assert!((x1 - 17.22).abs() < 0.001);
13399        assert!((y1 - y0 - s.font_size).abs() < 0.001);
13400    }
13401
13402    /// When merging glyphs into a span the width_source degrades to Estimate
13403    /// if any glyph was estimated.
13404    #[test]
13405    fn g2_merged_span_degrades_width_source_on_estimate() {
13406        let mut s = TextSpan {
13407            width_source: WidthSource::Metric,
13408            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13409            ..Default::default()
13410        };
13411
13412        // Simulate what draw_glyph does on merge: push bound + downgrade.
13413        s.char_bounds.push([7.0, 0.0, 12.0, 10.0]);
13414        s.width_source = WidthSource::Estimate; // second glyph had no advance
13415
13416        assert_eq!(s.width_source, WidthSource::Estimate);
13417        assert_eq!(s.char_bounds.len(), 2);
13418    }
13419
13420    /// Verify WidthSource enum serialises to the two expected string literals.
13421    #[test]
13422    fn g2_width_source_variants_are_correct() {
13423        assert_eq!(format!("{:?}", WidthSource::Metric), "Metric");
13424        assert_eq!(format!("{:?}", WidthSource::Estimate), "Estimate");
13425        assert_ne!(WidthSource::Metric, WidthSource::Estimate);
13426        assert_eq!(WidthSource::default(), WidthSource::Estimate);
13427    }
13428
13429    // ---- M3: RichGeometry / tight glyph bounds tests ----
13430
13431    #[test]
13432    fn m3_default_text_span_has_basic_mode() {
13433        let s = TextSpan::default();
13434        assert_eq!(s.geometry_mode, GeometryMode::Basic);
13435        assert_eq!(s.bounds_source, BoundsSource::Advance);
13436        assert!(s.tight_char_bounds.is_empty());
13437        assert!(s.glyph_advances.is_empty());
13438    }
13439
13440    #[test]
13441    fn m3_geometry_mode_default_is_basic() {
13442        assert_eq!(GeometryMode::default(), GeometryMode::Basic);
13443    }
13444
13445    #[test]
13446    fn m3_bounds_source_default_is_advance() {
13447        assert_eq!(BoundsSource::default(), BoundsSource::Advance);
13448    }
13449
13450    #[test]
13451    fn m3_geometry_mode_variants_are_distinct() {
13452        assert_ne!(GeometryMode::Basic, GeometryMode::RichGeometry);
13453        assert_eq!(format!("{:?}", GeometryMode::Basic), "Basic");
13454        assert_eq!(format!("{:?}", GeometryMode::RichGeometry), "RichGeometry");
13455    }
13456
13457    #[test]
13458    fn m3_bounds_source_variants_are_distinct() {
13459        assert_ne!(BoundsSource::Advance, BoundsSource::Tight);
13460        assert_ne!(BoundsSource::Tight, BoundsSource::Estimate);
13461        assert_eq!(format!("{:?}", BoundsSource::Advance), "Advance");
13462        assert_eq!(format!("{:?}", BoundsSource::Tight), "Tight");
13463        assert_eq!(format!("{:?}", BoundsSource::Estimate), "Estimate");
13464    }
13465
13466    #[test]
13467    fn m3_text_span_rich_geometry_preserves_existing_fields() {
13468        let s = TextSpan {
13469            text: "Hello".into(),
13470            x: 10.0,
13471            y: 100.0,
13472            width: 30.0,
13473            height: 12.0,
13474            font_size: 12.0,
13475            font_name: Some("Helvetica".into()),
13476            is_bold: true,
13477            is_italic: false,
13478            color: Some([0, 0, 0, 255]),
13479            width_source: WidthSource::Metric,
13480            char_bounds: vec![[10.0, 100.0, 40.0, 112.0]],
13481            geometry_mode: GeometryMode::RichGeometry,
13482            bounds_source: BoundsSource::Tight,
13483            tight_char_bounds: vec![[8.5, 98.5, 41.5, 114.0]],
13484            glyph_advances: vec![30.0],
13485            glyph_bounds_sources: vec![BoundsSource::Tight],
13486            ..Default::default()
13487        };
13488
13489        // Existing fields preserved.
13490        assert_eq!(s.text, "Hello");
13491        assert!((s.x - 10.0).abs() < 0.001);
13492        assert!((s.y - 100.0).abs() < 0.001);
13493        assert_eq!(s.width_source, WidthSource::Metric);
13494        assert_eq!(s.char_bounds.len(), 1);
13495
13496        // M3 fields populated.
13497        assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
13498        assert_eq!(s.bounds_source, BoundsSource::Tight);
13499        assert_eq!(s.tight_char_bounds.len(), 1);
13500        assert_eq!(s.glyph_advances.len(), 1);
13501        assert!((s.glyph_advances[0] - 30.0).abs() < 0.001);
13502    }
13503
13504    #[test]
13505    fn m3_text_span_basic_mode_does_not_populate_tight_fields() {
13506        let s = TextSpan {
13507            text: "A".into(),
13508            x: 0.0,
13509            y: 0.0,
13510            width: 7.0,
13511            height: 10.0,
13512            font_size: 10.0,
13513            geometry_mode: GeometryMode::Basic,
13514            bounds_source: BoundsSource::Advance,
13515            ..Default::default()
13516        };
13517
13518        assert!(s.tight_char_bounds.is_empty());
13519        assert!(s.glyph_advances.is_empty());
13520    }
13521
13522    #[test]
13523    fn m3_tight_char_bounds_are_optional_in_basic_mode() {
13524        // A span from Basic mode should have empty tight fields.
13525        let s = TextSpan {
13526            text: "test".into(),
13527            x: 0.0,
13528            y: 0.0,
13529            width: 40.0,
13530            height: 12.0,
13531            font_size: 12.0,
13532            geometry_mode: GeometryMode::Basic,
13533            ..Default::default()
13534        };
13535        assert!(s.tight_char_bounds.is_empty());
13536        assert!(s.glyph_advances.is_empty());
13537    }
13538
13539    #[test]
13540    fn m3_test_span_constructor_preserves_all_fields() {
13541        // Verify the span constructor used in existing tests still works.
13542        let s = span("hello", 10.0, 100.0, 30.0);
13543        assert_eq!(s.geometry_mode, GeometryMode::Basic);
13544        assert_eq!(s.bounds_source, BoundsSource::Advance);
13545        assert!(s.tight_char_bounds.is_empty());
13546    }
13547
13548    #[test]
13549    fn m3_width_source_unchanged_by_m3() {
13550        // WidthSource should not be affected by RichGeometry.
13551        assert_eq!(WidthSource::Metric, WidthSource::Metric);
13552        assert_eq!(WidthSource::Estimate, WidthSource::Estimate);
13553        assert_eq!(WidthSource::default(), WidthSource::Estimate);
13554    }
13555
13556    #[test]
13557    fn m3_transform_bbox_corners_identity() {
13558        let bbox = [0.0, 0.0, 10.0, 12.0];
13559        let identity = kurbo::Affine::IDENTITY;
13560        let result = transform_bbox_corners(&bbox, &identity);
13561        assert!((result[0] - 0.0).abs() < 0.001);
13562        assert!((result[1] - 0.0).abs() < 0.001);
13563        assert!((result[2] - 10.0).abs() < 0.001);
13564        assert!((result[3] - 12.0).abs() < 0.001);
13565    }
13566
13567    #[test]
13568    fn m3_transform_bbox_corners_translation() {
13569        let bbox = [0.0, 0.0, 10.0, 12.0];
13570        let t = kurbo::Affine::translate((50.0, 100.0));
13571        let result = transform_bbox_corners(&bbox, &t);
13572        assert!((result[0] - 50.0).abs() < 0.001);
13573        assert!((result[1] - 100.0).abs() < 0.001);
13574        assert!((result[2] - 60.0).abs() < 0.001);
13575        assert!((result[3] - 112.0).abs() < 0.001);
13576    }
13577
13578    #[test]
13579    fn m3_transform_bbox_corners_scale() {
13580        let bbox = [0.0, 0.0, 10.0, 12.0];
13581        let s = kurbo::Affine::scale(2.0);
13582        let result = transform_bbox_corners(&bbox, &s);
13583        assert!((result[0] - 0.0).abs() < 0.001);
13584        assert!((result[1] - 0.0).abs() < 0.001);
13585        assert!((result[2] - 20.0).abs() < 0.001);
13586        assert!((result[3] - 24.0).abs() < 0.001);
13587    }
13588
13589    #[test]
13590    fn m3_transform_bbox_corners_with_negative_bbox() {
13591        // Glyphs with descenders have negative y0 in font space.
13592        let bbox = [-5.0, -200.0, 15.0, 800.0];
13593        let s = kurbo::Affine::scale(0.012); // 12pt font at 1000-upem
13594        let result = transform_bbox_corners(&bbox, &s);
13595        assert!((result[0] - (-0.06)).abs() < 0.01);
13596        assert!((result[1] - (-2.4)).abs() < 0.01);
13597        assert!((result[2] - 0.18).abs() < 0.01);
13598        assert!((result[3] - 9.6).abs() < 0.01);
13599    }
13600
13601    #[test]
13602    fn m3_tight_char_bounds_descender_detection() {
13603        // A glyph with a descender should have tight_char_bounds.y0 < origin.y0.
13604        let s = TextSpan {
13605            text: "g".into(),
13606            x: 100.0,
13607            y: 200.0, // baseline
13608            width: 8.0,
13609            height: 12.0,
13610            font_size: 12.0,
13611            geometry_mode: GeometryMode::RichGeometry,
13612            bounds_source: BoundsSource::Tight,
13613            tight_char_bounds: vec![[99.0, 197.5, 109.0, 210.0]],
13614            char_bounds: vec![[100.0, 200.0, 108.0, 212.0]],
13615            glyph_advances: vec![8.0],
13616            ..Default::default()
13617        };
13618
13619        // Tight y0 (197.5) < span y (200.0) — descender extends below baseline.
13620        assert!(
13621            s.tight_char_bounds[0][1] < s.y,
13622            "descender should extend below baseline"
13623        );
13624    }
13625
13626    #[test]
13627    fn m3_tight_char_bounds_ascender_detection() {
13628        // A glyph with an ascender should have tight_char_bounds.y1 > advance y1.
13629        let s = TextSpan {
13630            text: "f".into(),
13631            x: 100.0,
13632            y: 200.0,
13633            width: 7.0,
13634            height: 12.0,
13635            font_size: 12.0,
13636            geometry_mode: GeometryMode::RichGeometry,
13637            bounds_source: BoundsSource::Tight,
13638            tight_char_bounds: vec![[95.0, 200.0, 108.0, 214.0]],
13639            char_bounds: vec![[100.0, 200.0, 107.0, 212.0]],
13640            glyph_advances: vec![7.0],
13641            ..Default::default()
13642        };
13643
13644        // Tight y1 (214.0) > advance y1 (200.0 + 12.0 = 212.0).
13645        let advance_y1 = s.y + s.height;
13646        assert!(
13647            s.tight_char_bounds[0][3] > advance_y1,
13648            "ascender should extend above advance bounds"
13649        );
13650    }
13651
13652    #[test]
13653    fn m3_tight_char_bounds_differ_from_advance() {
13654        // Verify tight bounds produce a different bbox than advance bounds.
13655        let s = TextSpan {
13656            text: "A".into(),
13657            x: 10.0,
13658            y: 100.0,
13659            width: 7.22,
13660            height: 10.0,
13661            font_size: 10.0,
13662            geometry_mode: GeometryMode::RichGeometry,
13663            bounds_source: BoundsSource::Tight,
13664            char_bounds: vec![[10.0, 100.0, 17.22, 110.0]], // advance
13665            tight_char_bounds: vec![[9.5, 100.2, 17.5, 109.5]], // tight (different)
13666            glyph_advances: vec![7.22],
13667            ..Default::default()
13668        };
13669
13670        let ac = s.char_bounds[0];
13671        let tc = s.tight_char_bounds[0];
13672        let differs = (ac[0] - tc[0]).abs() > 0.01
13673            || (ac[1] - tc[1]).abs() > 0.01
13674            || (ac[2] - tc[2]).abs() > 0.01
13675            || (ac[3] - tc[3]).abs() > 0.01;
13676        assert!(differs, "tight bounds should differ from advance bounds");
13677    }
13678
13679    #[test]
13680    fn m3_device_new_uses_basic_mode() {
13681        let dev = TextExtractionDevice::new();
13682        assert_eq!(dev.geometry_mode, GeometryMode::Basic);
13683        assert!(dev.deferred_rich_glyphs.is_empty());
13684    }
13685
13686    #[test]
13687    fn m3_device_with_mode_rich_geometry() {
13688        let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13689        assert_eq!(dev.geometry_mode, GeometryMode::RichGeometry);
13690        assert!(dev.deferred_rich_glyphs.is_empty());
13691    }
13692
13693    /// A synthetic rectangular glyph outline in raw design space, plus its
13694    /// bbox. The renderer (`renderer.rs::fill_glyph`) fills this raw outline
13695    /// with `composed = transform * glyph_transform` and NO upem pre-scaling,
13696    /// so the page-space bbox of a tight glyph MUST equal
13697    /// `(composed * outline).bounding_box()`. These tests use that as the
13698    /// ground-truth oracle, which is what guards against scaling regressions.
13699    #[cfg(test)]
13700    fn synthetic_glyph_rect() -> (BezPath, kurbo::Rect) {
13701        let mut path = BezPath::new();
13702        path.move_to((100.0, 0.0));
13703        path.line_to((600.0, 0.0));
13704        path.line_to((600.0, 700.0));
13705        path.line_to((100.0, 700.0));
13706        path.close_path();
13707        let bbox = path.bounding_box();
13708        (path, bbox)
13709    }
13710
13711    #[test]
13712    fn m3_basic_mode_skips_tight_bounds_computation() {
13713        // Basic mode must NOT compute tight bounds, even when a span is present.
13714        let mut dev = TextExtractionDevice::new();
13715        dev.spans.push(TextSpan {
13716            text: "A".into(),
13717            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13718            ..Default::default()
13719        });
13720        let spans = dev.into_spans();
13721        assert_eq!(spans.len(), 1);
13722        assert!(spans[0].tight_char_bounds.is_empty());
13723        assert!(spans[0].glyph_bounds_sources.is_empty());
13724        assert_eq!(spans[0].bounds_source, BoundsSource::Advance);
13725    }
13726
13727    #[test]
13728    fn m3_rich_geometry_tight_bounds_match_rendered_outline() {
13729        // Axis-aligned scale 0.01: the page-space tight bbox must equal the
13730        // bbox of the transformed outline — i.e. design [100,600]x[0,700]
13731        // scaled by 0.01 → [1.0, 0.0, 6.0, 7.0]. A double-scale regression
13732        // (e.g. an extra font_size/1000) would yield ~[0.01, 0, 0.06, 0.07].
13733        let (path, bbox) = synthetic_glyph_rect();
13734        let composed = Affine::scale(0.01);
13735        let oracle = (composed * path.clone()).bounding_box();
13736
13737        let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13738        dev.spans.push(TextSpan {
13739            text: "A".into(),
13740            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13741            geometry_mode: GeometryMode::RichGeometry,
13742            ..Default::default()
13743        });
13744        dev.deferred_rich_glyphs.push(DeferredGlyph {
13745            coeffs: composed.as_coeffs(),
13746            font_size: 10.0,
13747            glyph_width: 7.0,
13748            needs_exact: false,
13749            outline: Some(path),
13750            font_bbox: Some(bbox),
13751        });
13752
13753        let spans = dev.into_spans();
13754        assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
13755        assert_eq!(spans[0].bounds_source, BoundsSource::Tight);
13756        let tb = spans[0].tight_char_bounds[0];
13757        assert!(
13758            (tb[0] - oracle.x0).abs() < 1e-6,
13759            "x0: got {} want {}",
13760            tb[0],
13761            oracle.x0
13762        );
13763        assert!(
13764            (tb[1] - oracle.y0).abs() < 1e-6,
13765            "y0: got {} want {}",
13766            tb[1],
13767            oracle.y0
13768        );
13769        assert!(
13770            (tb[2] - oracle.x1).abs() < 1e-6,
13771            "x1: got {} want {}",
13772            tb[2],
13773            oracle.x1
13774        );
13775        assert!(
13776            (tb[3] - oracle.y1).abs() < 1e-6,
13777            "y1: got {} want {}",
13778            tb[3],
13779            oracle.y1
13780        );
13781        // Sanity: tight height (~7) is commensurate with the advance font_size
13782        // (10), not ~100x smaller — the regression this test exists to catch.
13783        assert!(
13784            tb[3] - tb[1] > 1.0,
13785            "tight height must be commensurate with font size"
13786        );
13787    }
13788
13789    #[test]
13790    fn m3_rich_geometry_tight_bounds_rotation_is_exact() {
13791        // 90° rotation routes through the needs_exact (transform-then-bound)
13792        // path. Oracle = bbox of the rotated outline.
13793        let (path, bbox) = synthetic_glyph_rect();
13794        let composed = Affine::scale(0.01) * Affine::rotate(std::f64::consts::FRAC_PI_2);
13795        let oracle = (composed * path.clone()).bounding_box();
13796
13797        let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13798        dev.spans.push(TextSpan {
13799            text: "A".into(),
13800            char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13801            geometry_mode: GeometryMode::RichGeometry,
13802            ..Default::default()
13803        });
13804        dev.deferred_rich_glyphs.push(DeferredGlyph {
13805            coeffs: composed.as_coeffs(),
13806            font_size: 10.0,
13807            glyph_width: 7.0,
13808            needs_exact: true,
13809            outline: Some(path),
13810            font_bbox: Some(bbox),
13811        });
13812
13813        let spans = dev.into_spans();
13814        assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
13815        let tb = spans[0].tight_char_bounds[0];
13816        assert!(
13817            (tb[0] - oracle.x0).abs() < 1e-6,
13818            "x0: got {} want {}",
13819            tb[0],
13820            oracle.x0
13821        );
13822        assert!(
13823            (tb[1] - oracle.y0).abs() < 1e-6,
13824            "y0: got {} want {}",
13825            tb[1],
13826            oracle.y0
13827        );
13828        assert!(
13829            (tb[2] - oracle.x1).abs() < 1e-6,
13830            "x1: got {} want {}",
13831            tb[2],
13832            oracle.x1
13833        );
13834        assert!(
13835            (tb[3] - oracle.y1).abs() < 1e-6,
13836            "y1: got {} want {}",
13837            tb[3],
13838            oracle.y1
13839        );
13840    }
13841
13842    #[test]
13843    fn m3_empty_device_rich_geometry_no_panic() {
13844        let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13845        let text = dev.into_text();
13846        assert!(text.is_empty());
13847    }
13848
13849    #[test]
13850    fn m3_bounds_source_estimate_fallback() {
13851        // Verify that BoundsSource::Estimate can be set on a span.
13852        let s = TextSpan {
13853            geometry_mode: GeometryMode::RichGeometry,
13854            bounds_source: BoundsSource::Estimate,
13855            ..Default::default()
13856        };
13857        assert_eq!(s.bounds_source, BoundsSource::Estimate);
13858        assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
13859        // Fallback still has empty tight fields (no glyphs).
13860        assert!(s.tight_char_bounds.is_empty());
13861    }
13862
13863    /// One paragraph block of a single line, as the merge leaves a line of a
13864    /// double-spaced run: `top` is the top of its box, the box one em deep.
13865    fn line_block(text: &str, left: f64, top: f64, width: f64, size: f64) -> Block {
13866        let span = TextSpan {
13867            text: text.into(),
13868            x: left,
13869            y: top - size * 0.8,
13870            width,
13871            height: size,
13872            font_size: size,
13873            ..TextSpan::default()
13874        };
13875        Block {
13876            kind: BlockKind::Paragraph,
13877            page: 0,
13878            bbox: [left, top - size, left + width, top],
13879            text: text.into(),
13880            spans: vec![span],
13881            index: 0,
13882            continues_previous: false,
13883            confidence: None,
13884            grid: None,
13885            table_origin: TableOrigin::Other,
13886            trace_learned: None,
13887        }
13888    }
13889
13890    /// `texts` as single lines at `pitch`, all `size` on the same left edge
13891    /// and measure.
13892    fn run_of(texts: &[&str], pitch: f64, size: f64) -> Vec<Block> {
13893        texts
13894            .iter()
13895            .enumerate()
13896            .map(|(k, text)| line_block(text, 72.0, 700.0 - pitch * k as f64, 468.0, size))
13897            .collect()
13898    }
13899
13900    #[test]
13901    fn a_double_spaced_paragraph_is_one_paragraph() {
13902        fn line(text: &str, y: f64) -> TextSpan {
13903            // One measure for every line, so the join is deciding about the
13904            // gaps and nothing else.
13905            span(text, 72.0, y, 360.0)
13906        }
13907        let mut spans = Vec::new();
13908        // Single-spaced body: three lines on a 14 point pitch, one paragraph.
13909        for (text, y) in [
13910            "the committee met in ordinary session on tuesday morning",
13911            "and heard the report of the working group on procedure",
13912            "before adjourning for the midday recess.",
13913        ]
13914        .iter()
13915        .zip([700.0, 686.0, 672.0])
13916        {
13917            spans.push(line(text, y));
13918        }
13919        // Six full-width lines on a 24 point pitch: one paragraph set double,
13920        // with a word broken across its second line end.
13921        let run = [
13922            "the occurrence of brachygnathia inferior and hernias showed",
13923            "statistically highly significant differences between breeds and the line-",
13924            "breaks in the middle of a word join back together without a",
13925            "space while the other lines of the passage join with one plain",
13926            "space each so that the whole passage reads as the typesetter",
13927            "set it before the measure broke it into lines.",
13928        ];
13929        for (text, y) in run.iter().zip([630.0, 606.0, 582.0, 558.0, 534.0, 510.0]) {
13930            spans.push(line(text, y));
13931        }
13932        // The join runs after heading classification in the extract paths
13933        // (#636); here every block is still a paragraph, so it runs directly.
13934        let blocks =
13935            join_double_spaced_runs(paragraph_blocks(spans, &[], 0, 0, TableStrategy::Both), 0);
13936        assert_eq!(
13937            blocks.len(),
13938            2,
13939            "the run joined and the body stayed: {:?}",
13940            blocks.iter().map(|block| &block.text).collect::<Vec<_>>()
13941        );
13942        assert_eq!(blocks[0].index, 0);
13943        assert_eq!(blocks[1].index, 1);
13944        assert_eq!(
13945            blocks[1].text,
13946            "the occurrence of brachygnathia inferior and hernias showed statistically \
13947             highly significant differences between breeds and the linebreaks in the \
13948             middle of a word join back together without a space while the other lines \
13949             of the passage join with one plain space each so that the whole passage \
13950             reads as the typesetter set it before the measure broke it into lines."
13951        );
13952        assert_eq!(blocks[1].spans.len(), 6);
13953        assert!(
13954            blocks[1].bbox[1] < 510.0 && blocks[1].bbox[3] > 630.0,
13955            "the box covers the run: {:?}",
13956            blocks[1].bbox
13957        );
13958    }
13959
13960    #[test]
13961    fn a_one_line_list_on_wide_leading_stays_items() {
13962        let blocks = run_of(
13963            &[
13964                "• ripe red apples from the orchard",
13965                "• green pears from the walled garden",
13966                "• dark plums from the far paddock",
13967                "• yellow quinces from the old trees",
13968                "• blue sloes from the hedgerows",
13969            ],
13970            24.0,
13971            12.0,
13972        );
13973        let joined = join_double_spaced_runs(blocks, 0);
13974        assert_eq!(joined.len(), 5);
13975    }
13976
13977    #[test]
13978    fn an_address_block_stays_lines() {
13979        let blocks = run_of(
13980            &[
13981                "Jonathan Pryce",
13982                "12 Red Lion Square",
13983                "London WC1R 4QH",
13984                "United Kingdom",
13985            ],
13986            22.0,
13987            12.0,
13988        );
13989        let joined = join_double_spaced_runs(blocks, 0);
13990        assert_eq!(joined.len(), 4);
13991    }
13992
13993    #[test]
13994    fn label_and_value_lines_stay_apart() {
13995        let blocks = run_of(
13996            &["Ausfertigungsdatum: 20.12.1963", "Vollzitat:"],
13997            24.0,
13998            12.0,
13999        );
14000        let joined = join_double_spaced_runs(blocks, 3);
14001        assert_eq!(joined.len(), 2);
14002        assert_eq!(joined[0].index, 3);
14003        assert_eq!(joined[1].index, 4);
14004    }
14005
14006    #[test]
14007    fn contents_lines_stay_apart() {
14008        let blocks = run_of(
14009            &[
14010                "25 Independent Auditors' Reports",
14011                "26 Consolidated Statements",
14012            ],
14013            24.0,
14014            12.0,
14015        );
14016        let joined = join_double_spaced_runs(blocks, 0);
14017        assert_eq!(joined.len(), 2);
14018    }
14019
14020    #[test]
14021    fn two_paragraphs_on_one_pitch_stay_two() {
14022        let blocks = run_of(
14023            &[
14024                "the surveyors counted every head twice over the long winter.",
14025                "The year before the new regulations came into force was mild",
14026            ],
14027            24.0,
14028            12.0,
14029        );
14030        let joined = join_double_spaced_runs(blocks, 0);
14031        assert_eq!(joined.len(), 2);
14032    }
14033
14034    #[test]
14035    fn section_lines_stay_apart() {
14036        let blocks = run_of(
14037            &[
14038                "§ 2 Begriffe und Definitionen der in diesem Vertrag verwendeten Terminologie",
14039                "§ 3 Anwendungsbereich und Geltung dieser Bestimmungen im gesamten Inland",
14040            ],
14041            24.0,
14042            12.0,
14043        );
14044        let joined = join_double_spaced_runs(blocks, 0);
14045        assert_eq!(joined.len(), 2);
14046    }
14047
14048    #[test]
14049    fn article_lines_stay_apart() {
14050        let blocks = run_of(
14051            &[
14052                "Art. 2 Begriffe und Definitionen der in diesem Vertrag verwendeten Terminologie",
14053                "Art. 3 Anwendungsbereich und Geltung dieser Bestimmungen im gesamten Inland",
14054            ],
14055            24.0,
14056            12.0,
14057        );
14058        let joined = join_double_spaced_runs(blocks, 0);
14059        assert_eq!(joined.len(), 2);
14060    }
14061
14062    #[test]
14063    fn headings_never_join() {
14064        let mut blocks = run_of(
14065            &[
14066                "the occurrence of brachygnathia inferior and hernias showed",
14067                "statistically highly significant differences between breeds and the passage",
14068            ],
14069            24.0,
14070            12.0,
14071        );
14072        blocks[1].kind = BlockKind::Heading { level: 2 };
14073        let joined = join_double_spaced_runs(blocks, 0);
14074        assert_eq!(joined.len(), 2);
14075    }
14076
14077    #[test]
14078    fn m3_multiple_glyphs_tight_bounds_consistent_count() {
14079        // Simulate a span with 3 glyphs — tight_char_bounds and glyph_advances
14080        // should have the same length.
14081        let s = TextSpan {
14082            text: "abc".into(),
14083            x: 0.0,
14084            y: 0.0,
14085            width: 25.0,
14086            height: 12.0,
14087            font_size: 12.0,
14088            geometry_mode: GeometryMode::RichGeometry,
14089            bounds_source: BoundsSource::Tight,
14090            char_bounds: vec![[0., 0., 8., 12.], [8., 0., 16., 12.], [16., 0., 25., 12.]],
14091            tight_char_bounds: vec![
14092                [-1., -2., 9., 14.],
14093                [7., -2., 17., 14.],
14094                [15., -2., 26., 14.],
14095            ],
14096            glyph_advances: vec![8.0, 8.0, 9.0],
14097            ..Default::default()
14098        };
14099
14100        assert_eq!(s.tight_char_bounds.len(), 3);
14101        assert_eq!(s.glyph_advances.len(), 3);
14102        assert_eq!(s.char_bounds.len(), 3);
14103        // Every glyph has advance >= 0.
14104        for &adv in &s.glyph_advances {
14105            assert!(adv >= 0.0, "glyph advance must be non-negative");
14106        }
14107    }
14108
14109    fn styled_span(text: &str, x: f64, y: f64, width: f64, bold: bool) -> TextSpan {
14110        let mut out = span(text, x, y, width);
14111        out.is_bold = bold;
14112        out
14113    }
14114
14115    fn block_of(spans: Vec<TextSpan>) -> Block {
14116        let mut block = Block {
14117            kind: crate::blocks::BlockKind::Paragraph,
14118            page: 0,
14119            bbox: [0.0; 4],
14120            text: String::new(),
14121            spans,
14122            index: 0,
14123            continues_previous: false,
14124            confidence: None,
14125            grid: None,
14126            table_origin: TableOrigin::Other,
14127            trace_learned: None,
14128        };
14129        let lines = lines_of_block(&block);
14130        let texts: Vec<String> = lines.iter().map(|line| line_text(&block, line)).collect();
14131        block.text = crate::blocks::join_lines(&texts);
14132        crate::blocks::recompute_bbox(&mut block);
14133        block
14134    }
14135
14136    #[test]
14137    fn an_inid_field_keeps_its_continuation() {
14138        let upper = block_of(vec![
14139            styled_span("(73)", 72.0, 700.0, 30.0, true),
14140            styled_span(
14141                "Assignee: Schlumberger Technology",
14142                110.0,
14143                700.0,
14144                400.0,
14145                true,
14146            ),
14147        ]);
14148        let lower = block_of(vec![styled_span(
14149            "Corporation, Sugar Land, TX (US)",
14150            110.0,
14151            688.0,
14152            380.0,
14153            false,
14154        )]);
14155        let joined = join_marker_continuations(vec![upper, lower], 0);
14156        assert_eq!(
14157            joined.len(),
14158            1,
14159            "the field was cut off its continuation: {:?}",
14160            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14161        );
14162        assert!(
14163            joined[0]
14164                .text
14165                .contains("Schlumberger Technology Corporation"),
14166            "the join lost the middle: {:?}",
14167            joined[0].text
14168        );
14169        assert_eq!(joined[0].spans.len(), 3);
14170    }
14171
14172    #[test]
14173    fn a_bulleted_item_keeps_its_hung_line() {
14174        let upper = block_of(vec![styled_span(
14175            "\u{2022} Making investments in research and development and in",
14176            72.0,
14177            700.0,
14178            440.0,
14179            true,
14180        )]);
14181        let lower = block_of(vec![styled_span(
14182            "anticipate future waves of innovation across the industry",
14183            100.0,
14184            688.0,
14185            400.0,
14186            false,
14187        )]);
14188        let joined = join_marker_continuations(vec![upper, lower], 0);
14189        assert_eq!(
14190            joined.len(),
14191            1,
14192            "the item was cut off its hung line: {:?}",
14193            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14194        );
14195    }
14196
14197    #[test]
14198    fn a_field_label_ending_in_a_colon_stays_apart() {
14199        let upper = block_of(vec![styled_span(
14200            "(56) References cited:",
14201            72.0,
14202            700.0,
14203            440.0,
14204            true,
14205        )]);
14206        let lower = block_of(vec![styled_span(
14207            "A list of patents and publications considered",
14208            100.0,
14209            688.0,
14210            380.0,
14211            false,
14212        )]);
14213        let joined = join_marker_continuations(vec![upper, lower], 3);
14214        assert_eq!(
14215            joined.len(),
14216            2,
14217            "a label ending in a colon was joined to its value: {:?}",
14218            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14219        );
14220        assert_eq!(joined[0].index, 3);
14221        assert_eq!(joined[1].index, 4);
14222    }
14223
14224    #[test]
14225    fn a_short_marker_line_is_not_joined() {
14226        let upper = block_of(vec![styled_span(
14227            "(12) United States Patent",
14228            72.0,
14229            700.0,
14230            150.0,
14231            true,
14232        )]);
14233        let lower = block_of(vec![styled_span(
14234            "Palaniappan et al.",
14235            100.0,
14236            688.0,
14237            200.0,
14238            false,
14239        )]);
14240        let joined = join_marker_continuations(vec![upper, lower], 0);
14241        assert_eq!(
14242            joined.len(),
14243            2,
14244            "a short marker line was joined to the line under it: {:?}",
14245            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14246        );
14247    }
14248
14249    #[test]
14250    fn the_next_marker_starts_a_new_block() {
14251        let upper = block_of(vec![styled_span(
14252            "(73) Assignee: Schlumberger Technology",
14253            72.0,
14254            700.0,
14255            440.0,
14256            true,
14257        )]);
14258        // Hung under the upper line on purpose, so that only the marker
14259        // clause keeps the two apart.
14260        let lower = block_of(vec![styled_span(
14261            "(21) Appl. No.: 09/789,473",
14262            100.0,
14263            688.0,
14264            380.0,
14265            false,
14266        )]);
14267        let joined = join_marker_continuations(vec![upper, lower], 0);
14268        assert_eq!(
14269            joined.len(),
14270            2,
14271            "the next marker was joined to the item above it: {:?}",
14272            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14273        );
14274    }
14275
14276    #[test]
14277    fn a_wrapped_marker_item_keeps_its_hanging_continuation() {
14278        // The marker is on the FIRST line and the wrap on the LAST: three
14279        // lines before the weight cut parts the item from its continuation.
14280        // Read off the last line alone, the item opens with no marker and
14281        // stays cut.
14282        let upper = block_of(vec![
14283            styled_span(
14284                "\u{2022} Making investments in research and",
14285                72.0,
14286                700.0,
14287                440.0,
14288                true,
14289            ),
14290            styled_span(
14291                "development and in new facilities built",
14292                72.0,
14293                688.0,
14294                440.0,
14295                true,
14296            ),
14297            styled_span(
14298                "to meet demand across the coming years to",
14299                72.0,
14300                676.0,
14301                440.0,
14302                true,
14303            ),
14304        ]);
14305        let lower = block_of(vec![styled_span(
14306            "anticipate future waves of innovation across the industry",
14307            100.0,
14308            664.0,
14309            400.0,
14310            false,
14311        )]);
14312        let joined = join_marker_continuations(vec![upper, lower], 0);
14313        assert_eq!(
14314            joined.len(),
14315            1,
14316            "the wrapped item was cut off its hung line: {:?}",
14317            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14318        );
14319        assert!(
14320            joined[0].text.contains("coming years to anticipate"),
14321            "the join lost the middle: {:?}",
14322            joined[0].text
14323        );
14324        assert_eq!(joined[0].spans.len(), 4);
14325    }
14326
14327    #[test]
14328    fn a_short_heading_line_over_a_full_paragraph_is_not_cut() {
14329        // The short-heading cut is deliberately not shipped with this guard:
14330        // a short line over full lines stays one block through this pass.
14331        let block = block_of(vec![
14332            span("Contingent liabilities", 72.0, 700.0, 150.0),
14333            span(
14334                "Goodwill represents the excess of the consideration transferred",
14335                72.0,
14336                688.0,
14337                440.0,
14338            ),
14339            span(
14340                "over the fair value of the identifiable net assets acquired",
14341                72.0,
14342                676.0,
14343                440.0,
14344            ),
14345            span(
14346                "and is tested for impairment at least once a year.",
14347                72.0,
14348                664.0,
14349                440.0,
14350            ),
14351        ]);
14352        let out = join_marker_continuations(vec![block], 0);
14353        assert_eq!(
14354            out.len(),
14355            1,
14356            "a short heading line was cut off its paragraph: {:?}",
14357            out.iter().map(|b| &b.text).collect::<Vec<_>>()
14358        );
14359        assert_eq!(out[0].spans.len(), 4);
14360        assert_eq!(out[0].index, 0);
14361    }
14362
14363    #[test]
14364    fn a_marker_line_whose_next_line_does_not_hang_stays_apart() {
14365        let upper = block_of(vec![styled_span(
14366            "(73) Assignee: Schlumberger Technology",
14367            72.0,
14368            700.0,
14369            440.0,
14370            true,
14371        )]);
14372        // Flush with the marker line's left edge: a new block, not a hang.
14373        let lower = block_of(vec![styled_span(
14374            "Corporation, Sugar Land, TX (US)",
14375            72.0,
14376            688.0,
14377            380.0,
14378            false,
14379        )]);
14380        let joined = join_marker_continuations(vec![upper, lower], 0);
14381        assert_eq!(
14382            joined.len(),
14383            2,
14384            "a marker line was joined to a line that does not hang under it: {:?}",
14385            joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14386        );
14387    }
14388}