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(®ions.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 ®ions,
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(¤t.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, ©));
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(®ion, &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}