pdf_engine/text.rs
1//! Text extraction via a custom Device implementation.
2
3// Copyright (c) 2026 Innovation Trigger B.V.
4//
5// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
6// the PDFluent Commercial Licence. See the LICENSE file in this repository --
7// that file travels with the copy you received, which a URL does not.
8
9use kurbo::{Affine, BezPath, PathEl, Point, Shape};
10use pdf_render::pdf_interpret::cmap::BfString;
11use pdf_render::pdf_interpret::font::{Glyph, GlyphPlace, OutlineGlyph};
12use pdf_render::pdf_interpret::{
13 BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
14};
15use std::borrow::Cow;
16use std::cell::Cell;
17use std::cmp::Ordering;
18use std::collections::HashSet;
19use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
20
21use crate::blocks::{Block, BlockKind, TableStrategy};
22#[cfg(test)]
23use crate::table_trace::TableOrigin;
24
25/// Minimum Y tolerance for grouping spans into horizontal bands. The
26/// effective tolerance is typically `median_font_size * BAND_Y_FRACTION`
27/// per ANN[r17/TEX4]; this constant acts as the absolute floor.
28const BAND_Y_TOLERANCE: f64 = 5.0;
29/// Fraction of the page's median font size used as the band-Y
30/// tolerance. Empirically 0.30× works across common typography —
31/// below typical leading (~1.2×) so adjacent lines never collapse,
32/// above sub-pixel baseline drift.
33const BAND_Y_FRACTION: f64 = 0.30;
34/// Multiplier applied to median line spacing to derive the horizontal
35/// paragraph-break cut threshold. Normal line-to-line progression is
36/// ~1.0× the median; paragraph breaks typically show 1.5× or more.
37const PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER: f64 = 1.8;
38/// How far a continuation must sit in from its marker line's left edge to
39/// read as hung under the marker's text (#574). Past a third of an em the
40/// indent is the list's own hang and not the page's drift.
41const MARKER_HANG_MIN_EM: f64 = 0.3;
42/// How far in it may sit and still hang from that line (#574). Six ems is
43/// the widest marker-plus-gap on the measured pages; past it the line is a
44/// new indent, not a continuation.
45const MARKER_HANG_MAX_EM: f64 = 6.0;
46/// How close a hung line must start to the marker line's own text column
47/// where the page drew the marker in a span of its own (#574). Half an em
48/// either way; the column is exact, so the tolerance is the drift.
49const MARKER_TEXT_LEFT_TOL_EM: f64 = 0.5;
50/// How far short of the wider line's right edge a wrapping marker line may
51/// stop (#574). A line that ended an em and a half early turned back; one
52/// that ran to the measure wrapped.
53const MARKER_WRAP_SLACK_EM: f64 = 1.5;
54/// The most white a hung continuation may leave above itself (#574). Half
55/// an em over the glyphs is normal leading; more is a paragraph break.
56const MARKER_GAP_MAX_EM: f64 = 0.5;
57
58// ANN[r17/TEX1] Multi-signal consensus thresholds.
59// The previous single-threshold scheme (gap > 0.15 * font_size) missed
60// word boundaries when kerning or narrow fonts produced small measured
61// gaps even though the PDF emitted an explicit TJ backward shift, and
62// over-emitted spaces for condensed fonts where 0.15em of kerning is
63// well below an actual word space. The consensus system weights three
64// signals and inserts a space when the combined confidence exceeds
65// SPACE_CONSENSUS_THRESHOLD.
66/// Raw TJ backward adjustment (positive in PDF TJ units) that is
67/// definitively a word break. Matches pdftotext / MuPDF heuristics —
68/// a space glyph is typically emitted as either a literal 0x20 or as
69/// a TJ adjustment of around 250 1/1000 em. 100 units is a safely
70/// conservative floor.
71const TJ_SPACE_THRESHOLD_UNITS: f32 = 100.0;
72/// Weight of the TJ offset signal when confidence is high.
73const TJ_SIGNAL_WEIGHT: f64 = 0.95;
74/// Weight of the purely geometric gap signal.
75const GAP_SIGNAL_WEIGHT: f64 = 0.80;
76/// Weight of character-heuristic signals (CamelCase, digit↔letter).
77const HEURISTIC_SIGNAL_WEIGHT: f64 = 0.60;
78/// Combined weight at which a space is inserted.
79const SPACE_CONSENSUS_THRESHOLD: f64 = 0.75;
80
81/// How many EARLIER gaps have to agree with the gap being judged before a run
82/// reads as tracked.
83///
84/// Four earlier ones, so five agreeing gaps in all and a run of at least six
85/// glyphs. Two letters set the same distance apart is a coincidence of two
86/// sidebearings; five in a row is the typesetter holding a key down.
87///
88/// THE NAME USED TO SAY `PAIRS` AND THE COUNT WAS READ AS FOUR, which is the
89/// gap the review found (#582): the current gap is recorded AFTER it is
90/// judged, so a five-glyph word offers only three earlier gaps at its last
91/// pair and never reaches this. `TITLE` and `NOTES` therefore come back as
92/// `T I T L E` and `N O T E S` while `REGION` is joined -- at the glyph.
93///
94/// It is left at four rather than lowered to three: while a glyph is being
95/// read the rest of its span is not known. The short words are reached
96/// instead once the span is finished -- `retrack_a_finished_span` asks the
97/// same question of all its gaps at once -- which #1973 measured on the 6,446
98/// DocLayNet validation pages: nine pages change, each a tracked word read
99/// right, none a line of single-letter words. That is the measurement #582
100/// asked for, and `a_five_letter_tracked_word_is_read_once_its_span_is_finished`
101/// pins where the boundary now is.
102const TRACKING_MIN_PAIRS: usize = 4;
103
104/// How far the gaps of a tracked run may differ from one another, as a
105/// fraction of the gap being judged.
106///
107/// A quarter. Tracking is a constant added to every advance, so what varies
108/// inside a tracked run is only the kerning of the pair -- which is why
109/// `D I R E C TO R S` keeps `TO` and `RS` together: those two pairs kern
110/// closed far enough to fall back under the threshold on their own.
111const TRACKING_TOLERANCE: f64 = 0.25;
112
113/// The smallest median gap between a span's glyphs, as a share of its size,
114/// that is letters set apart rather than set solid (#1973). `P E N N` at 6.2
115/// points stands 0.145 em apart, a 60-point display line 0.05, and prose 0 --
116/// its boxes are advances and touch. 0.04 is under the lightest tracking seen
117/// by a margin and far over prose.
118const TRACKED_GAP_MIN_EM: f64 = 0.04;
119/// Fraction of a median character width above which a gap contributes
120/// to the geometric signal (pdf_oxide uses ~0.30).
121const GAP_TO_MEDIAN_CHAR_FRACTION: f64 = 0.30;
122/// Fallback gap fraction relative to `font_size` when the running
123/// median character width has not yet been established.
124const GAP_TO_FONT_SIZE_FALLBACK_FRACTION: f64 = 0.15;
125
126/// Minimum horizontal gap treated as a column gutter (adaptive fallback).
127const COLUMN_GAP_THRESHOLD_MIN: f64 = 10.0;
128/// Maximum adaptive column gap threshold.
129const COLUMN_GAP_THRESHOLD_MAX: f64 = 40.0;
130/// Multiplier applied to median inter-word gap to derive column threshold.
131const COLUMN_GAP_MEDIAN_MULTIPLIER: f64 = 3.0;
132/// Fallback column gap threshold when median cannot be computed.
133const COLUMN_GAP_THRESHOLD_FALLBACK: f64 = 20.0;
134/// Maximum drift allowed when matching gutters across neighboring bands.
135const COLUMN_GAP_MATCH_TOLERANCE: f64 = 12.0;
136/// Minimum number of gapped bands required before we enable column mode.
137const MIN_COLUMN_GAPPED_BANDS: usize = 3;
138/// Minimum fraction of bands in a region that must expose the shared gutters.
139const MIN_COLUMN_GAP_SUPPORT: f64 = 0.80;
140/// Minimum fraction of non-empty column slices that must look like prose.
141const MIN_DENSE_SLICE_RATIO: f64 = 0.35;
142/// How much of its own column the typical slice of a region may cover before
143/// the region reads as prose set in columns rather than as a table (#570).
144/// Under two thirds is a cell with white beside it; a line of prose fills its
145/// measure.
146const GRID_SLICE_FILL: f64 = 0.6;
147/// How many of a region's rows a column has to appear in before the region can
148/// be a grid. Three quarters, so that a table with an empty cell here and there
149/// is still a table and a sidebar beside one column is not.
150const GRID_COLUMN_SUPPORT: f64 = 0.75;
151/// The fewest rows a grid has. Three, because two rows of two short slices is
152/// the shape of a heading beside a date.
153const GRID_MIN_ROWS: usize = 3;
154/// The fewest labels a column of labels has. Three: two names beside two
155/// paragraphs is a heading over a note as often as it is a table.
156const LABEL_MIN_ROWS: usize = 3;
157/// The fewest bands a side of a proposed column cut has to have ink on before
158/// the gaps inside it say anything. Three, the same evidence a grid is asked
159/// for: two bands that both break once are a heading beside a date.
160const BANK_MIN_ROWS: usize = 3;
161/// How many of a side's own bands have to break at a gap as wide as the cut's
162/// before the cut is read as one gutter of a table rather than the gutter
163/// between two columns. More than half: a table breaks every row of both
164/// banks, and a paper set in columns with ONE table in one of its columns
165/// breaks a minority of that column's lines -- which is a page whose column
166/// cut is right and has to stay.
167const BANK_BREAK_SHARE: f64 = 0.5;
168/// How much wider than the wide side's leading a step has to be before it is
169/// the start of something rather than the next line of it. The same question
170/// `blocks::wrapped_leading` asks of a table's rows, and the same answer.
171const LABEL_PITCH_JUMP: f64 = 1.4;
172
173/// Two span starts this far apart are the same cell edge (#434). A cell's
174/// wrapped line is set from the cell's own left edge, so the two lines start on
175/// the same coordinate up to the rounding of the text matrix.
176const CELL_EDGE_TOLERANCE: f64 = 2.0;
177/// How far below its row a line may sit and still be that row's continuation,
178/// as a multiple of the row's tallest glyph. One line of leading is ~1.2; the
179/// next row of a table sits further down than this.
180const WRAPPED_CELL_LINE_GAP_FACTOR: f64 = 1.8;
181/// How far above the row the band that corroborates the grid may sit, counted
182/// in bands. The lines between are the wrapped cells of the row above, so a
183/// couple of them is all a row can put in the way.
184const TABLE_GRID_MAX_LOOKBACK: usize = 4;
185/// The share of the grid's own row pitch a continuation may sit below the line
186/// it continues. A row's pitch is one line plus the cell's padding and a
187/// wrapped line is one line, so a line at the full pitch is the next row.
188const WRAPPED_CELL_MAX_PITCH_FRACTION: f64 = 0.9;
189/// A grid is a claim about columns, so the band that corroborates it has to
190/// carry at least this many of them. Two, because a row of the table is
191/// allowed an empty cell.
192const TABLE_GRID_MIN_COLUMNS: usize = 2;
193/// A drawn path this flat is a line and not a box (#545). The ruling lines of
194/// the Isartor manual's appendix are 0.18 pt of ink; a hairline stroke is a
195/// path of no height at all, and a table's cell shading is eighteen points.
196const RULE_MAX_THICKNESS: f64 = 3.0;
197/// A line shorter than this separates nothing: it is a tick, a bullet or the
198/// underline of a word.
199const RULE_MIN_LENGTH: f64 = 24.0;
200/// How many path boxes are kept per page before they are clustered: a map
201/// drawn from ten thousand paths is one figure, and past this the rest add
202/// nothing to where it is.
203const MAX_RECORDED_PATH_BOXES: usize = 4000;
204
205/// How far apart two path boxes may be, in points, and still be one figure.
206const GRAPHIC_GAP: f64 = 2.0;
207
208fn touches(a: &[f64; 4], b: &[f64; 4], gap: f64) -> bool {
209 a[0] <= b[2] + gap && b[0] <= a[2] + gap && a[1] <= b[3] + gap && b[1] <= a[3] + gap
210}
211
212fn union(a: &[f64; 4], b: &[f64; 4]) -> [f64; 4] {
213 [
214 a[0].min(b[0]),
215 a[1].min(b[1]),
216 a[2].max(b[2]),
217 a[3].max(b[3]),
218 ]
219}
220
221/// How many image boxes are kept per page: a page tiled from hundreds of
222/// image strips is a scan, not a page of pictures to caption (#636).
223const MAX_RECORDED_IMAGES: usize = 256;
224
225/// How many rules are kept. A chart draws thousands of short flat segments and
226/// none of them rules a row; the cap bounds the memory a pathological page can
227/// ask for without changing what any real table looks like.
228const MAX_RECORDED_RULES: usize = 512;
229/// How many lattice rulings are kept (#574). A timetable draws its cells as
230/// several hundred boxes, four edges each; a chart can draw tens of thousands
231/// of segments, and past this the page is not a grid a lattice should read.
232const MAX_RECORDED_RULINGS: usize = 8192;
233/// Grey at or above this (0..255) is paper, not shading (#574).
234const FILL_WHITE_ABOVE: u32 = 247;
235/// The smallest fill, in points, that can stand behind a line of text.
236const FILL_MIN_WIDTH: f64 = 10.0;
237/// The smallest fill, in points, that can stand behind a line of text.
238const FILL_MIN_HEIGHT: f64 = 4.0;
239/// A lattice edge shorter than this is a tick, not a cell border.
240const RULING_MIN_LENGTH: f64 = 3.0;
241/// The share of a region's width a rule has to cross before it counts as
242/// separating that region's rows. A rule between two rows runs the whole table;
243/// a rule under one cell does not.
244const RULE_SPAN_FRACTION: f64 = 0.9;
245/// How many of a region's band gaps have to carry a rule before the region is
246/// a ruled table. Two, so that a single horizontal line -- a header rule, the
247/// line above a footnote -- never speaks for the page, while a region the
248/// recursion has already cut down to three rows can still be recognised as the
249/// table it is. Each of the two has to cross the whole region and lie between
250/// two bands that follow each other, which one rule under a headline does not.
251const MIN_RULED_ROW_SEPARATORS: usize = 2;
252/// The share of a region's band gaps that have to carry such a rule. Half,
253/// because a row whose cell wraps puts a band inside itself, and that gap has
254/// no rule in it.
255const MIN_RULED_GAP_SUPPORT: f64 = 0.5;
256
257/// How many cells the row itself has to fill before a line under it is read as
258/// one of its cells running on.
259///
260/// Three, because two is what a page set in columns looks like: its lines
261/// start on the same two edges as a two-cell row does, one of its columns ends
262/// before the other, and the line left over then reads exactly like a wrapped
263/// cell. On `tracemonkey` p13 it is one -- and the page is two columns of
264/// prose, where moving a line into "its cell" is the column cut of #389 class
265/// O-B done on one band instead of the region. A two-column table pays for
266/// that: its wrapped cells are the other half of #545.
267const TABLE_ROW_MIN_CELLS: usize = 3;
268
269/// Whether a text span's width was computed from real font metrics or estimated.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
271#[cfg_attr(feature = "serde", derive(serde::Serialize))]
272pub enum WidthSource {
273 /// Width derived from the font's actual glyph advance (hmtx, CFF, Type1 charstring).
274 Metric,
275 /// Width estimated at 50 % of font size — no glyph metric was available.
276 #[default]
277 Estimate,
278}
279
280impl WidthSource {
281 /// Stable label used across SDK bindings and the JSON wire form
282 /// (`"Metric"` / `"Estimate"`). Single source of truth for the string; the
283 /// `serde` derive on the enum produces identical values.
284 pub fn as_str(&self) -> &'static str {
285 match self {
286 WidthSource::Metric => "Metric",
287 WidthSource::Estimate => "Estimate",
288 }
289 }
290}
291
292/// Controls the richness of per-glyph geometry extraction.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
294pub enum GeometryMode {
295 /// Advance-based bounds only (current default).
296 #[default]
297 Basic,
298 /// Full tight glyph bounds from outline paths.
299 RichGeometry,
300}
301
302/// Provenance of per-glyph bounding boxes.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
304pub enum BoundsSource {
305 /// Advance bounds.
306 #[default]
307 Advance,
308 /// Path-derived tight bounds.
309 Tight,
310 /// Fallback (no outline available).
311 Estimate,
312}
313
314/// Vertical font metrics in /1000 em (1000 units-per-em), sourced from the
315/// embedded font's OS/2 / hhea tables via skrifa. `ascent` is positive (above
316/// the baseline); `descent` is negative (below it). The whole struct is `None`
317/// on a span when no embedded font binary exposes metrics (non-embedded
318/// standard-14, CFF, Type1, Type3).
319#[derive(Debug, Clone, Copy, PartialEq, Default)]
320#[cfg_attr(feature = "serde", derive(serde::Serialize))]
321pub struct FontMetrics {
322 /// Ascent above the baseline, in /1000 em.
323 pub ascent: f64,
324 /// Descent below the baseline (negative), in /1000 em.
325 pub descent: f64,
326 /// Cap height in /1000 em, when present in the font.
327 #[cfg_attr(
328 feature = "serde",
329 serde(rename = "capHeight", skip_serializing_if = "Option::is_none")
330 )]
331 pub cap_height: Option<f64>,
332 /// x-height in /1000 em, when present in the font.
333 #[cfg_attr(
334 feature = "serde",
335 serde(rename = "xHeight", skip_serializing_if = "Option::is_none")
336 )]
337 pub x_height: Option<f64>,
338}
339
340/// A single text span at a specific position.
341#[derive(Debug, Clone, Default)]
342pub struct TextSpan {
343 /// The extracted text.
344 pub text: String,
345 /// X position in user space.
346 pub x: f64,
347 /// Y position in user space.
348 pub y: f64,
349 /// Approximate bounding-box width in user space.
350 pub width: f64,
351 /// Approximate bounding-box height in user space.
352 pub height: f64,
353 /// Font size (approximate, from transform).
354 pub font_size: f64,
355 /// Whether a content run ends after this span: a consumer joining
356 /// same-style spans must not merge past it (#643). Only set on the raw
357 /// path; the default path leaves every span open, as before.
358 pub complete_run: bool,
359
360 // ---- G1 read-only metadata (added 2026-05; backward-compatible) ----
361 /// PostScript name of the font, with any 6-character subset prefix stripped
362 /// (e.g. `Helvetica-Bold`, `TimesNewRomanPS-BoldMT`). `None` for Type1
363 /// standard-14 fonts and Type3 fonts where no embedded font data is
364 /// available through the public `pdf-interpret` API.
365 pub font_name: Option<String>,
366 /// Inferred bold style: `weight >= 700` or PostScript name suggests bold
367 /// ("bold", "demi", "semibold", "heavy", "black"). Defaults `false` when
368 /// no descriptor data is reachable.
369 pub is_bold: bool,
370 /// Inferred italic style: FontDescriptor /Italic flag set or PostScript
371 /// name suggests italic/oblique/slant. Defaults `false` when no descriptor
372 /// data is reachable.
373 pub is_italic: bool,
374 /// Fill color as sRGB RGBA, derived from `Paint::Color(c).to_rgba().to_rgba8()`
375 /// at the moment of glyph paint. `None` for `Paint::Pattern` (tiling /
376 /// shading) — the editor falls back to "auto" in that case.
377 pub color: Option<[u8; 4]>,
378
379 // ---- G2 glyph-level metrics (added 2026-05) ----
380 /// Whether glyph widths were measured from real font advance data or estimated.
381 pub width_source: WidthSource,
382 /// Per-glyph bounding boxes in user-space, one entry per source glyph.
383 /// `[x0, y0, x1, y1]` with y0 < y1 (PDF coordinate frame).
384 pub char_bounds: Vec<[f64; 4]>,
385
386 // ---- M3 rich geometry (added 2026-06; backward-compatible) ----
387 #[doc(hidden)]
388 pub geometry_mode: GeometryMode,
389 #[doc(hidden)]
390 pub bounds_source: BoundsSource,
391 #[doc(hidden)]
392 pub tight_char_bounds: Vec<[f64; 4]>,
393 #[doc(hidden)]
394 pub glyph_advances: Vec<f64>,
395 #[doc(hidden)]
396 pub glyph_bounds_sources: Vec<BoundsSource>,
397
398 // ---- Golf 1 typographic metadata (added 2026-06; backward-compatible) ----
399 /// Full affine transform of the span's first glyph in user space,
400 /// `[a, b, c, d, e, f]` (kurbo coeffs of CTM × text-matrix). Captures
401 /// rotation and shear that `(x, y, font_size)` discards. Text placed by a
402 /// rectangle rather than drawn by a glyph (a form field's value with no
403 /// appearance stream, #549) carries the placement in the same form.
404 /// `None` for spans built from neither (e.g. synthetic markers).
405 pub transform: Option<[f64; 6]>,
406 /// Numeric font weight (~100–900) from the embedded font's OS/2 table when
407 /// available. `None` for non-embedded / standard-14 fonts.
408 pub font_weight: Option<u16>,
409 /// Whether the font is serif, when determinable from embedded font data.
410 /// `None` when no embedded descriptor is reachable.
411 pub is_serif: Option<bool>,
412 /// Whether the font is monospace, when determinable from embedded font data.
413 /// `None` when no embedded descriptor is reachable.
414 pub is_monospace: Option<bool>,
415 /// Coarse PDF text render mode: `0` fill, `1` stroke, `3` invisible — the
416 /// only three values the renderer's `GlyphDrawMode` expresses (the
417 /// fill+stroke / clip modes 2 and 4–7 are not distinguished). Reflects the
418 /// span's first glyph, and `0` for text placed by a rectangle rather than
419 /// drawn (#549). `None` for spans built from neither.
420 pub render_mode: Option<u8>,
421 /// Whether the span was set in the engine's own OCR layer resource
422 /// ([`pdf_ocr::pipeline::OCR_FONT_NAME`]). The hidden-text drop keeps
423 /// these spans while author-hidden text goes, because the layer is the
424 /// engine's own voice, not ink the author hid.
425 pub is_ocr_layer: bool,
426
427 // ---- Golf 2 font metrics (added 2026-06; backward-compatible) ----
428 /// Vertical font metrics (ascent/descent, optional cap/x-height) in /1000
429 /// em from the embedded font binary. `None` for non-embedded fonts.
430 pub font_metrics: Option<FontMetrics>,
431}
432
433impl TextSpan {
434 /// Whether this span is text the page hides: drawn with render mode 3
435 /// (invisible) or painted with zero alpha (lane L1).
436 ///
437 /// The render mode comes from the span's first glyph and the alpha from
438 /// its fill paint; a span with no paint information counts as visible.
439 pub fn is_hidden(&self) -> bool {
440 if self.render_mode == Some(3) {
441 return true;
442 }
443 if let Some(color) = self.color {
444 if color[3] == 0 {
445 return true;
446 }
447 }
448 false
449 }
450
451 /// Conservative right edge using whichever is wider: measured or estimated.
452 /// Used by column detection to avoid underestimating span extent.
453 ///
454 /// Measured only for a quarter-turned span (#574): the estimate counts
455 /// characters times a nominal advance, which runs ALONG the baseline, and
456 /// for a baseline that stands up the page that length is not an x extent
457 /// at all. Widening x by it lays a 29-glyph column head flat across the
458 /// next column and closes whatever gutter it lands on. A free-angle span
459 /// keeps the estimate, as before: its baseline still advances in x.
460 fn right(&self) -> f64 {
461 if span_is_quarter_turned(self) {
462 return self.x + self.width;
463 }
464 self.x + self.width.max(self.estimated_width())
465 }
466
467 /// Right edge from measured glyph positions only.
468 fn measured_right(&self) -> f64 {
469 self.x + self.width
470 }
471
472 fn estimated_width(&self) -> f64 {
473 let char_count = self.text.chars().count() as f64;
474 if char_count <= 0.0 {
475 self.font_size * 0.5
476 } else {
477 self.font_size * 0.5 * char_count
478 }
479 }
480}
481
482/// A block of text (grouped by reading order).
483#[derive(Debug, Clone)]
484pub struct TextBlock {
485 /// Spans within this block, sorted by position.
486 pub spans: Vec<TextSpan>,
487}
488
489impl TextBlock {
490 /// Concatenate all spans into a single string, in reading order.
491 ///
492 /// Spans that are close together are joined without a separator;
493 /// a space is inserted when the gap between spans exceeds half
494 /// the average character width.
495 ///
496 /// ANN[r17/TEX5] The spans are in the order the page draws them, left to
497 /// right, and for Arabic or Hebrew that is the reverse of the order they
498 /// are read in (#436, class R-H of #389). The assembled line therefore goes
499 /// through [`crate::bidi::logical_order`] before it is returned: a line
500 /// with no right-to-left character comes back untouched, and one that has
501 /// them comes back with each of its runs the way a reader would type it.
502 /// The spans themselves are left alone -- they carry the geometry, and the
503 /// geometry is visual.
504 pub fn text(&self) -> String {
505 crate::bidi::logical_order(&self.visual_text()).into_owned()
506 }
507
508 /// [`text`](Self::text) with the cell-path word rules where
509 /// `cell_path_words` is true: a word space set at exactly a quarter em
510 /// reads as a space, exactly as without the option otherwise.
511 pub(crate) fn text_with_cell_path_words(&self, cell_path_words: bool) -> String {
512 crate::bidi::logical_order(&self.visual_text_with(cell_path_words)).into_owned()
513 }
514
515 /// The line as the page draws it, left to right. The order every geometric
516 /// decision above this point was made in.
517 fn visual_text(&self) -> String {
518 self.visual_text_with(false)
519 }
520
521 /// [`visual_text`](Self::visual_text) with the cell-path word rules where
522 /// `cell_path_words` is true.
523 fn visual_text_with(&self, cell_path_words: bool) -> String {
524 if self.spans.is_empty() {
525 return String::new();
526 }
527 let spans: &[TextSpan] = &self.spans;
528 // What goes between each pair, decided pair by pair first and then
529 // looked at as a row: letter-spacing is a property of the row.
530 enum Join {
531 Overlap(String),
532 Gap(f64, bool),
533 }
534 let mut joins: Vec<Join> = Vec::with_capacity(spans.len());
535 // ANN[r17/TEX5] Where the line got to, which is not always the span
536 // before this one: a combining mark is drawn on the glyph before it and
537 // moves the pen nowhere, so its right edge sits an advance short and
538 // the letter after it looks a word apart. A Hebrew word with points
539 // came out `שָׁל וֹם` -- one word, two "words" (#436). A span that is
540 // nothing but marks does not say where the pen is, so the last one that
541 // does answers instead.
542 let mut anchor = &spans[0];
543 for pair in spans.windows(2) {
544 let prev = &pair[0];
545 let curr = &pair[1];
546 if !crate::bidi::is_only_marks(&prev.text) {
547 anchor = prev;
548 }
549 // The gap is measured along the baseline the anchor was drawn on:
550 // for upright text that is `curr.x - anchor.measured_right()` to
551 // the last bit, for a rotated run it is the distance the reader
552 // sees between the two (#435).
553 let direction = span_direction(anchor);
554 let (curr_along, _) = baseline_coordinates(curr, direction);
555 let gap = curr_along - baseline_end(anchor, direction);
556 // W1 (#663): the text join beside the shared word-join rule. A gap
557 // under 0.15 em is under 0.25 em, so `pieces_join` implies the rule
558 // below by arithmetic: this asserts without changing a byte, and
559 // shows the rewrite where the shared call goes.
560 debug_assert!(
561 !crate::words_join::pieces_join(
562 gap,
563 (baseline_coordinates(curr, direction).1
564 - baseline_coordinates(anchor, direction).1)
565 .abs(),
566 prev.font_size
567 ) || gap <= prev.font_size * 0.25,
568 "the shared join implies the text join"
569 );
570 if gap <= prev.font_size * 0.12 && second_run_carries_on_from_the_first(prev, curr) {
571 if let Some(trimmed) = trim_overlapping_word_prefix(&prev.text, &curr.text) {
572 joins.push(Join::Overlap(trimmed));
573 continue;
574 }
575 }
576 if space_drawn_over_previous_run(prev, curr, gap) {
577 joins.push(Join::Overlap(curr.text.trim_start_matches(' ').to_string()));
578 continue;
579 }
580 let space = if cell_path_words {
581 // At the boundary, a space: a word space set at exactly a
582 // quarter em is still a word space (`financial officer`).
583 gap >= prev.font_size * 0.25
584 } else {
585 gap > prev.font_size * 0.25
586 };
587 joins.push(Join::Gap(gap, space));
588 }
589 untrack_spaced_spans(spans, &mut joins, |j| match j {
590 Join::Gap(gap, space) => Some((*gap, space)),
591 Join::Overlap(_) => None,
592 });
593 let mut result = spans[0].text.clone();
594 for (curr, join) in spans[1..].iter().zip(joins) {
595 match join {
596 Join::Overlap(trimmed) => result.push_str(&trimmed),
597 Join::Gap(_, space) => {
598 if space {
599 result.push(' ');
600 }
601 result.push_str(&curr.text);
602 }
603 }
604 }
605 result
606 }
607}
608
609/// Letter-spacing drawn as a row of one-letter spans (#1973).
610///
611/// #582 reads tracking inside a span. When the tracking is wide -- a space
612/// glyph after every capital, as in `P O W E L L I N D U S T R I E S` set at
613/// five points -- every letter is a span of its own, the space glyphs never
614/// reach the text, and each gap between the spans is over the word-space
615/// threshold: the line came out one letter per word. On the evidence of the
616/// 24-09 round that is the largest class of text we write that the page does
617/// not hold (0.74 % of our words on DocLayNet, against Docling's 0.07 %).
618///
619/// A run of four one-letter spans or more (a kerned pair such as `AT` drawn as
620/// one does not end it) is read as letter-spacing only when THE PAGE SAYS
621/// WHERE ITS WORDS PART: a space glyph drawn as a span of its own between them
622/// (`P O W E L L` + a space span + `I N D U S T R I E S`), or gaps that fall in
623/// two -- the letters of a word one distance apart and the words clearly
624/// further, the widest gap at least 1.6 times the narrowest. The letters of a
625/// word then lose their spaces and the words keep one. A row whose gaps all agree
626/// says nothing about where its words part, and is left as it is: letters set
627/// apart on purpose and letters tracked apart are the same page, and that is
628/// the choice `a_line_of_single_letter_words_reads_as_tracking_and_that_is_the_choice`
629/// already pins inside a span.
630fn untrack_spaced_spans<J>(
631 spans: &[TextSpan],
632 joins: &mut [J],
633 mut gap_of: impl FnMut(&mut J) -> Option<(f64, &mut bool)>,
634) {
635 let blank = |span: &TextSpan| span.text.trim().is_empty();
636 // Capitals, and the punctuation a line of them carries: letter-spaced
637 // display type is set in capitals, and a row of lower-case letters,
638 // digits or symbols one apart is a formula or a listing (`NK(S) ∩ T`,
639 // `# 0 1 2 3`), which this must not touch.
640 let small = |span: &TextSpan| {
641 let t = span.text.trim();
642 let n = t.chars().count();
643 (n == 1 || n == 2)
644 && t.chars()
645 .all(|c| c.is_uppercase() || matches!(c, ',' | '.' | '\'' | '’' | '&' | '-'))
646 && (n == 1 || t.chars().any(char::is_uppercase))
647 };
648 let mut start = 0;
649 while start < spans.len() {
650 if !small(&spans[start]) {
651 start += 1;
652 continue;
653 }
654 let mut end = start + 1;
655 while end < spans.len()
656 && (small(&spans[end]) || blank(&spans[end]))
657 && gap_of(&mut joins[end - 1]).is_some()
658 {
659 end += 1;
660 }
661 // A space drawn at the end of the run is not inside it, and neither
662 // is a last "word" of one letter after it: `F C F C` + a space + `S a
663 // m p l e` is a figure's label beside a word, not two tracked words.
664 loop {
665 while end > start + 1 && blank(&spans[end - 1]) {
666 end -= 1;
667 }
668 let tail = spans[start..end].iter().rposition(blank);
669 match tail {
670 Some(at)
671 if spans[start + at + 1..end]
672 .iter()
673 .filter(|s| s.text.trim().chars().any(char::is_uppercase))
674 .count()
675 < 2 =>
676 {
677 end = start + at;
678 }
679 _ => break,
680 }
681 }
682 let singles = spans[start..end]
683 .iter()
684 .filter(|s| {
685 let t = s.text.trim();
686 t.chars().count() == 1 && t.chars().all(char::is_uppercase)
687 })
688 .count();
689 // THE PAGE DREW ITS WORD SPACES: a space glyph set as a span of its
690 // own where the words part, which is the boundary. The gaps between the
691 // letters are tracking and lose their space; a gap much wider than the
692 // run's tracking keeps it -- the end of a line, or a word the page
693 // parted without a drawn space. Without a drawn space the row is left
694 // as it is: gaps that fall in two were tried as the boundary and read
695 // figure labels and OCR noise (`B C B C`, `I I I`) as words.
696 if singles >= 4 && spans[start..end].iter().any(blank) {
697 let mut tracking: Vec<f64> = (start..end - 1)
698 .filter(|&k| !blank(&spans[k]) && !blank(&spans[k + 1]))
699 .filter_map(|k| gap_of(&mut joins[k]).map(|(g, _)| g))
700 .collect();
701 tracking.sort_by(|a, b| a.total_cmp(b));
702 let median = tracking.get(tracking.len() / 2).copied().unwrap_or(0.0);
703 for k in start..end - 1 {
704 let beside_a_drawn_space = blank(&spans[k]) || blank(&spans[k + 1]);
705 if let Some((gap, space)) = gap_of(&mut joins[k]) {
706 if beside_a_drawn_space || gap <= 1.5 * median.max(0.0) {
707 *space = false;
708 }
709 }
710 }
711 }
712 // Never stand still: a run that shrank to nothing moves on by one.
713 start = end.max(start + 1);
714 }
715}
716
717#[derive(Debug, Clone)]
718struct TextBand {
719 y: f64,
720 spans: Vec<TextSpan>,
721}
722
723impl TextBand {
724 fn new(span: TextSpan) -> Self {
725 Self {
726 y: span.y,
727 spans: vec![span],
728 }
729 }
730
731 fn sort_spans(&mut self) {
732 self.spans.sort_by(|a, b| {
733 a.x.partial_cmp(&b.x)
734 .unwrap_or(Ordering::Equal)
735 .then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
736 });
737 split_spans_covering_siblings(&mut self.spans);
738 self.spans.sort_by(|a, b| {
739 a.x.partial_cmp(&b.x)
740 .unwrap_or(Ordering::Equal)
741 .then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
742 });
743 collapse_overprinted_spans(&mut self.spans);
744 }
745
746 fn row_block(&self) -> TextBlock {
747 let mut spans = self.spans.clone();
748 spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
749 TextBlock { spans }
750 }
751
752 fn left(&self) -> f64 {
753 self.spans
754 .iter()
755 .map(|span| span.x)
756 .fold(f64::INFINITY, f64::min)
757 }
758
759 fn right(&self) -> f64 {
760 self.spans
761 .iter()
762 .map(TextSpan::right)
763 .fold(f64::NEG_INFINITY, f64::max)
764 }
765
766 fn width(&self) -> f64 {
767 (self.right() - self.left()).max(0.0)
768 }
769
770 fn gap_midpoints(&self, column_gap_threshold: f64) -> Vec<f64> {
771 self.gaps(column_gap_threshold)
772 .into_iter()
773 .map(|gap| (gap.start + gap.end) * 0.5)
774 .collect()
775 }
776
777 fn gaps(&self, column_gap_threshold: f64) -> Vec<BandGap> {
778 if self.spans.len() < 2 {
779 return Vec::new();
780 }
781
782 let mut spans = self.spans.clone();
783 spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
784
785 let mut gaps = Vec::new();
786 let mut prev_right = spans[0].right();
787 for span in spans.iter().skip(1) {
788 let gap = span.x - prev_right;
789 if gap >= column_gap_threshold {
790 gaps.push(BandGap {
791 start: prev_right,
792 end: span.x,
793 });
794 }
795 prev_right = prev_right.max(span.right());
796 }
797
798 gaps
799 }
800
801 fn split_by_boundaries(&self, boundaries: &[f64]) -> Vec<Vec<TextSpan>> {
802 let mut columns = vec![Vec::new(); boundaries.len() + 1];
803 for span in &self.spans {
804 // Through `right()` rather than the estimate inline, so a
805 // quarter-turned span is centred on the x it covers and not on the
806 // length of the run stood up on it (#574). The same value for
807 // upright text.
808 let center_x = (span.x + span.right()) * 0.5;
809 let column_idx = boundaries
810 .iter()
811 .position(|boundary| center_x < *boundary)
812 .unwrap_or(boundaries.len());
813 columns[column_idx].push(span.clone());
814 }
815
816 for spans in &mut columns {
817 spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
818 }
819
820 columns
821 }
822
823 fn fits_single_column(
824 &self,
825 boundaries: &[f64],
826 region_left: f64,
827 region_right: f64,
828 ) -> Option<usize> {
829 let mut column_idx: Option<usize> = None;
830 for span in &self.spans {
831 let left = span.x;
832 let right = span.right();
833 if boundaries
834 .iter()
835 .any(|boundary| left < *boundary && right > *boundary)
836 {
837 return None;
838 }
839
840 let center_x = left + (right - left) * 0.5;
841 let idx = boundaries
842 .iter()
843 .position(|boundary| center_x < *boundary)
844 .unwrap_or(boundaries.len());
845 match column_idx {
846 Some(existing) if existing != idx => return None,
847 Some(_) => {}
848 None => column_idx = Some(idx),
849 }
850 }
851 let idx = column_idx?;
852 let mut edges = Vec::with_capacity(boundaries.len() + 2);
853 edges.push(region_left);
854 edges.extend_from_slice(boundaries);
855 edges.push(region_right);
856
857 let column_width = (edges[idx + 1] - edges[idx]).max(0.0);
858 if column_width <= 0.0 || self.width() > column_width * 0.8 {
859 return None;
860 }
861
862 Some(idx)
863 }
864}
865
866#[derive(Debug, Clone, Copy)]
867struct BandGap {
868 start: f64,
869 end: f64,
870}
871
872/// The points a path segment is built from, in the order the segment draws
873/// them. Control points included: a cubic lies inside the hull of its four
874/// points, so a bound taken over all of them is never too small.
875fn element_points(element: &PathEl) -> impl Iterator<Item = Point> {
876 let mut points = [Point::ZERO; 3];
877 let count = match *element {
878 PathEl::MoveTo(p) | PathEl::LineTo(p) => {
879 points[0] = p;
880 1
881 }
882 PathEl::QuadTo(a, b) => {
883 points[0] = a;
884 points[1] = b;
885 2
886 }
887 PathEl::CurveTo(a, b, c) => {
888 points[0] = a;
889 points[1] = b;
890 points[2] = c;
891 3
892 }
893 PathEl::ClosePath => 0,
894 };
895 points.into_iter().take(count)
896}
897
898/// A horizontal rule the page draws, in the page space the spans are measured
899/// in (#545).
900///
901/// This is the one thing on the page that says "table" without guessing at it
902/// from the text's own geometry. A width heuristic cannot tell a table from a
903/// paper set in two columns -- both are slices of a band with a gutter between
904/// them -- and the discriminator tried on slice width fixed the Isartor table
905/// by breaking `tracemonkey`. The ruling lines are drawn by the document
906/// itself, and a page of prose does not draw one between every pair of lines.
907#[derive(Clone, Copy, Debug)]
908#[doc(hidden)]
909pub struct HorizontalRule {
910 pub left: f64,
911 pub right: f64,
912 pub y: f64,
913}
914
915/// One straight, axis-aligned stroke of a drawn grid, in page space (#574):
916/// a thin line in either direction, or one edge of a drawn rectangle.
917///
918/// KEPT APART FROM [`HorizontalRule`] on purpose. Every heuristic that reads
919/// the horizontal rules was tuned on what that list holds; a table's cell
920/// borders and shading boxes would flood it with short segments none of them
921/// was measured against. This list is read by the lattice alone.
922#[derive(Clone, Copy, Debug, PartialEq)]
923#[doc(hidden)]
924pub struct Ruling {
925 /// Left or only x.
926 pub x0: f64,
927 /// Bottom or only y.
928 pub y0: f64,
929 /// Right x; equal to `x0` for a vertical ruling.
930 pub x1: f64,
931 /// Top y; equal to `y0` for a horizontal ruling.
932 pub y1: f64,
933}
934
935/// A Device implementation that captures text from draw_glyph calls.
936///
937/// ANN[r17/TEX1][r17/TEX3] Space detection uses a multi-signal consensus
938/// rather than a single geometric threshold. Three signals vote:
939/// 1. `pending_tj_offset` — raw TJ backward shift surfaced by the
940/// interpreter (confidence 0.95). This is the definitive word-break
941/// signal used by pdftotext / MuPDF.
942/// 2. geometric gap — measured horizontal distance between the
943/// previous glyph's right edge and this glyph's origin (confidence
944/// 0.80). Compared against the running median glyph width rather
945/// than a flat em-fraction so condensed/wide fonts are handled
946/// uniformly.
947/// 3. character heuristic — CamelCase transition or digit↔letter
948/// transition at the merge point (confidence 0.60). Catches cases
949/// where the writer relied on typography (e.g. table cells glued
950/// with zero gap: `Qty1Price$5`).
951///
952/// A space is inserted when the weighted sum meets SPACE_CONSENSUS_THRESHOLD.
953/// Span accumulation still merges adjacent glyphs into one TextSpan (TEX3)
954/// so downstream reading-order logic sees logical text runs, not individual
955/// character positions.
956///
957/// One entry on the marked-content stack for `/ActualText`. Sequences nest,
958/// so each `BDC`/`BMC` pushes and `EMC` pops; the innermost entry carrying
959/// a replacement wins for a glyph drawn inside it. The replacement is
960/// emitted once, on the first glyph of its sequence, and later glyphs of
961/// the same sequence are suppressed. A sequence that closes without a glyph
962/// having carried it reads once at its close instead, at the marks it drew.
963struct ActualTextEntry {
964 actual_text: Option<String>,
965 consumed: bool,
966 /// A glyph that reads as drawn text was painted while this sequence was
967 /// open, so the sequence already stands in the text and its close must
968 /// not read the replacement as well.
969 saw_glyph: bool,
970 /// A sequence nested inside this one already answered for these marks --
971 /// its replacement read on a glyph or at its close -- so this close
972 /// stays quiet, and a replacement inside a replacement reads once.
973 child_resolved: bool,
974 /// The marks painted while this sequence was open, in page space: glyph
975 /// boxes, path boxes and image boxes united. Where the replacement reads
976 /// when no glyph carried it.
977 extent: Option<[f64; 4]>,
978}
979
980#[doc(hidden)]
981pub struct TextExtractionDevice {
982 spans: Vec<TextSpan>,
983 last_y: f64,
984 last_end_x: f64,
985 /// The gaps between the last few glyph pairs of the span being built, so
986 /// that a run the typesetter TRACKED can be told from a run with word
987 /// spaces in it (#582). See `a_tracked_run`.
988 recent_gaps: Vec<f64>,
989 /// The last glyph drawn with a fill: where, and what it reads. Text in
990 /// render mode 2 or 6 reaches the device twice per glyph, filled and then
991 /// stroked, and the stroke is the same character on the same spot.
992 last_fill: Option<([f64; 6], Option<BfString>)>,
993 /// How far the last glyph's box was widened past its declared advance by
994 /// the quarter-em floor of `glyph_width_and_source`; the gap after it is
995 /// that much short of the one the page drew.
996 last_widened: f64,
997 /// The face the last span is MERGED on: the program's own name, which is
998 /// what `font_name` was before it learned /BaseFont (#574). A span that
999 /// carries the page's name for a face the program does not name keeps
1000 /// merging as it did -- a formula's CMMI into its CMR -- because a merge
1001 /// across a face change is where a word space is judged, and splitting
1002 /// there lost `where µ measures` on the arXiv pages.
1003 last_merge_face: Option<String>,
1004 /// Where in the text of the span being built the space consensus put a
1005 /// space, and the gap it judged: a drawn space is not in here.
1006 inserted_spaces: Vec<(usize, f64)>,
1007 /// Where in that text the current run of regular gaps began.
1008 run_start: usize,
1009 /// The last gap between two letters of the span being built.
1010 last_letter_gap: Option<f64>,
1011 /// TJ adjustment in raw 1/1000 em units since the last glyph was
1012 /// drawn. Positive values = backward shift (i.e., explicit horizontal
1013 /// space). Reset every time a glyph is drawn.
1014 pending_tj_offset: f32,
1015 /// Running sample of measured glyph widths used as the adaptive
1016 /// reference for the geometric gap signal. Cheap to maintain and
1017 /// avoids having to re-walk all spans per decision.
1018 glyph_widths: Vec<f64>,
1019 /// Cached median glyph width (kept fresh every `MEDIAN_REFRESH`
1020 /// insertions). Zero = not yet established, caller falls back to
1021 /// font-size scaling.
1022 cached_median_char_width: f64,
1023 /// Geometry extraction mode — Basic (advance bounds) or RichGeometry (tight bounds).
1024 geometry_mode: GeometryMode,
1025 /// Per-glyph data recorded during extraction for deferred tight-bounds
1026 /// computation. Only populated in RichGeometry mode. The index into this
1027 /// vec corresponds 1:1 with the glyph order across all spans.
1028 deferred_rich_glyphs: Vec<DeferredGlyph>,
1029 /// Hand back the ligature code points a font declared instead of the
1030 /// letters they stand for. Off, because a search box compares characters
1031 /// and U+FB03 is not `ffi` (#437); see [`crate::ligatures`].
1032 keep_ligatures: bool,
1033 /// Report the document's own font names and the descriptor's metrics on
1034 /// spans. Off, because the default output is what it was before the E34
1035 /// truth existed: substitute-face names and AFM metrics (#643).
1036 document_font_names: bool,
1037 /// The horizontal rules the page draws, in page space (#545).
1038 rules: Vec<HorizontalRule>,
1039 /// Every thin line and rectangle edge the page draws, for the lattice
1040 /// (#574).
1041 rulings: Vec<Ruling>,
1042 /// Every solid, non-white, axis-aligned rectangle the page fills, in page
1043 /// space and clipped: the shading behind a heading, a highlighted row, a
1044 /// box a note is set in (#574, the line network's vector inputs).
1045 fills: Vec<[f64; 4]>,
1046 /// Where the page places its images, in page space: the unit square each
1047 /// image XObject is drawn into, under its transform and clipped to what is
1048 /// visible (#636). A caption sits beside a picture, and the picture is
1049 /// nowhere else in what this device reads.
1050 images: Vec<[f64; 4]>,
1051 /// The box of every path the page fills or strokes that is not a thin
1052 /// line, in page space and clipped (#636): what a chart or a drawn figure
1053 /// is made of. Clustered into figures by [`Self::take_graphics`].
1054 path_boxes: Vec<[f64; 4]>,
1055 /// The clip boxes in force, innermost last, in page space (#545). Each
1056 /// entry is already intersected with the one below it, so the last is the
1057 /// box a path drawn now can reach.
1058 clip_stack: Vec<kurbo::Rect>,
1059 /// Whether glyphs merge into the runs the page draws, for the raw-order
1060 /// path (`ExtractOptions::raw_order`, #643). Off: every glyph stands on
1061 /// its own span, exactly as without the option. On: the gap is judged on
1062 /// the pen the last glyph left, so runs merge as drawn, and a run ends
1063 /// where the marked-content segment does.
1064 raw_runs: bool,
1065 /// The marked-content segment each span was drawn in, in span order; only
1066 /// filled when [`Self::raw_runs`] is on. `None` outside any segment (an
1067 /// untagged page never opens one).
1068 raw_bdc: Vec<Option<u32>>,
1069 /// The open marked-content segments, innermost last; only tracked when
1070 /// [`Self::raw_runs`] is on.
1071 raw_bdc_stack: Vec<u32>,
1072 /// The next marked-content segment number; only used when
1073 /// [`Self::raw_runs`] is on.
1074 raw_bdc_next: u32,
1075 /// Whether a segment opened or closed since the last glyph was drawn;
1076 /// only set when [`Self::raw_runs`] is on. The next glyph starts a fresh
1077 /// span, so a run never crosses the segment it was drawn in.
1078 raw_bdc_changed: bool,
1079 /// The page's declared name of the face the run being built is set in,
1080 /// subset prefix stripped; only tracked when [`Self::raw_runs`] is on.
1081 /// The program substitutes one face for several of the page's --
1082 /// `HelveticaNeueLTStd-BdOu` and `-Blk` both reach the spans as
1083 /// `Helvetica` -- so the merge face cannot tell `20` from `25`, and the
1084 /// page's own name is what parts them.
1085 last_raw_declared: Option<String>,
1086 /// Where the last glyph drawn ended, and the unit advance direction it
1087 /// read in; only tracked when [`Self::raw_runs`] is on. A turned glyph
1088 /// joins its run along the way it reads, so the pen is a point here,
1089 /// not the x the default path keeps.
1090 raw_pen: [f64; 2],
1091 raw_dir: [f64; 2],
1092 /// Whether `raw_pen` holds a glyph; only tracked when [`Self::raw_runs`]
1093 /// is on.
1094 raw_pen_valid: bool,
1095 /// The open marked-content sequences, innermost last. Tracked only while
1096 /// [`Self::actual_text`] applies replacements; ignored otherwise, so the
1097 /// default output is what it was before the replacements were read. The
1098 /// stack survives `Do` into a Form XObject because the same device reads
1099 /// the nested stream.
1100 actual_text_stack: Vec<ActualTextEntry>,
1101 /// Whether `/ActualText` replacements apply. Off, because the default
1102 /// output is what it was before the replacements were read. Set this
1103 /// when the caller asked for the replacements through
1104 /// `ExtractOptions::actual_text` (#663).
1105 actual_text: bool,
1106 /// The annotation appearances being drawn, innermost last, in page
1107 /// space: the rectangle each annotation shows its appearance in. A
1108 /// glyphless replacement inside one is the value the widget shows --
1109 /// the only place that value is painted -- so it reads even where
1110 /// replacements on the page stay off.
1111 annotation_appearances: Vec<kurbo::Rect>,
1112 /// Where the last glyph's own text went, so that it can be read again
1113 /// when the next glyph arrives. See [`LastGlyphText`].
1114 last_glyph_text: Option<LastGlyphText>,
1115 /// This device's number, which names its entry in [`LAST_GLYPH`].
1116 id: u64,
1117 /// Whether [`LAST_GLYPH`] names this device, so that it is written once
1118 /// rather than at every glyph (spec, "Performance").
1119 holds_last_glyph: bool,
1120 /// Clears this device's entry in [`LAST_GLYPH`] when the device goes,
1121 /// unwinding included.
1122 last_glyph_guard: ClearLastGlyph,
1123}
1124
1125/// The number the next [`TextExtractionDevice`] gets.
1126static NEXT_DEVICE: AtomicU64 = AtomicU64::new(0);
1127
1128/// Empties [`LAST_GLYPH`] when dropped if the entry is the device's whose
1129/// number it holds, so no glyph -- and no interpreter font behind it --
1130/// outlives the device that drew it, even when extraction panics (spec
1131/// review 14-10 02:00 (i)).
1132struct ClearLastGlyph(u64);
1133
1134impl ClearLastGlyph {
1135 /// Empties [`LAST_GLYPH`] if it names this device.
1136 fn clear(&self) {
1137 let _ = LAST_GLYPH.try_with(|slot| {
1138 if slot.get() == Some(self.0) {
1139 slot.set(None);
1140 }
1141 });
1142 }
1143}
1144
1145impl Drop for ClearLastGlyph {
1146 fn drop(&mut self) {
1147 self.clear();
1148 }
1149}
1150
1151/// Whether [`LAST_GLYPH`] holds a glyph on this thread.
1152#[cfg(test)]
1153pub(crate) fn last_glyph_is_held() -> bool {
1154 LAST_GLYPH.with(|slot| slot.get().is_some())
1155}
1156
1157thread_local! {
1158 /// The number of the device on this thread that holds a last glyph.
1159 ///
1160 /// The glyph itself is not kept: the device keeps its place on the page
1161 /// (a plain value, so the device stays `Send`), and the next glyph says
1162 /// what the glyph at that place reads as now when drawing it changed
1163 /// that (`OutlineGlyph::reread_previous`).
1164 static LAST_GLYPH: Cell<Option<u64>> = const { Cell::new(None) };
1165}
1166
1167/// Where the last glyph's text went, so that it can be read again.
1168///
1169/// The interpreter offers a combining mark from the AMS name rows (spec
1170/// `specs/text/circlecopyrt.md`, C4 and C5a) to the glyph on the other side
1171/// of it once it has seen both, and the glyph already handed over may then
1172/// read differently: a `c` drawn before its circle reads `c⃝`, and a slash
1173/// drawn before the `=` it strikes reads as nothing, the `=` then reading
1174/// `≠`. The device reads the last glyph again at the next one, but only when
1175/// its text still stands last in the last span, so that what it reads now
1176/// can go in place of what it read then; reading it is what takes the offer,
1177/// and a glyph that cannot be read again leaves the offer to be refused.
1178/// Only the text changes: the glyph's box stays, and the pen stays where the
1179/// first reading left it, except that a glyph that now reads as nothing
1180/// gives its box back.
1181struct LastGlyphText {
1182 /// Where the glyph stands on its page, set when it is remembered.
1183 place: Option<GlyphPlace>,
1184 /// The span the text went into, and where in its text it starts.
1185 span: usize,
1186 offset: usize,
1187 /// How long the text was, in bytes.
1188 len: usize,
1189 /// Whether the glyph read as nothing and so wrote no text and no box.
1190 boxless: bool,
1191 /// Whether the glyph opened the span.
1192 opened: bool,
1193 /// What the span held before this glyph: glyph boxes, width, and (for a
1194 /// glyph that opened it) the previous span's run end under raw runs.
1195 char_bounds: usize,
1196 width: f64,
1197 previous_complete_run: Option<bool>,
1198 /// The rich-geometry records before this glyph's.
1199 deferred: usize,
1200 /// How many spans there were after this glyph, and the last one's text
1201 /// length and box count: the glyph is still last while these hold.
1202 span_count: usize,
1203 tail_text: usize,
1204 tail_boxes: usize,
1205}
1206
1207/// Lightweight glyph data recorded during extraction for deferred tight-bounds
1208/// computation. Avoids computing bounds on the hot path.
1209#[derive(Clone)]
1210struct DeferredGlyph {
1211 /// Transform coefficients [a,b,c,d,e,f] from composed (CTM * glyph_transform)
1212 coeffs: [f64; 6],
1213 /// Font size in points
1214 font_size: f64,
1215 /// Advance width in page-space points
1216 glyph_width: f64,
1217 /// Whether this glyph needs transform-then-bound (rotation/shear)
1218 needs_exact: bool,
1219 /// The glyph outline path in raw glyph space. Retained ONLY for
1220 /// rotated/sheared glyphs (`needs_exact`), where a tight bound requires
1221 /// bounding the transformed path; `None` for axis-aligned glyphs (the
1222 /// `font_bbox` suffices) and for ink-less glyphs (spaces, Type3).
1223 outline: Option<BezPath>,
1224 /// Pre-computed bounding box of the outline in raw glyph space. `None` when
1225 /// the glyph has no ink (space, Type3, or empty outline) → Estimate.
1226 font_bbox: Option<kurbo::Rect>,
1227}
1228
1229const MEDIAN_REFRESH: usize = 32;
1230
1231/// How far apart two glyphs' type sizes may stand and still merge into one
1232/// raw run, as a fraction of the larger (#643). Within a run the size is one
1233/// Tm and never varies; across runs the page changes it wholesale (`MM` at
1234/// 7pt against its 4.95pt space is twenty-nine per cent).
1235const RAW_SIZE_TOLERANCE: f64 = 0.01;
1236
1237impl Default for TextExtractionDevice {
1238 fn default() -> Self {
1239 Self::new()
1240 }
1241}
1242
1243impl TextExtractionDevice {
1244 /// Create a new text extraction device with Basic geometry (default).
1245 pub fn new() -> Self {
1246 Self::with_mode(GeometryMode::Basic)
1247 }
1248
1249 /// Create a new text extraction device with the given geometry mode.
1250 pub fn with_mode(geometry_mode: GeometryMode) -> Self {
1251 let id = NEXT_DEVICE.fetch_add(1, AtomicOrdering::Relaxed);
1252 Self {
1253 spans: Vec::new(),
1254 last_y: f64::NEG_INFINITY,
1255 last_end_x: f64::NEG_INFINITY,
1256 recent_gaps: Vec::new(),
1257 last_fill: None,
1258 last_widened: 0.0,
1259 last_merge_face: None,
1260 inserted_spaces: Vec::new(),
1261 run_start: 0,
1262 last_letter_gap: None,
1263 pending_tj_offset: 0.0,
1264 glyph_widths: Vec::new(),
1265 cached_median_char_width: 0.0,
1266 geometry_mode,
1267 deferred_rich_glyphs: Vec::new(),
1268 keep_ligatures: false,
1269 document_font_names: false,
1270 rules: Vec::new(),
1271 rulings: Vec::new(),
1272 fills: Vec::new(),
1273 images: Vec::new(),
1274 path_boxes: Vec::new(),
1275 clip_stack: Vec::new(),
1276 raw_runs: false,
1277 raw_bdc: Vec::new(),
1278 raw_bdc_stack: Vec::new(),
1279 raw_bdc_next: 0,
1280 raw_bdc_changed: false,
1281 last_raw_declared: None,
1282 raw_pen: [0.0, 0.0],
1283 raw_dir: [1.0, 0.0],
1284 raw_pen_valid: false,
1285 actual_text_stack: Vec::new(),
1286 actual_text: false,
1287 annotation_appearances: Vec::new(),
1288 last_glyph_text: None,
1289 id,
1290 holds_last_glyph: false,
1291 last_glyph_guard: ClearLastGlyph(id),
1292 }
1293 }
1294
1295 fn push_placed_span(&mut self, text: &str, rect: kurbo::Rect) {
1296 if text.is_empty() {
1297 return;
1298 }
1299
1300 let height = if rect.height().is_finite() && rect.height() > 0.0 {
1301 rect.height()
1302 } else {
1303 0.0
1304 };
1305 let width = if rect.width().is_finite() && rect.width() > 0.0 {
1306 rect.width()
1307 } else {
1308 0.0
1309 };
1310
1311 // A NEW SPAN IS A NEW RUN, so what the last one was tracked at says
1312 // nothing about this one (#582).
1313 self.recent_gaps.clear();
1314 self.inserted_spaces.clear();
1315 self.run_start = 0;
1316 self.last_letter_gap = None;
1317 self.last_merge_face = None;
1318 self.spans.push(TextSpan {
1319 text: text.to_string(),
1320 x: rect.x0,
1321 y: rect.y0,
1322 width,
1323 height,
1324 font_size: height,
1325 // Placed text is one synthetic span, never a content run (#643).
1326 complete_run: false,
1327 font_name: None,
1328 is_bold: false,
1329 is_italic: false,
1330 color: None,
1331 width_source: WidthSource::Estimate,
1332 char_bounds: Vec::new(),
1333 // The placement is a real statement about where these characters
1334 // are, in the same form a glyph's is -- scale on the diagonal,
1335 // position in the last pair -- so a caller that lays the text back
1336 // out has what it needs, and the coverage gate over these two
1337 // fields stays a gate rather than acquiring an exception.
1338 transform: Some([height / 1000.0, 0.0, 0.0, height / 1000.0, rect.x0, rect.y0]),
1339 font_weight: None,
1340 is_serif: None,
1341 is_monospace: None,
1342 // Filled: a field value is painted, not stroked and not hidden.
1343 render_mode: Some(0),
1344 // Placed, not recognized: no OCR layer wrote this.
1345 is_ocr_layer: false,
1346 font_metrics: None,
1347 geometry_mode: self.geometry_mode,
1348 bounds_source: BoundsSource::Estimate,
1349 tight_char_bounds: Vec::new(),
1350 glyph_advances: Vec::new(),
1351 glyph_bounds_sources: Vec::new(),
1352 });
1353
1354 // The pen follows, so that anything drawn after this reads its gap
1355 // against where the placed text ended rather than against a glyph
1356 // three widgets back.
1357 self.last_y = rect.y0;
1358 self.last_end_x = rect.x1;
1359 self.last_widened = 0.0;
1360 self.pending_tj_offset = 0.0;
1361 }
1362
1363 /// Hand back the ligature code points the font declared -- `ffi` rather than
1364 /// `ffi` -- from the TEXT outputs ([`Self::into_text`], [`Self::into_blocks`]).
1365 ///
1366 /// The default folds, because the extracted string is read by a search
1367 /// index, a RAG chunker or a person pasting a word into a search box, and
1368 /// none of them will match a ligature code point against the letters
1369 /// somebody types. Set this when the caller wants the file's own
1370 /// characters back: a typesetting tool, or a comparison against the
1371 /// content stream. The paragraph blocks ([`Self::into_paragraph_blocks`])
1372 /// are unfolded either way: they feed the decisions, and the fold happens
1373 /// after them (see [`crate::ligatures`]).
1374 #[must_use]
1375 pub fn keep_ligatures(mut self, keep: bool) -> Self {
1376 self.keep_ligatures = keep;
1377 self
1378 }
1379
1380 /// Merge glyphs into the runs the page draws, for the raw-order path
1381 /// (`ExtractOptions::raw_order`, #643).
1382 pub fn raw_runs(mut self, raw: bool) -> Self {
1383 self.raw_runs = raw;
1384 self
1385 }
1386
1387 /// Report the document's own font names and the descriptor's metrics on
1388 /// spans: the page's /BaseFont and /Ascent//Descent instead of the
1389 /// substitute face's. Off, because the default output is what it was
1390 /// before the E34 truth existed. Set this when the caller asked for the
1391 /// document truth through `ExtractOptions::document_font_names` (#643).
1392 #[must_use]
1393 pub fn document_font_names(mut self, report: bool) -> Self {
1394 self.document_font_names = report;
1395 self
1396 }
1397
1398 /// Apply `/ActualText` replacements to the spans. Off, because the
1399 /// default output is what it was before the replacements were read. Set
1400 /// this when the caller asked for the replacements through
1401 /// `ExtractOptions::actual_text` (#663).
1402 #[must_use]
1403 pub fn actual_text(mut self, apply: bool) -> Self {
1404 self.actual_text = apply;
1405 self
1406 }
1407
1408 /// Whether the device is inside an annotation appearance stream: a
1409 /// replacement there is the annotation's own statement about what it
1410 /// shows, so the marked-content stack is tracked even where replacements
1411 /// on the page stay off.
1412 fn in_annotation_appearance(&self) -> bool {
1413 !self.annotation_appearances.is_empty()
1414 }
1415
1416 /// Fold a painted box, in page space, into every open marked-content
1417 /// sequence: the marks a glyphless replacement reads at when it closes.
1418 fn note_drawn_box(&mut self, drawn: [f64; 4]) {
1419 for open in self.actual_text_stack.iter_mut() {
1420 open.extent = Some(match open.extent {
1421 Some(before) => union(&before, &drawn),
1422 None => drawn,
1423 });
1424 }
1425 }
1426
1427 /// Where a glyphless replacement reads: the marks its sequence drew, or
1428 /// the annotation showing the sequence when it drew nothing placeable.
1429 fn glyphless_placement(&self, extent: Option<[f64; 4]>) -> Option<kurbo::Rect> {
1430 if let Some([x0, y0, x1, y1]) = extent {
1431 if x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() {
1432 return Some(kurbo::Rect::new(x0, y0, x1, y1));
1433 }
1434 }
1435 self.annotation_appearances.last().copied()
1436 }
1437
1438 /// Read the replacement of a marked-content sequence that closes without
1439 /// a glyph having carried it: once, at the marks it drew. A sequence
1440 /// that already answered -- on a glyph, or through a nested sequence
1441 /// that did -- stays quiet, so a replacement inside a replacement reads
1442 /// once and the innermost answers.
1443 fn emit_glyphless_replacement(&mut self, entry: ActualTextEntry) {
1444 let answered = entry.consumed || entry.child_resolved;
1445 if answered || entry.saw_glyph || entry.actual_text.is_none() {
1446 // The sequence already stands in the text -- its replacement
1447 // read on a glyph, a nested sequence answered for it, or its
1448 // glyphs read as drawn -- or it carries no replacement. A nested
1449 // answer still quiets the sequence around it.
1450 if answered {
1451 if let Some(parent) = self.actual_text_stack.last_mut() {
1452 parent.child_resolved = true;
1453 }
1454 }
1455 return;
1456 }
1457 let replacement = entry.actual_text.as_deref().unwrap_or("");
1458 if let Some(at) = self.glyphless_placement(entry.extent) {
1459 if !replacement.is_empty() {
1460 self.push_placed_span(replacement, at);
1461 }
1462 }
1463 if let Some(parent) = self.actual_text_stack.last_mut() {
1464 parent.child_resolved = true;
1465 }
1466 }
1467
1468 /// Drop the spans whose centre falls outside `rect` (`[x0, y0, x1, y1]`
1469 /// in user space, y up), before segmentation turns the rest into blocks.
1470 /// Called with the rectangle `ExtractOptions::clip_margins` leaves on the
1471 /// page: content the margins cover is never read.
1472 pub(crate) fn clip_spans(&mut self, rect: [f64; 4]) {
1473 self.spans.retain(|span| {
1474 let centre = (span.x + span.width * 0.5, span.y + span.height * 0.5);
1475 centre.0 >= rect[0] && centre.0 <= rect[2] && centre.1 >= rect[1] && centre.1 <= rect[3]
1476 });
1477 }
1478
1479 /// Refresh the cached median char width. Called lazily from
1480 /// `draw_glyph` to keep the hot path cheap.
1481 fn refresh_median_char_width(&mut self) {
1482 if self.glyph_widths.is_empty() {
1483 self.cached_median_char_width = 0.0;
1484 return;
1485 }
1486 let mut sorted = self.glyph_widths.clone();
1487 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1488 self.cached_median_char_width = sorted[sorted.len() / 2];
1489 }
1490
1491 /// Move the pen to the end of the glyph just drawn.
1492 ///
1493 /// ANN[r17/TEX5] A combining mark -- a Hebrew point, an Arabic harakat --
1494 /// is drawn on the glyph before it and leaves the pen where it was. Letting
1495 /// it move the pen back to its own origin makes the next letter look an
1496 /// advance away, and that gap then reads as a word break: `שָׁלוֹם` came
1497 /// apart into `שָׁל` and `וֹם` (#436). The declared advance cannot answer
1498 /// this, because `glyph_width_and_source` clamps a zero one up to a quarter
1499 /// of the font size before anything sees it.
1500 fn advance_pen(&mut self, text: &str, glyph_end_x: f64, widened: f64) {
1501 if !crate::bidi::is_only_marks(text) {
1502 self.last_end_x = glyph_end_x;
1503 self.last_widened = widened;
1504 }
1505 }
1506
1507 /// Move the raw pen past a glyph, for the turned-run judgment (#643).
1508 /// A no-op unless [`Self::raw_runs`] is on; marks never move it, exactly
1509 /// like [`Self::advance_pen`].
1510 fn advance_raw_pen(
1511 &mut self,
1512 text: &str,
1513 x: f64,
1514 y: f64,
1515 adv_x: f64,
1516 adv_y: f64,
1517 glyph_width: f64,
1518 ) {
1519 if !self.raw_runs || crate::bidi::is_only_marks(text) {
1520 return;
1521 }
1522 self.raw_pen = [x + adv_x * glyph_width, y + adv_y * glyph_width];
1523 self.raw_dir = [adv_x, adv_y];
1524 self.raw_pen_valid = true;
1525 }
1526
1527 /// Decide whether a space should be glued between two glyphs within
1528 /// the same span. Returns (insert_space, start_new_span).
1529 fn evaluate_space_consensus(
1530 &self,
1531 gap: f64,
1532 font_size: f64,
1533 prev_text: &str,
1534 next_text: &str,
1535 ) -> bool {
1536 let mut confidence = 0.0;
1537
1538 // Signal 1 — TJ offset (highest confidence). Raw units; a full
1539 // space is ~250. Anything over TJ_SPACE_THRESHOLD_UNITS counts.
1540 //
1541 // ONLY A NEGATIVE ONE, because only a negative TJ moves the next glyph
1542 // right, away from the last. A positive one is a pair kern pulling it
1543 // back: `[(FINANCIAL RESUL)134.6(TS)] TJ` in NYSE_MS_2001 overlaps
1544 // its `L` and `T` by a point, and counted by its size it wrote
1545 // `FINANCIAL RESUL TS` and `MANAGEMENT'S DISCUSSION AND ANAL YSIS`,
1546 // two headings the ground truth could not match (#638 rank 4).
1547 if self.pending_tj_offset <= -TJ_SPACE_THRESHOLD_UNITS {
1548 confidence += TJ_SIGNAL_WEIGHT;
1549 }
1550
1551 // Signal 2 — geometric gap. Prefer the adaptive median-char-width
1552 // reference; fall back to font-size when the median hasn't been
1553 // established yet (first few glyphs on a page).
1554 let gap_reference = if self.cached_median_char_width > 0.0 {
1555 self.cached_median_char_width * GAP_TO_MEDIAN_CHAR_FRACTION
1556 } else {
1557 font_size * GAP_TO_FONT_SIZE_FALLBACK_FRACTION
1558 };
1559 if gap > gap_reference {
1560 confidence += GAP_SIGNAL_WEIGHT;
1561 }
1562
1563 // Signal 3 — character-class transitions. Only checked when the
1564 // previous span ends with a character and the incoming text starts
1565 // with one; avoids double-counting with punctuation.
1566 if let (Some(prev_last), Some(next_first)) =
1567 (prev_text.chars().last(), next_text.chars().next())
1568 {
1569 let camel = prev_last.is_lowercase() && next_first.is_uppercase();
1570 let digit_to_letter = prev_last.is_ascii_digit() && next_first.is_alphabetic();
1571 let letter_to_digit = prev_last.is_alphabetic() && next_first.is_ascii_digit();
1572 if camel || digit_to_letter || letter_to_digit {
1573 confidence += HEURISTIC_SIGNAL_WEIGHT;
1574 }
1575 }
1576
1577 confidence >= SPACE_CONSENSUS_THRESHOLD
1578 }
1579
1580 /// Whether this gap is the typesetter's TRACKING rather than a word space
1581 /// (#582).
1582 ///
1583 /// A display line set in letter-spaced capitals puts the same distance
1584 /// between every pair of letters, and that distance is over every
1585 /// threshold a word space has: the TJ adjustment is a full one and the
1586 /// geometric gap is far past a third of a character. So the consensus
1587 /// fires on EVERY pair and `DIRECTORS' REPORT` comes out as
1588 /// `D I R E C TO R S ' R E P O R T` -- which is 47 of the 200 pages of
1589 /// run `structured-doclaynet-20260922T081244Z` writing text the ground
1590 /// truth does not have, and, on the heading axis, 65 of 154 missed
1591 /// headings (#583).
1592 ///
1593 /// WHAT SEPARATES THEM IS NOT THE LETTERS BUT THE REGULARITY. Tracking is
1594 /// a constant added to every advance, so a tracked run's gaps agree with
1595 /// one another; prose has word spaces at the ends of words and kerning
1596 /// everywhere else, and four consecutive gaps that agree within a quarter
1597 /// while every one of them is over the word-space threshold is not a
1598 /// sentence. Nothing here reads the text, so it holds for a language whose
1599 /// capitals this file has never seen.
1600 ///
1601 /// AND THE REAL WORD SPACE IS NOT LOST, because a tracked line still draws
1602 /// its word space as a space GLYPH: `DIRECTORS' REPORT` carries a 0x20 in
1603 /// the string the page shows, and that arrives as text rather than as a
1604 /// gap for this to judge.
1605 ///
1606 /// WHERE IT PAYS, AND WHY THAT IS A CHOICE AND NOT AN OVERSIGHT. A page
1607 /// that draws `A B C D E F` as separate glyphs with equal gaps and no
1608 /// space glyphs is, byte for byte, the same page as a letter-spaced
1609 /// display line: both are single characters at one constant advance, and
1610 /// nothing on the page says which the author meant. This takes the reading
1611 /// that is far more common and that the corpus measured -- letter-spacing,
1612 /// 47 of 200 pages -- and pays for it on a line of isolated single-letter
1613 /// words, which is rare. The price is pinned by
1614 /// `a_line_of_single_letter_words_reads_as_tracking_and_that_is_the_choice`
1615 /// so that a later reader argues with the note rather than with a silence.
1616 fn a_tracked_run(&self, gap: f64) -> bool {
1617 if gap <= 0.0 || self.recent_gaps.len() < TRACKING_MIN_PAIRS {
1618 return false;
1619 }
1620 self.recent_gaps
1621 .iter()
1622 .rev()
1623 .take(TRACKING_MIN_PAIRS)
1624 .all(|before| (before - gap).abs() <= TRACKING_TOLERANCE * gap)
1625 }
1626
1627 /// Take back the spaces already written into a run now recognised as
1628 /// tracked.
1629 ///
1630 /// The regularity needs [`TRACKING_MIN_PAIRS`] earlier gaps before it can be
1631 /// seen, so the first letters of the run have had their spaces inserted
1632 /// already: without this, `DIRECTORS` comes back as `D I R E CTORS`.
1633 ///
1634 /// ONLY THE SPACES THE CONSENSUS PUT IN, AT THE TRACKING (#623). The join
1635 /// used to take back every space between single characters at the end of
1636 /// the text, and a space the page DREW looks the same there: `IT ALL ADDS
1637 /// UP` (NYSE_WAL_2014) draws its word spaces as glyphs, and the run came out
1638 /// `ITALL`. It also stopped at the first piece longer than one letter, so a
1639 /// narrow `I` that took no space left `D IR ECTORS`. A space goes when the
1640 /// consensus inserted it at a gap the tracking explains, inside the run
1641 /// the tracking was seen in, between two letters; a word space set wider
1642 /// than the tracking, drawn or not, stays -- and so does the space of
1643 /// `suppose that`, which a formula's regular gaps further on matched
1644 /// (1201.4397).
1645 fn take_back_tracked_spaces(
1646 text: &mut String,
1647 inserted: &mut Vec<(usize, f64)>,
1648 run_start: usize,
1649 tracking: f64,
1650 ) {
1651 let between_letters = |at: usize| {
1652 text[..at]
1653 .chars()
1654 .next_back()
1655 .is_some_and(char::is_alphabetic)
1656 && text[at + 1..]
1657 .chars()
1658 .next()
1659 .is_some_and(char::is_alphabetic)
1660 };
1661 let goes = |at: usize, gap: f64| {
1662 at >= run_start
1663 && at < text.len()
1664 && text.as_bytes()[at] == b' '
1665 && (gap - tracking).abs() <= TRACKING_TOLERANCE * tracking
1666 && between_letters(at)
1667 };
1668 if !inserted.iter().any(|&(at, gap)| goes(at, gap)) {
1669 return;
1670 }
1671 let goes: Vec<bool> = inserted.iter().map(|&(at, gap)| goes(at, gap)).collect();
1672 let bytes = text.as_bytes();
1673 let mut kept = String::with_capacity(text.len());
1674 let mut still: Vec<(usize, f64)> = Vec::with_capacity(inserted.len());
1675 let mut from = 0;
1676 for (&(at, gap), &goes) in inserted.iter().zip(&goes) {
1677 if at < from || at >= text.len() || bytes[at] != b' ' {
1678 continue;
1679 }
1680 kept.push_str(&text[from..at]);
1681 if goes {
1682 from = at + 1;
1683 } else {
1684 still.push((kept.len(), gap));
1685 kept.push(' ');
1686 from = at + 1;
1687 }
1688 }
1689 kept.push_str(&text[from..]);
1690 *text = kept;
1691 *inserted = still;
1692 }
1693
1694 /// Keep a drawn path when it is a horizontal rule (#545).
1695 ///
1696 /// The whole of the page's graphics arrives here and almost none of it is
1697 /// kept: what is, is ink that is a line -- flat enough not to be a box and
1698 /// long enough to separate two rows -- recorded in the page space the
1699 /// spans already live in, so the two can be compared without a second
1700 /// coordinate system to keep in step. The box is the transformed bounding
1701 /// box, which for a rotated path is the box around it and therefore too
1702 /// tall to be a rule: a rule on a turned page is not read as one, and that
1703 /// is the safe way round.
1704 ///
1705 /// A stroke is measured on its centreline, since that is the path the
1706 /// device is given and the weight is a property of the pen. A heavy rule is
1707 /// still a rule, and it still lies where its centreline lies.
1708 fn record_rule(&mut self, path: &BezPath, transform: Affine) {
1709 // The cap is asked at the door AND at every push. One path may hold
1710 // hundreds of subpaths, so a check that runs once per call is not a
1711 // bound on anything: it lets a single compound path put thousands of
1712 // entries in a vector `region_is_ruled` then walks per band.
1713 if self.rules.len() >= MAX_RECORDED_RULES {
1714 return;
1715 }
1716 // SUBPATH BY SUBPATH, because a producer is free to draw all of a
1717 // table's rules in one path, and a box around the lot of them is a box
1718 // and not a line. The bound is taken over the points a segment is made
1719 // of, curve controls included -- a curve lies inside its own hull, so
1720 // the box is never too small, and the arithmetic is a comparison per
1721 // point rather than solving each curve for its extremes. On the slowest
1722 // document of the gate set, which draws a figure of several thousand
1723 // segments, exact curve bounds cost 7% of its 3.3 s; this costs 1.6%.
1724 // WHAT IS DRAWN IS NOT WHAT IS SEEN. A Form XObject's `/BBox` reaches
1725 // this device as a clip and the geometry inside it unclipped, so a line
1726 // ruled across one cell of a form can arrive as a line across the page.
1727 // Two of those are enough for `region_is_ruled` to refuse a column cut
1728 // that should have been taken, so the extent recorded is the visible
1729 // one.
1730 let clip = self.clip_stack.last().copied();
1731 let mut lo = (f64::INFINITY, f64::INFINITY);
1732 let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
1733 let flush = |lo: &mut (f64, f64), hi: &mut (f64, f64), rules: &mut Vec<HorizontalRule>| {
1734 let (mut left, mut right) = (lo.0, hi.0);
1735 let (mut bottom, mut top) = (lo.1, hi.1);
1736 if let Some(clip) = clip {
1737 left = left.max(clip.x0);
1738 right = right.min(clip.x1);
1739 bottom = bottom.max(clip.y0);
1740 top = top.min(clip.y1);
1741 }
1742 if lo.0.is_finite()
1743 && rules.len() < MAX_RECORDED_RULES
1744 && bottom <= top
1745 && hi.1 - lo.1 <= RULE_MAX_THICKNESS
1746 && right - left >= RULE_MIN_LENGTH
1747 {
1748 rules.push(HorizontalRule {
1749 left,
1750 right,
1751 y: (lo.1 + hi.1) * 0.5,
1752 });
1753 }
1754 *lo = (f64::INFINITY, f64::INFINITY);
1755 *hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
1756 };
1757
1758 for element in path.elements() {
1759 if matches!(element, PathEl::MoveTo(_) | PathEl::ClosePath) {
1760 flush(&mut lo, &mut hi, &mut self.rules);
1761 }
1762 for point in element_points(element) {
1763 let p = transform * point;
1764 lo = (lo.0.min(p.x), lo.1.min(p.y));
1765 hi = (hi.0.max(p.x), hi.1.max(p.y));
1766 }
1767 }
1768 flush(&mut lo, &mut hi, &mut self.rules);
1769 }
1770
1771 /// Keep a drawn path's straight axis-aligned strokes for the lattice
1772 /// (#574): each subpath that is a thin line, in either direction, and the
1773 /// four edges of each subpath that is an axis-aligned rectangle -- the way
1774 /// a timetable draws its cells. Anything else, a curve or a slanted line,
1775 /// is not a grid and is not kept.
1776 fn record_rulings(&mut self, path: &BezPath, transform: Affine) {
1777 if self.rulings.len() >= MAX_RECORDED_RULINGS {
1778 return;
1779 }
1780 let clip = self.clip_stack.last().copied();
1781 let mut points: Vec<Point> = Vec::new();
1782 let mut curved = false;
1783 let mut subpaths: Vec<(Vec<Point>, bool)> = Vec::new();
1784 for element in path.elements() {
1785 if matches!(element, PathEl::MoveTo(_)) && !points.is_empty() {
1786 subpaths.push((std::mem::take(&mut points), curved));
1787 curved = false;
1788 }
1789 if matches!(element, PathEl::QuadTo(..) | PathEl::CurveTo(..)) {
1790 curved = true;
1791 }
1792 for point in element_points(element) {
1793 points.push(transform * point);
1794 }
1795 }
1796 if !points.is_empty() {
1797 subpaths.push((points, curved));
1798 }
1799 for (points, curved) in subpaths {
1800 if curved || points.len() < 2 {
1801 continue;
1802 }
1803 let (mut x0, mut y0) = (f64::INFINITY, f64::INFINITY);
1804 let (mut x1, mut y1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
1805 for p in &points {
1806 x0 = x0.min(p.x);
1807 y0 = y0.min(p.y);
1808 x1 = x1.max(p.x);
1809 y1 = y1.max(p.y);
1810 }
1811 let clipped = |r: Ruling| -> Option<Ruling> {
1812 let Some(c) = clip else {
1813 return Some(r);
1814 };
1815 let r = Ruling {
1816 x0: r.x0.max(c.x0),
1817 y0: r.y0.max(c.y0),
1818 x1: r.x1.min(c.x1),
1819 y1: r.y1.min(c.y1),
1820 };
1821 (r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r)
1822 };
1823 let (w, h) = (x1 - x0, y1 - y0);
1824 let mut found: Vec<Ruling> = Vec::new();
1825 if h <= RULE_MAX_THICKNESS && w >= RULING_MIN_LENGTH {
1826 let y = (y0 + y1) * 0.5;
1827 found.push(Ruling {
1828 x0,
1829 y0: y,
1830 x1,
1831 y1: y,
1832 });
1833 } else if w <= RULE_MAX_THICKNESS && h >= RULING_MIN_LENGTH {
1834 let x = (x0 + x1) * 0.5;
1835 found.push(Ruling {
1836 x0: x,
1837 y0,
1838 x1: x,
1839 y1,
1840 });
1841 } else if w > RULE_MAX_THICKNESS && h > RULE_MAX_THICKNESS {
1842 // A rectangle, and only if every point is one of its corners.
1843 let corner = |p: &Point| {
1844 ((p.x - x0).abs() < 0.5 || (p.x - x1).abs() < 0.5)
1845 && ((p.y - y0).abs() < 0.5 || (p.y - y1).abs() < 0.5)
1846 };
1847 if points.iter().all(corner) && points.len() <= 6 {
1848 found.push(Ruling { x0, y0, x1, y1: y0 });
1849 found.push(Ruling { x0, y0: y1, x1, y1 });
1850 found.push(Ruling { x0, y0, x1: x0, y1 });
1851 found.push(Ruling { x0: x1, y0, x1, y1 });
1852 }
1853 }
1854 for r in found.into_iter().filter_map(clipped) {
1855 if self.rulings.len() >= MAX_RECORDED_RULINGS {
1856 return;
1857 }
1858 self.rulings.push(r);
1859 }
1860 }
1861 }
1862
1863 /// The lattice rulings the page drew so far, taken out of the device
1864 /// (#574). Called before the device is consumed into its blocks.
1865 #[doc(hidden)]
1866 /// Keep a filled path that is a solid, non-white, axis-aligned rectangle
1867 /// big enough to stand behind a line of text (#574): shading, a
1868 /// highlighted row, a note's box. Anything curved or patterned, anything
1869 /// near white -- a page's own background -- and anything past the cap is
1870 /// left out.
1871 fn record_fill(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'_>) {
1872 if self.fills.len() >= MAX_RECORDED_RULINGS {
1873 return;
1874 }
1875 let Paint::Color(color) = paint else {
1876 return;
1877 };
1878 let rgba = color.to_rgba().to_rgba8();
1879 let [r, g, b, a] = rgba;
1880 let luminance = (299 * u32::from(r) + 587 * u32::from(g) + 114 * u32::from(b)) / 1000;
1881 if a < 26 || luminance >= FILL_WHITE_ABOVE {
1882 return;
1883 }
1884 let mut points: Vec<Point> = Vec::new();
1885 for element in path.elements() {
1886 match element {
1887 PathEl::QuadTo(..) | PathEl::CurveTo(..) => return,
1888 PathEl::MoveTo(_) if !points.is_empty() => return,
1889 _ => points.extend(element_points(element).map(|p| transform * p)),
1890 }
1891 }
1892 if points.len() < 4 {
1893 return;
1894 }
1895 let rect = transform.transform_rect_bbox(path.bounding_box());
1896 // Axis-aligned: every corner on the box's edges.
1897 let on_edge = |p: &Point| {
1898 ((p.x - rect.x0).abs() < 0.5 || (p.x - rect.x1).abs() < 0.5)
1899 && ((p.y - rect.y0).abs() < 0.5 || (p.y - rect.y1).abs() < 0.5)
1900 };
1901 if !points.iter().all(on_edge) {
1902 return;
1903 }
1904 let mut rect = rect;
1905 if let Some(clip) = self.clip_stack.last() {
1906 rect = rect.intersect(*clip);
1907 }
1908 if rect.width() >= FILL_MIN_WIDTH && rect.height() >= FILL_MIN_HEIGHT {
1909 self.fills.push([rect.x0, rect.y0, rect.x1, rect.y1]);
1910 }
1911 }
1912
1913 /// The fills [`Self::record_fill`] kept, in page space.
1914 pub fn take_fills(&mut self) -> Vec<[f64; 4]> {
1915 std::mem::take(&mut self.fills)
1916 }
1917
1918 pub fn take_rulings(&mut self) -> Vec<Ruling> {
1919 std::mem::take(&mut self.rulings)
1920 }
1921
1922 /// The boxes the page drew its images into, `[left, bottom, right, top]`
1923 /// in page space, in drawing order (#636).
1924 #[doc(hidden)]
1925 pub fn take_images(&mut self) -> Vec<[f64; 4]> {
1926 std::mem::take(&mut self.images)
1927 }
1928
1929 /// The figures the page DRAWS rather than places as images, as boxes in
1930 /// page space (#636): the path boxes that touch or overlap, merged; a
1931 /// cluster counts when it holds at least three paths and is at least 20
1932 /// points each way, and not when it covers more than half the page -- a
1933 /// page's background or frame is not a figure anyone captions.
1934 #[doc(hidden)]
1935 pub fn take_graphics(&mut self, width: f64, height: f64) -> Vec<[f64; 4]> {
1936 let boxes = std::mem::take(&mut self.path_boxes);
1937 let mut clusters: Vec<([f64; 4], usize)> = Vec::new();
1938 for b in boxes {
1939 let mut merged = (b, 1usize);
1940 let mut rest = Vec::with_capacity(clusters.len());
1941 for c in clusters {
1942 if touches(&merged.0, &c.0, GRAPHIC_GAP) {
1943 merged = (union(&merged.0, &c.0), merged.1 + c.1);
1944 } else {
1945 rest.push(c);
1946 }
1947 }
1948 rest.push(merged);
1949 clusters = rest;
1950 }
1951 let page = (width * height).max(1.0);
1952 clusters
1953 .into_iter()
1954 .filter(|(b, n)| {
1955 let (w, h) = (b[2] - b[0], b[3] - b[1]);
1956 *n >= 3 && w >= 20.0 && h >= 20.0 && w * h <= 0.5 * page
1957 })
1958 .map(|(b, _)| b)
1959 .collect()
1960 }
1961
1962 fn record_path_box(&mut self, path: &BezPath, transform: Affine) {
1963 if self.path_boxes.len() >= MAX_RECORDED_PATH_BOXES {
1964 return;
1965 }
1966 let mut rect = transform.transform_rect_bbox(path.bounding_box());
1967 if let Some(clip) = self.clip_stack.last() {
1968 rect = rect.intersect(*clip);
1969 }
1970 // A THIN LINE IS A RULE, a ruling or an underline -- the table and
1971 // paragraph passes read those -- and not a part of a figure.
1972 if rect.width().min(rect.height()) >= 1.5 {
1973 self.path_boxes.push([rect.x0, rect.y0, rect.x1, rect.y1]);
1974 }
1975 }
1976
1977 /// Record where an image lands: its PIXEL rectangle under the transform
1978 /// the interpreter hands over -- which maps `0..width, 0..height`, not the
1979 /// unit square of the content stream's `cm` -- clipped to the visible box. A sliver under a point wide is not a picture
1980 /// anyone would caption -- it is a rule or a dot drawn as an image -- and
1981 /// is left out, and so is anything past the cap.
1982 fn record_image(&mut self, width: u32, height: u32, transform: Affine) {
1983 if self.images.len() >= MAX_RECORDED_IMAGES {
1984 return;
1985 }
1986 let (w, h) = (f64::from(width.max(1)), f64::from(height.max(1)));
1987 let mut rect = transform.transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w, h));
1988 if let Some(clip) = self.clip_stack.last() {
1989 rect = rect.intersect(*clip);
1990 }
1991 if rect.width() >= 1.0 && rect.height() >= 1.0 {
1992 self.images.push([rect.x0, rect.y0, rect.x1, rect.y1]);
1993 }
1994 }
1995
1996 /// Consume the device and return extracted text as a single string.
1997 pub fn into_text(mut self) -> String {
1998 self.end_last_glyph();
1999 if self.geometry_mode == GeometryMode::RichGeometry {
2000 self.compute_tight_bounds();
2001 }
2002 self.spans.iter_mut().for_each(untrack_spaced_letters);
2003 let keep_ligatures = self.keep_ligatures;
2004 let rules = std::mem::take(&mut self.rules);
2005 let mut blocks = group_spans_into_blocks(self.spans, &rules);
2006 if !keep_ligatures {
2007 // Folded on the spans, which the lines assemble from: the one
2008 // fold every TEXT output shares (see `crate::ligatures`).
2009 for block in &mut blocks {
2010 crate::ligatures::fold_text_block(block);
2011 }
2012 }
2013 let lines: Vec<String> = blocks.iter().map(|b| b.text()).collect();
2014 let stitched = stitch_hyphenated_lines(&lines);
2015 normalize_text_output(&stitched)
2016 }
2017
2018 /// Consume the device and return text blocks.
2019 pub fn into_blocks(mut self) -> Vec<TextBlock> {
2020 self.end_last_glyph();
2021 if self.geometry_mode == GeometryMode::RichGeometry {
2022 self.compute_tight_bounds();
2023 }
2024 self.spans.iter_mut().for_each(untrack_spaced_letters);
2025 let keep_ligatures = self.keep_ligatures;
2026 let rules = std::mem::take(&mut self.rules);
2027 let mut blocks = group_spans_into_blocks(self.spans, &rules);
2028 if !keep_ligatures {
2029 for block in &mut blocks {
2030 crate::ligatures::fold_text_block(block);
2031 }
2032 }
2033 blocks
2034 }
2035
2036 /// Consume the device and return the page's paragraphs (#567).
2037 ///
2038 /// `page` is the 0-based page index the blocks record, and `first_index`
2039 /// where this page's blocks start in the document's reading order, so a
2040 /// caller walking a document hands the count of what it has emitted so far.
2041 pub fn into_paragraph_blocks(mut self, page: usize, first_index: usize) -> Vec<Block> {
2042 self.end_last_glyph();
2043 if self.geometry_mode == GeometryMode::RichGeometry {
2044 self.compute_tight_bounds();
2045 }
2046 self.spans.iter_mut().for_each(untrack_spaced_letters);
2047 let rules = std::mem::take(&mut self.rules);
2048 // Unfolded: these blocks feed the layout and model decisions and the
2049 // training dump, which all read the characters the glyphs declared.
2050 // The fold happens on the way out of `PdfDocument`, after the
2051 // decisions (see `crate::ligatures`). Every table stands: the
2052 // single-page path takes no options, so no strategy steers it.
2053 paragraph_blocks(self.spans, &rules, page, first_index, TableStrategy::Both)
2054 }
2055
2056 /// The paragraphs AND the rules the page drew, in one pass (#600).
2057 ///
2058 /// The blocks are what a reader wants and the rules are what a model
2059 /// wants: a cell boundary in a ruled table is a drawn line, and a learned
2060 /// table structure that cannot see the lines is guessing at the strongest
2061 /// evidence the page offers. They come back together because the
2062 /// alternative is interpreting the page twice, and two passes over one
2063 /// page is two chances for them to disagree.
2064 ///
2065 /// Hidden rather than published: this is what
2066 /// `crates/pdf-learned`'s feature dump reads, and the shape of it follows
2067 /// whatever that needs rather than a promise to anyone else.
2068 #[doc(hidden)]
2069 pub fn into_paragraph_blocks_and_rules(
2070 self,
2071 page: usize,
2072 first_index: usize,
2073 ) -> (Vec<Block>, Vec<HorizontalRule>) {
2074 self.into_paragraph_blocks_and_rules_with_strategy(
2075 page,
2076 first_index,
2077 TableStrategy::Both,
2078 false,
2079 )
2080 }
2081
2082 /// [`Self::into_paragraph_blocks_and_rules`], with the table-detection
2083 /// strategy given: under [`TableStrategy::Lines`] a grid no drawn rule
2084 /// crosses goes back to the paragraph merge (#659).
2085 ///
2086 /// `cell_path_words` is
2087 /// [`ExtractOptions::cell_path_words`](crate::ExtractOptions::cell_path_words):
2088 /// the leader fold and the line and cell texts read with the cell-path
2089 /// word rules where true.
2090 ///
2091 /// Hidden like the twin: this is what `extract_blocks` reads, and the
2092 /// shape of it follows whatever that needs rather than a promise to
2093 /// anyone else.
2094 #[doc(hidden)]
2095 pub fn into_paragraph_blocks_and_rules_with_strategy(
2096 mut self,
2097 page: usize,
2098 first_index: usize,
2099 table_strategy: TableStrategy,
2100 cell_path_words: bool,
2101 ) -> (Vec<Block>, Vec<HorizontalRule>) {
2102 self.end_last_glyph();
2103 if self.geometry_mode == GeometryMode::RichGeometry {
2104 self.compute_tight_bounds();
2105 }
2106 self.spans.iter_mut().for_each(untrack_spaced_letters);
2107 let rules = std::mem::take(&mut self.rules);
2108 let blocks = paragraph_blocks_with_cell_path_words(
2109 self.spans,
2110 &rules,
2111 page,
2112 first_index,
2113 table_strategy,
2114 cell_path_words,
2115 );
2116 (blocks, rules)
2117 }
2118
2119 /// Consume the device and return the page's blocks in raw content-stream
2120 /// order (#643).
2121 ///
2122 /// Requires [`Self::raw_runs`]: the spans must be the runs the page
2123 /// draws, in the order it draws them. Blocks follow draw order with no
2124 /// reading-order sort; the frames turn with the page exactly as the
2125 /// default path's do.
2126 pub fn into_raw_blocks(mut self, page: usize, first_index: usize) -> Vec<Block> {
2127 self.end_last_glyph();
2128 debug_assert!(self.raw_runs, "raw blocks need raw runs");
2129 if self.geometry_mode == GeometryMode::RichGeometry {
2130 self.compute_tight_bounds();
2131 }
2132 self.spans.iter_mut().for_each(untrack_spaced_letters);
2133 // The page ends every run it drew (#643).
2134 if let Some(last) = self.spans.last_mut() {
2135 last.complete_run = true;
2136 }
2137 let bdc = std::mem::take(&mut self.raw_bdc);
2138 debug_assert_eq!(bdc.len(), self.spans.len());
2139 raw_blocks(self.spans, &bdc, page, first_index)
2140 }
2141
2142 /// Consume the device and return raw spans.
2143 #[allow(dead_code)]
2144 pub(crate) fn into_spans(mut self) -> Vec<TextSpan> {
2145 self.end_last_glyph();
2146 if self.geometry_mode == GeometryMode::RichGeometry {
2147 self.compute_tight_bounds();
2148 }
2149 self.spans.iter_mut().for_each(untrack_spaced_letters);
2150 self.spans
2151 }
2152
2153 /// Forget the last glyph where the device ends: no glyph follows, so its
2154 /// place is never read again and [`LAST_GLYPH`] no longer names this
2155 /// device.
2156 fn end_last_glyph(&mut self) {
2157 self.last_glyph_text = None;
2158 self.holds_last_glyph = false;
2159 self.last_glyph_guard.clear();
2160 }
2161
2162 /// Remember `glyph` as the last glyph and `record` as where its text went.
2163 ///
2164 /// A glyph the page hides (`hidden`), or one whose text lands in a span
2165 /// the page hides, is not remembered: hidden text can be dropped from the
2166 /// output after the fact, and a mark that went into it would go with it.
2167 /// Not read again, its offer is refused and the mark reads as before its
2168 /// row (spec review 14-10 02:00 (ii)).
2169 fn remember_last_glyph(
2170 &mut self,
2171 glyph: &OutlineGlyph,
2172 mut record: LastGlyphText,
2173 hidden: bool,
2174 ) {
2175 let tail = self.spans.last();
2176 if hidden || tail.is_some_and(TextSpan::is_hidden) {
2177 self.last_glyph_text = None;
2178 return;
2179 }
2180 record.span_count = self.spans.len();
2181 record.tail_text = tail.map_or(0, |span| span.text.len());
2182 record.tail_boxes = tail.map_or(0, |span| span.char_bounds.len());
2183 record.place = glyph.place();
2184 if record.place.is_none() {
2185 self.last_glyph_text = None;
2186 return;
2187 }
2188 if !self.holds_last_glyph {
2189 let id = self.id;
2190 self.holds_last_glyph = LAST_GLYPH.try_with(|slot| slot.set(Some(id))).is_ok();
2191 }
2192 self.last_glyph_text = self.holds_last_glyph.then_some(record);
2193 }
2194
2195 /// Whether the last glyph's text still stands last in the last span.
2196 fn last_glyph_still_last(&self, last: &LastGlyphText) -> bool {
2197 let tail = self.spans.last();
2198 self.spans.len() == last.span_count
2199 && tail.map_or(0, |span| span.text.len()) == last.tail_text
2200 && tail.map_or(0, |span| span.char_bounds.len()) == last.tail_boxes
2201 }
2202
2203 /// Read the last glyph's text again and put what it reads now in place of
2204 /// what it read then (see [`LastGlyphText`]). The glyph is read only while
2205 /// its text still stands last, so that the read is never one this device
2206 /// cannot act on; nothing changes when the text is unchanged.
2207 fn reread_last_glyph(&mut self, glyph: &Glyph<'_>) {
2208 let Some(last) = self.last_glyph_text.as_ref() else {
2209 return;
2210 };
2211 if !self.last_glyph_still_last(last) {
2212 self.last_glyph_text = None;
2213 return;
2214 }
2215 // Only the glyph arriving now can have changed the last one, and it
2216 // says so; on a page without a row mark it never has.
2217 let read = match (glyph, last.place) {
2218 (Glyph::Outline(outline), Some(place)) => outline.reread_previous(place),
2219 _ => None,
2220 };
2221 let Some(read) = read else {
2222 return;
2223 };
2224 let last = self.last_glyph_text.as_mut().expect("checked above");
2225 if last.boxless {
2226 // It wrote nothing, and a glyph that read as nothing is a mark
2227 // whose read gave its offer up; there is nothing to put back.
2228 return;
2229 }
2230 let span = &mut self.spans[last.span];
2231 // Compared in place: on a page without mark rows the text never
2232 // changes, and the comparison then costs no allocation.
2233 let written = &span.text[last.offset..];
2234 let unchanged = match &read {
2235 Some(BfString::Char(c)) => written == c.encode_utf8(&mut [0; 4]),
2236 Some(BfString::String(s)) => written == s,
2237 None => written.is_empty(),
2238 };
2239 if unchanged {
2240 return;
2241 }
2242 let now = match read {
2243 Some(BfString::Char(c)) => c.to_string(),
2244 Some(BfString::String(s)) => s,
2245 None => String::new(),
2246 };
2247 span.text.truncate(last.offset);
2248 span.text.push_str(&now);
2249 last.len = now.len();
2250 last.tail_text = span.text.len();
2251 if !now.is_empty() {
2252 return;
2253 }
2254 // Read as nothing now: the glyph gives its box back, and a span it
2255 // opened goes with it.
2256 let last = self.last_glyph_text.take().expect("checked above");
2257 self.deferred_rich_glyphs.truncate(last.deferred);
2258 if last.opened {
2259 self.spans.pop();
2260 if self.raw_runs {
2261 self.raw_bdc.pop();
2262 if let (Some(prev), Some(was)) = (self.spans.last_mut(), last.previous_complete_run)
2263 {
2264 prev.complete_run = was;
2265 }
2266 }
2267 } else {
2268 let span = &mut self.spans[last.span];
2269 span.char_bounds.truncate(last.char_bounds);
2270 span.width = last.width;
2271 }
2272 }
2273
2274 /// Record glyph-level data for deferred tight-bounds computation.
2275 /// Called from draw_glyph in RichGeometry mode only.
2276 fn record_deferred_glyph(
2277 &mut self,
2278 glyph: &Glyph<'_>,
2279 composed: &Affine,
2280 font_size: f64,
2281 glyph_width: f64,
2282 ) {
2283 let coeffs = composed.as_coeffs();
2284 let is_rotated = coeffs[1].abs() > ROTATION_EPSILON || coeffs[2].abs() > ROTATION_EPSILON;
2285 let is_sheared = (coeffs[0] - coeffs[3]).abs() > SHEAR_EPSILON;
2286 let needs_exact = is_rotated || is_sheared;
2287
2288 // Capture the glyph's ink bbox in raw glyph space. The full outline path
2289 // is retained only when `needs_exact` (rotated/sheared), so a tight bound
2290 // can bound the transformed path; axis-aligned glyphs keep only the bbox
2291 // (enveloping its transformed corners is exact) to bound memory on large
2292 // pages. Ink-less glyphs (spaces, Type3) carry no bbox → Estimate later.
2293 let (outline, font_bbox) = match glyph {
2294 Glyph::Outline(o) => {
2295 let path = o.outline();
2296 let bb = path.bounding_box();
2297 if bb.width() <= 0.0 && bb.height() <= 0.0 {
2298 (None, None)
2299 } else if needs_exact {
2300 (Some(path), Some(bb))
2301 } else {
2302 (None, Some(bb))
2303 }
2304 }
2305 Glyph::Type3(_) => (None, None),
2306 };
2307
2308 self.deferred_rich_glyphs.push(DeferredGlyph {
2309 coeffs,
2310 font_size,
2311 glyph_width,
2312 needs_exact,
2313 outline,
2314 font_bbox,
2315 });
2316 }
2317
2318 /// Compute tight bounds for all recorded glyphs and populate the spans.
2319 /// Called once after all glyphs have been extracted.
2320 fn compute_tight_bounds(&mut self) {
2321 let deferred = std::mem::take(&mut self.deferred_rich_glyphs);
2322 if deferred.is_empty() {
2323 return;
2324 }
2325
2326 let mut idx = 0usize;
2327
2328 for span in &mut self.spans {
2329 let n = span.char_bounds.len();
2330 if n == 0 {
2331 continue;
2332 }
2333
2334 for _gi in 0..n {
2335 if idx >= deferred.len() {
2336 break;
2337 }
2338 let dg = &deferred[idx];
2339 idx += 1;
2340
2341 let composed = Affine::new(dg.coeffs);
2342 let (tight_bound, source) = if let Some(font_bbox) = dg.font_bbox {
2343 // Glyph has ink. The outline/bbox is in raw glyph space and
2344 // `composed` (CTM * glyph_transform) maps it straight to page
2345 // space — exactly as the renderer fills the raw outline, with
2346 // NO upem pre-scaling. Applying any extra font_size/1000 here
2347 // would double-scale the bound.
2348 if dg.needs_exact {
2349 // Rotated/sheared: bound the transformed outline path so
2350 // the box stays tight (the transformed bbox would be
2351 // looser). Falls back to enveloping the bbox corners if
2352 // the path was not retained.
2353 let page_bbox = match dg.outline {
2354 Some(ref path) => (composed * path.clone()).bounding_box(),
2355 None => {
2356 let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
2357 let b = transform_bbox_corners(&raw, &composed);
2358 kurbo::Rect::new(b[0], b[1], b[2], b[3])
2359 }
2360 };
2361 (
2362 [page_bbox.x0, page_bbox.y0, page_bbox.x1, page_bbox.y1],
2363 BoundsSource::Tight,
2364 )
2365 } else {
2366 // Axis-aligned scale + translate: enveloping the
2367 // transformed bbox corners is exact.
2368 let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
2369 (transform_bbox_corners(&raw, &composed), BoundsSource::Tight)
2370 }
2371 } else {
2372 // No ink (space, Type3, missing outline): fall back to the
2373 // advance box in page space, in the same frame as the span's
2374 // char_bounds ([x, y, x + advance, y + font_size]).
2375 let x = dg.coeffs[4];
2376 let y = dg.coeffs[5];
2377 (
2378 [x, y, x + dg.glyph_width, y + dg.font_size],
2379 BoundsSource::Estimate,
2380 )
2381 };
2382
2383 span.tight_char_bounds.push(tight_bound);
2384 span.glyph_advances.push(dg.glyph_width);
2385 span.glyph_bounds_sources.push(source);
2386 }
2387
2388 span.bounds_source = aggregate_bounds_source(&span.glyph_bounds_sources);
2389 }
2390 }
2391}
2392
2393impl Device<'_> for TextExtractionDevice {
2394 fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
2395 fn set_blend_mode(&mut self, _: BlendMode) {}
2396 fn begin_marked_content(&mut self, _tag: &[u8], _mcid: Option<i32>, actual_text: Option<&str>) {
2397 if self.actual_text || self.in_annotation_appearance() {
2398 self.actual_text_stack.push(ActualTextEntry {
2399 actual_text: actual_text.map(str::to_string),
2400 consumed: false,
2401 saw_glyph: false,
2402 child_resolved: false,
2403 extent: None,
2404 });
2405 }
2406 if !self.raw_runs {
2407 return;
2408 }
2409 let id = self.raw_bdc_next;
2410 self.raw_bdc_next += 1;
2411 self.raw_bdc_stack.push(id);
2412 self.raw_bdc_changed = true;
2413 }
2414 fn end_marked_content(&mut self) {
2415 if let Some(entry) = self.actual_text_stack.pop() {
2416 self.emit_glyphless_replacement(entry);
2417 }
2418 if !self.raw_runs {
2419 return;
2420 }
2421 self.raw_bdc_stack.pop();
2422 self.raw_bdc_changed = true;
2423 }
2424 fn draw_path(
2425 &mut self,
2426 path: &BezPath,
2427 transform: Affine,
2428 paint: &Paint<'_>,
2429 mode: &PathDrawMode,
2430 ) {
2431 self.record_rule(path, transform);
2432 self.record_rulings(path, transform);
2433 self.record_path_box(path, transform);
2434 if matches!(mode, PathDrawMode::Fill(_)) {
2435 self.record_fill(path, transform, paint);
2436 }
2437 if !self.actual_text_stack.is_empty() {
2438 let mut drawn = transform.transform_rect_bbox(path.bounding_box());
2439 if let Some(clip) = self.clip_stack.last() {
2440 drawn = drawn.intersect(*clip);
2441 }
2442 self.note_drawn_box([drawn.x0, drawn.y0, drawn.x1, drawn.y1]);
2443 }
2444 }
2445 fn begin_annotation_appearance(&mut self, rect: kurbo::Rect) {
2446 self.annotation_appearances.push(rect);
2447 }
2448 fn end_annotation_appearance(&mut self) {
2449 self.annotation_appearances.pop();
2450 }
2451 fn push_clip_path(&mut self, clip_path: &ClipPath) {
2452 // The clip arrives in page space already -- `render.rs` applies it
2453 // under the identity transform -- so its box needs no transform here.
2454 // The box and not the region: a clip shaped like a star still reaches
2455 // only its own bounding box, so this over-states what is visible and
2456 // never under-states it, which is the direction that cannot invent a
2457 // rule.
2458 let box_of_clip = clip_path.path.bounding_box();
2459 let effective = match self.clip_stack.last() {
2460 Some(outer) => outer.intersect(box_of_clip),
2461 None => box_of_clip,
2462 };
2463 self.clip_stack.push(effective);
2464 }
2465 fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
2466 fn draw_image(&mut self, image: Image<'_, '_>, transform: Affine) {
2467 self.record_image(image.width(), image.height(), transform);
2468 if !self.actual_text_stack.is_empty() {
2469 let (w, h) = (
2470 f64::from(image.width().max(1)),
2471 f64::from(image.height().max(1)),
2472 );
2473 let mut drawn = transform.transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w, h));
2474 if let Some(clip) = self.clip_stack.last() {
2475 drawn = drawn.intersect(*clip);
2476 }
2477 self.note_drawn_box([drawn.x0, drawn.y0, drawn.x1, drawn.y1]);
2478 }
2479 }
2480 fn pop_clip_path(&mut self) {
2481 self.clip_stack.pop();
2482 }
2483 fn pop_transparency_group(&mut self) {}
2484 fn draw_placed_text(&mut self, text: &str, rect: kurbo::Rect) {
2485 self.push_placed_span(text, rect);
2486 }
2487
2488 fn draw_glyph(
2489 &mut self,
2490 glyph: &Glyph<'_>,
2491 transform: Affine,
2492 glyph_transform: Affine,
2493 paint: &Paint<'_>,
2494 draw_mode: &GlyphDrawMode,
2495 ) {
2496 // A glyph whose text an `/ActualText` replacement stands in for, or
2497 // that the page hides and the output may drop, cannot carry a mark
2498 // offered across it, so the glyph before it is not read again: the
2499 // offer is then refused and both read as before (spec review (b),
2500 // review 14-10 02:00 (ii)).
2501 let color = paint_to_rgba(paint);
2502 let glyph_hidden = matches!(draw_mode, GlyphDrawMode::Invisible)
2503 || color.is_some_and(|color| color[3] == 0);
2504 let replaced = self.actual_text
2505 && self
2506 .actual_text_stack
2507 .iter()
2508 .any(|entry| entry.actual_text.is_some());
2509 if replaced || glyph_hidden {
2510 self.last_glyph_text = None;
2511 } else {
2512 self.reread_last_glyph(glyph);
2513 }
2514 // Whatever this glyph does, the glyph before it is no longer the last
2515 // one, unless this is the stroke that repeats its fill.
2516 let before = self.last_glyph_text.take();
2517 // The glyph is read once: a read decides a placement offered to it
2518 // and every later read answers the same, so reading it again here
2519 // would only repeat the font's lookup.
2520 let unicode = glyph.as_unicode();
2521
2522 // FILL AND STROKE IS ONE GLYPH (#623). Render mode 2 draws each glyph
2523 // filled and then stroked -- how a heading is made heavier without a
2524 // bold face -- and the interpreter hands both to the device, since a
2525 // renderer needs both. Read twice, `1.0 BACKGROUND` came out as
2526 // `BBAACCKKGGRROOUUNNDD` (GAMOBUDU LANDFILL, DocLayNet validation) and
2527 // the heading matched nothing. The stroke that repeats the fill just
2528 // before it, at the same transform, is the same character.
2529 let placed = (transform * glyph_transform).as_coeffs();
2530 let repeats_fill = matches!(draw_mode, GlyphDrawMode::Stroke(_))
2531 && self
2532 .last_fill
2533 .as_ref()
2534 .is_some_and(|(at, filled)| *at == placed && *filled == unicode);
2535 self.last_fill = match draw_mode {
2536 GlyphDrawMode::Fill => Some((placed, unicode.clone())),
2537 _ => None,
2538 };
2539 if repeats_fill {
2540 self.last_glyph_text = before;
2541 return;
2542 }
2543
2544 let reads_as_nothing = unicode.is_none();
2545 let glyph_text = match unicode {
2546 Some(BfString::Char(c)) => c.to_string(),
2547 Some(BfString::String(s)) => s,
2548 None => String::new(),
2549 };
2550
2551 let composed = transform * glyph_transform;
2552 let coeffs = composed.as_coeffs();
2553 let x = coeffs[4];
2554 let y = coeffs[5];
2555 let glyph_scale = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt().abs();
2556 let draw_size = glyph_scale * 1000.0;
2557 // Under raw runs the size is the area scale of the text matrix, so a
2558 // squashed line reports what it is set in (#643): `Deceased` reads
2559 // 6.9296, the geometric mean of its 6.86 by 7 matrix, where the first
2560 // column alone gives 6.86. Widths keep the drawn scale, so boxes and
2561 // gaps never move; the default path reports the drawn scale, as
2562 // before.
2563 let font_size = if self.raw_runs {
2564 (coeffs[0] * coeffs[3] - coeffs[1] * coeffs[2]).abs().sqrt() * 1000.0
2565 } else {
2566 draw_size
2567 };
2568
2569 // G2: distinguish real advance (Metric) from estimate (Estimate).
2570 let (glyph_width, glyph_ws) = glyph_width_and_source(glyph, draw_size);
2571 let widened = match glyph {
2572 Glyph::Outline(outline) => outline.advance_width().map_or(0.0, |w| {
2573 (glyph_width - f64::from(w) / 1000.0 * font_size).max(0.0)
2574 }),
2575 Glyph::Type3(_) => 0.0,
2576 };
2577 let glyph_end_x = x + glyph_width;
2578 let glyph_bound = [x, y, glyph_end_x, y + font_size];
2579
2580 let style = derive_glyph_style(glyph, self.document_font_names);
2581
2582 // An `/ActualText` replacement reads instead of the glyphs of its
2583 // marked run, once, on the run's first glyph; the box of that glyph
2584 // stays whole for however many letters the replacement holds. The
2585 // innermost open sequence carrying one wins, and a run replaced by
2586 // nothing reads as nothing. Later glyphs of the same run add no
2587 // text and no box, but the pen still moves past what was drawn, so
2588 // the gap after the run is measured from where the run ended.
2589 let actual_idx = if self.actual_text {
2590 self.actual_text_stack
2591 .iter()
2592 .rposition(|e| e.actual_text.is_some())
2593 } else {
2594 None
2595 };
2596 let text = if let Some(idx) = actual_idx {
2597 let replacement = if self.actual_text_stack[idx].consumed {
2598 String::new()
2599 } else {
2600 let found = self.actual_text_stack[idx]
2601 .actual_text
2602 .clone()
2603 .unwrap_or_default();
2604 self.actual_text_stack[idx].consumed = true;
2605 found
2606 };
2607 if replacement.is_empty() {
2608 self.last_y = y;
2609 self.advance_pen(&glyph_text, glyph_end_x, widened);
2610 let adv_len = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
2611 let (adv_x, adv_y) = if adv_len > 0.0 {
2612 (coeffs[0] / adv_len, coeffs[1] / adv_len)
2613 } else {
2614 (1.0, 0.0)
2615 };
2616 self.advance_raw_pen(&glyph_text, x, y, adv_x, adv_y, glyph_width);
2617 self.pending_tj_offset = 0.0;
2618 return;
2619 }
2620 replacement
2621 } else {
2622 if reads_as_nothing {
2623 // Read again at the next glyph all the same: a mark drawn
2624 // before its base gives its offer up by being read.
2625 if let Glyph::Outline(outline) = glyph {
2626 let record = LastGlyphText {
2627 place: None,
2628 span: self.spans.len().saturating_sub(1),
2629 offset: 0,
2630 len: 0,
2631 boxless: true,
2632 opened: false,
2633 char_bounds: 0,
2634 width: 0.0,
2635 previous_complete_run: None,
2636 deferred: self.deferred_rich_glyphs.len(),
2637 span_count: 0,
2638 tail_text: 0,
2639 tail_boxes: 0,
2640 };
2641 self.remember_last_glyph(outline, record, glyph_hidden);
2642 }
2643 return;
2644 }
2645 // A glyph that reads as drawn: the open sequences already stand
2646 // in the text, so their close must not read a replacement too.
2647 // Its box is still a mark a glyphless close could read at.
2648 if !self.actual_text_stack.is_empty() {
2649 for open in self.actual_text_stack.iter_mut() {
2650 open.saw_glyph = true;
2651 }
2652 self.note_drawn_box(glyph_bound);
2653 }
2654 glyph_text
2655 };
2656 // The glyph's own text, which the next glyph may make it read
2657 // differently (see `LastGlyphText`); a replacement is not.
2658 let own_glyph = match glyph {
2659 Glyph::Outline(outline) if actual_idx.is_none() => Some(outline),
2660 _ => None,
2661 };
2662
2663 // M3: record glyph data for deferred tight-bounds computation.
2664 let deferred_before = self.deferred_rich_glyphs.len();
2665 if self.geometry_mode == GeometryMode::RichGeometry {
2666 self.record_deferred_glyph(glyph, &composed, font_size, glyph_width);
2667 }
2668
2669 // ANN[r17/TEX4] Feed the running sample used to derive the adaptive
2670 // median character width. Capped to protect against pathological
2671 // pages with hundreds of thousands of glyphs.
2672 if self.glyph_widths.len() < 4096 {
2673 self.glyph_widths.push(glyph_width);
2674 if self.glyph_widths.len().is_multiple_of(MEDIAN_REFRESH) {
2675 self.refresh_median_char_width();
2676 }
2677 }
2678
2679 // The gap is what the page drew between this glyph and the last one,
2680 // judged on the pen the last glyph left. The pen moves past this glyph
2681 // only after the merge decision -- `advance_pen` on every path below --
2682 // so the gap reads the previous end, and `last_widened` still carries
2683 // the previous glyph's floor for the tracking read. Advancing here left
2684 // every glyph a span of its own and the tracking dead (#623 family).
2685 let gap = x - self.last_end_x;
2686 let same_line = (y - self.last_y).abs() <= font_size.max(BAND_Y_TOLERANCE) * 0.35;
2687 // Under raw runs a turned glyph joins its run along the way it reads
2688 // (#643): `Form` is one show of four glyphs reading down, and judged
2689 // on x and y each glyph stands alone. The gap runs along the way this
2690 // glyph reads and the same-line band across it, off the pen the last
2691 // glyph left. Two horizontal glyphs keep the judgment above, exactly
2692 // as before.
2693 let adv_len = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
2694 let (adv_x, adv_y) = if adv_len > 0.0 {
2695 (coeffs[0] / adv_len, coeffs[1] / adv_len)
2696 } else {
2697 (1.0, 0.0)
2698 };
2699 let vector = self.raw_runs
2700 && self.raw_pen_valid
2701 && (adv_y.abs() > adv_x.abs() || self.raw_dir[1].abs() > self.raw_dir[0].abs());
2702 let (same_line, gap, dir_changed) = if vector {
2703 let along = (x - self.raw_pen[0]) * adv_x + (y - self.raw_pen[1]) * adv_y;
2704 let across_x = (x - self.raw_pen[0]) - along * adv_x;
2705 let across_y = (y - self.raw_pen[1]) - along * adv_y;
2706 let across = (across_x.powi(2) + across_y.powi(2)).sqrt();
2707 let aligned = adv_x * self.raw_dir[0] + adv_y * self.raw_dir[1] > 0.999;
2708 (aligned && across <= font_size * 0.35, along, !aligned)
2709 } else {
2710 (same_line, gap, false)
2711 };
2712 let adjacent = same_line && gap >= -font_size * 0.25 && gap < font_size * 0.5;
2713
2714 // G1: only merge into the previous span when font + style + color
2715 // match. Otherwise the editor's style toolbar would render the wrong
2716 // state for the cursor position.
2717 //
2718 // A span is one font at one size and style: where the caller asks
2719 // for the document's names, the reported name parts a span even
2720 // where the merge face does not. Two document fonts can stand on one
2721 // face, and merged the second font's name and descriptor metrics are
2722 // lost with it (#636). Words still join across the split at word
2723 // level. The default path keeps merging on the face alone, as it
2724 // did: a program that names no face still reads under the page's
2725 // name (#574).
2726 let same_font = !self.document_font_names
2727 || self
2728 .spans
2729 .last()
2730 .is_some_and(|last| last.font_name == style.font_name);
2731 let style_matches = self
2732 .spans
2733 .last()
2734 .map(|last| {
2735 self.last_merge_face == style.merge_face
2736 && same_font
2737 && last.is_bold == style.is_bold
2738 && last.is_italic == style.is_italic
2739 && last.color == color
2740 })
2741 .unwrap_or(false);
2742 // Under raw runs a run ends where the type size changes: `MM` at 7pt
2743 // and the space at 4.95pt the page sets between it and `DD` are one
2744 // line but three runs (#643). The default path merges across sizes, as
2745 // before.
2746 let same_size = !self.raw_runs
2747 || self.spans.last().is_some_and(|last| {
2748 let big = last.font_size.max(font_size).max(1.0);
2749 (last.font_size - font_size).abs() <= RAW_SIZE_TOLERANCE * big
2750 });
2751 // A segment that opened or closed since the last glyph ends the run,
2752 // so a run never crosses the segment it was drawn in (#643). Only
2753 // ever set under raw runs; read and cleared here, on every glyph.
2754 let bdc_changed = std::mem::replace(&mut self.raw_bdc_changed, false);
2755 // Under raw runs a run ends where the page's declared face changes,
2756 // for the reason `last_raw_declared` gives (#643). A glyph the page
2757 // named nothing for never parts a run on its own.
2758 let declared = if self.raw_runs {
2759 match glyph {
2760 Glyph::Outline(outline) => outline
2761 .declared_name()
2762 .map(|d| strip_subset_prefix(&d).to_string()),
2763 Glyph::Type3(_) => None,
2764 }
2765 } else {
2766 None
2767 };
2768 let same_declared = !self.raw_runs
2769 || match (&self.last_raw_declared, &declared) {
2770 (Some(before), Some(now)) => before == now,
2771 _ => true,
2772 };
2773
2774 if adjacent
2775 && !self.spans.is_empty()
2776 && style_matches
2777 && same_size
2778 && same_declared
2779 && !bdc_changed
2780 && !dir_changed
2781 {
2782 // ANN[r17/TEX1] Multi-signal consensus replaces the prior
2783 // single-threshold rule (`gap > 0.15 * font_size`). The
2784 // consensus evaluates TJ offset, geometric gap, and
2785 // character-class transitions; a space is inserted only
2786 // when the weighted sum meets SPACE_CONSENSUS_THRESHOLD.
2787 // Decision is computed before the mutable borrow of `last`
2788 // to keep the borrow checker happy.
2789 // AND THE TYPESETTER'S TRACKING IS NOT A WORD SPACE (#582). The
2790 // consensus fires on every pair of a letter-spaced display line,
2791 // so the regularity of the run is asked about before its answer is
2792 // believed.
2793 //
2794 // BETWEEN TWO LETTERS THE RUN IS JUDGED ON THE GAP THE PAGE DREW
2795 // (#623). Two things make a tracked run's gaps disagree that are not
2796 // the tracking: the quarter-em floor of `glyph_width_and_source`
2797 // widens a narrow glyph's box, so the gap after every `I` of a
2798 // tracked `DIRECTORS’ DECLARATION` (OTC_STKAF_2002) read half the
2799 // tracking; and a pair kern in the TJ -- `[(ORPORA) 74 (TION)] TJ`
2800 // in NYSE_PVA_2004 -- takes 40 per cent off one gap. Either broke
2801 // the regularity for the next four pairs, and the run ended
2802 // `CORPORAT I O N`. Both are put back. A kern is a POSITIVE TJ, of
2803 // any size, since it moves the next glyph left: `Twenty-five`
2804 // tightens its `Tw` by 1.3 points. A small negative one is the
2805 // tracking itself -- NASDAQ_UCBI_2014 letter-spaces `CONSOLIDATED`
2806 // with `-80` between every pair -- and stays in the gap. Only
2807 // between letters and drawn spaces: a formula's commas and dots are
2808 // floored too, and put back there they make `{1, ..., n}` as
2809 // regular as tracking (1201.4397).
2810 let prev_char = self.spans.last().and_then(|last| last.text.chars().last());
2811 let next_char = text.chars().next();
2812 let letters = prev_char.is_some_and(char::is_alphabetic)
2813 && next_char.is_some_and(char::is_alphabetic);
2814 // A drawn space in a tracked line is floored and kerned like its
2815 // letters: `BACK TO` (NYSE_WAL_2014) kerns the `T` after its space
2816 // by 25, and left raw that one gap kept the run from being seen.
2817 let letter_or_space =
2818 |c: Option<char>| c.is_some_and(|c| c.is_alphabetic() || c == ' ');
2819 let drawn_gap = if letter_or_space(prev_char) && letter_or_space(next_char) {
2820 let kern = f64::from(self.pending_tj_offset.max(0.0)) / 1000.0 * font_size;
2821 gap + self.last_widened + kern
2822 } else {
2823 gap
2824 };
2825 let tracked = self.a_tracked_run(drawn_gap);
2826 let want_space = {
2827 let last = self.spans.last().expect("checked non-empty");
2828 !last.text.ends_with(' ')
2829 && !text.starts_with(' ')
2830 && !tracked
2831 && self.evaluate_space_consensus(gap, font_size, &last.text, &text)
2832 };
2833 // EVERY GAP, and not only the ones wide enough to be a space.
2834 // Filtering to the wide ones leaves a run of word spaces, which
2835 // agree with one another as neatly as tracking does -- that is how
2836 // `in a healthy` came back as `in ahealthy`. What tells the two
2837 // apart is the gaps INSIDE the words: tracking adds its constant
2838 // to those as well, and prose does not.
2839 //
2840 // A gap that breaks the regularity ends one run and the next
2841 // starts after it, and a take-back never reaches back past that
2842 // start: the gap itself is the boundary, so the word space before
2843 // a letter-spaced `C O M B I N E` stays (NYSE_GLW_2004). A span's
2844 // first gap has nothing to break, and it is the run's own. Only a
2845 // gap between two letters breaks a run, measured against the last
2846 // gap between two letters: a drawn space glyph sits a little off
2847 // the tracking on both sides, and a break there left `I T ALL`
2848 // (NYSE_WAL_2014) and `D ECLARATION` (OTC_STKAF_2002).
2849 if letters {
2850 if self.last_letter_gap.is_some_and(|before| {
2851 (before - drawn_gap).abs() > TRACKING_TOLERANCE * drawn_gap
2852 }) {
2853 self.run_start = self.spans.last().map_or(0, |last| last.text.len() + 1);
2854 }
2855 self.last_letter_gap = Some(drawn_gap);
2856 }
2857 self.recent_gaps.push(drawn_gap);
2858 if self.recent_gaps.len() > TRACKING_MIN_PAIRS * 2 {
2859 self.recent_gaps.remove(0);
2860 }
2861 let span_index = self.spans.len() - 1;
2862 let last = self.spans.last_mut().expect("checked non-empty");
2863 if tracked {
2864 Self::take_back_tracked_spaces(
2865 &mut last.text,
2866 &mut self.inserted_spaces,
2867 self.run_start,
2868 drawn_gap,
2869 );
2870 }
2871 if want_space {
2872 self.inserted_spaces.push((last.text.len(), drawn_gap));
2873 last.text.push(' ');
2874 }
2875 let offset = last.text.len();
2876 let char_bounds = last.char_bounds.len();
2877 let width = last.width;
2878 last.text.push_str(&text);
2879 last.width = last.width.max(glyph_end_x - last.x);
2880 last.height = last.height.max(font_size);
2881 // G2: append char_bound; downgrade width_source if this glyph is Estimate.
2882 last.char_bounds.push(glyph_bound);
2883 if glyph_ws == WidthSource::Estimate {
2884 last.width_source = WidthSource::Estimate;
2885 }
2886 if let Some(outline) = own_glyph {
2887 let record = LastGlyphText {
2888 place: None,
2889 span: span_index,
2890 offset,
2891 len: text.len(),
2892 boxless: false,
2893 opened: false,
2894 char_bounds,
2895 width,
2896 previous_complete_run: None,
2897 deferred: deferred_before,
2898 span_count: 0,
2899 tail_text: 0,
2900 tail_boxes: 0,
2901 };
2902 self.remember_last_glyph(outline, record, glyph_hidden);
2903 }
2904 self.last_y = y;
2905 self.advance_pen(&text, glyph_end_x, widened);
2906 self.advance_raw_pen(&text, x, y, adv_x, adv_y, glyph_width);
2907 // ANN[r17/TEX1] Consume the TJ signal: it only counts for
2908 // the one merge it preceded.
2909 self.pending_tj_offset = 0.0;
2910 return;
2911 }
2912
2913 self.last_y = y;
2914 self.advance_pen(&text, glyph_end_x, widened);
2915 self.advance_raw_pen(&text, x, y, adv_x, adv_y, glyph_width);
2916 // ANN[r17/TEX1] Non-adjacent glyph starts a fresh span, so any
2917 // pending TJ offset is about within-span word breaks and no longer
2918 // meaningful here.
2919 self.pending_tj_offset = 0.0;
2920 // AND A NEW SPAN IS A NEW RUN (#582). The gaps belong to the run they
2921 // were measured in; carried across, the first gap of a new line is
2922 // judged against the last line's tracking, and a line that opens with
2923 // a one-letter word loses its space -- `A report` as `Areport`. The
2924 // placed-text path clears this for the same reason.
2925 self.recent_gaps.clear();
2926 self.inserted_spaces.clear();
2927 self.run_start = 0;
2928 self.last_letter_gap = None;
2929
2930 let span = TextSpan {
2931 text,
2932 x,
2933 y,
2934 width: glyph_width,
2935 height: font_size,
2936 font_size,
2937 font_name: style.font_name.clone(),
2938 is_bold: style.is_bold,
2939 is_italic: style.is_italic,
2940 color,
2941 width_source: glyph_ws,
2942 char_bounds: vec![glyph_bound],
2943 transform: Some(coeffs),
2944 font_weight: style.font_weight,
2945 is_serif: style.is_serif,
2946 is_monospace: style.is_monospace,
2947 render_mode: Some(render_mode_from_draw_mode(draw_mode)),
2948 is_ocr_layer: style.is_ocr_layer,
2949 font_metrics: style.font_metrics,
2950 geometry_mode: self.geometry_mode,
2951 bounds_source: BoundsSource::Advance,
2952 tight_char_bounds: Vec::new(),
2953 glyph_advances: Vec::new(),
2954 glyph_bounds_sources: Vec::new(),
2955 complete_run: false,
2956 };
2957
2958 self.last_merge_face = style.merge_face;
2959 let previous_complete_run = self.spans.last().map(|prev| prev.complete_run);
2960 if self.raw_runs {
2961 // A run ends where the style, the size, the declared face or the
2962 // reading direction does; a segment or a text object alone never
2963 // ends one (`4a` reads as one span across two segments, and the
2964 // foreign-address fields merge across text objects), and neither
2965 // does a purely geometric part -- a gap or a backward step inside
2966 // one show -- so a consumer joining same-style spans may still
2967 // merge there (#643).
2968 let run_end = !style_matches || !same_size || !same_declared || dir_changed;
2969 if let Some(prev) = self.spans.last_mut() {
2970 prev.complete_run = run_end;
2971 }
2972 self.raw_bdc.push(self.raw_bdc_stack.last().copied());
2973 self.last_raw_declared = declared;
2974 }
2975 let record = own_glyph.map(|outline| {
2976 let record = LastGlyphText {
2977 place: None,
2978 span: self.spans.len(),
2979 offset: 0,
2980 len: span.text.len(),
2981 boxless: false,
2982 opened: true,
2983 char_bounds: 0,
2984 width: 0.0,
2985 previous_complete_run,
2986 deferred: deferred_before,
2987 span_count: 0,
2988 tail_text: 0,
2989 tail_boxes: 0,
2990 };
2991 (outline, record)
2992 });
2993 self.spans.push(span);
2994 if let Some((outline, record)) = record {
2995 self.remember_last_glyph(outline, record, glyph_hidden);
2996 }
2997 }
2998
2999 // ANN[r17/TEX1] Record TJ offsets. Accumulate because a single
3000 // inter-substring gap may be expressed as multiple numeric entries
3001 // (rare, but legal per PDF §9.4.3). The next draw_glyph consumes
3002 // the sum.
3003 fn text_adjustment(&mut self, amount: f32) {
3004 self.pending_tj_offset += amount;
3005 }
3006}
3007
3008/// Style metadata derived from a `Glyph` for the G1 text-run extension.
3009#[derive(Debug, Default, Clone)]
3010struct GlyphStyle {
3011 font_name: Option<String>,
3012 /// The face spans merge on: `font_name` as the program names it, without
3013 /// the /BaseFont fallback (see `last_merge_face`).
3014 merge_face: Option<String>,
3015 is_bold: bool,
3016 is_italic: bool,
3017 /// Numeric weight from embedded font data, if available.
3018 font_weight: Option<u16>,
3019 /// Serif flag from embedded font data, if available.
3020 is_serif: Option<bool>,
3021 /// Monospace flag from embedded font data, if available.
3022 is_monospace: Option<bool>,
3023 /// Whether the glyph was set in the engine's own OCR layer resource.
3024 is_ocr_layer: bool,
3025 /// Vertical font metrics from embedded font data, if available.
3026 font_metrics: Option<FontMetrics>,
3027}
3028
3029/// Strip a 6-character subset prefix (e.g. `AAAAAA+Helvetica` → `Helvetica`).
3030fn strip_subset_prefix(name: &str) -> &str {
3031 match name.split_once('+') {
3032 Some((prefix, rest)) if prefix.len() == 6 => rest,
3033 _ => name,
3034 }
3035}
3036
3037/// Heuristic style inference from a PostScript name when no descriptor
3038/// flags are reachable. Matches the same rules `pdf-interpret` uses in
3039/// `FallbackFontQuery::new`.
3040fn name_style_hints(name: &str) -> (bool, bool) {
3041 let lower = name.to_ascii_lowercase();
3042 let italic = lower.contains("italic") || lower.contains("oblique") || lower.contains("slant");
3043 let bold = lower.contains("bold")
3044 || lower.contains("demi")
3045 || lower.contains("semibold")
3046 || lower.contains("heavy")
3047 || lower.contains("black");
3048 (bold, italic)
3049}
3050
3051/// Whether a font name says monospace, for faces whose program is out of
3052/// reach (standard-14 and other non-embedded fonts).
3053///
3054/// Lowercased, spaceless substring match over known monospace families, the
3055/// way [`name_style_hints`] reads bold and italic. `Some(true)` on a hit,
3056/// `None` otherwise: a name that says nothing keeps the unknown it had. A
3057/// bare "mono" is never matched: decorative faces carry it too.
3058fn name_monospace_hint(name: &str) -> Option<bool> {
3059 let squashed: String = name
3060 .to_ascii_lowercase()
3061 .chars()
3062 .filter(|c| c.is_ascii_alphanumeric())
3063 .collect();
3064 const MONOSPACE_FAMILIES: &[&str] = &[
3065 "courier",
3066 "monaco",
3067 "menlo",
3068 "consolas",
3069 "liberationmono",
3070 "dejavusansmono",
3071 "notosansmono",
3072 "sfmono",
3073 "ubuntumono",
3074 "robotomono",
3075 "ptmono",
3076 "droidsansmono",
3077 "cascadiacode",
3078 "cascadiamono",
3079 "firacode",
3080 "firamono",
3081 "inconsolata",
3082 "jetbrainsmono",
3083 "sourcecodepro",
3084 "lucidaconsole",
3085 "nimbusmono",
3086 "texgyrecursor",
3087 "andalemono",
3088 "lettergothic",
3089 "prestigeelite",
3090 "anonymouspro",
3091 "oxygenmono",
3092 "overpassmono",
3093 "plexmono",
3094 "spacemono",
3095 "sharetechmono",
3096 "cutivemono",
3097 "novamono",
3098 "fixedsys",
3099 "typewriter",
3100 "monospace",
3101 ];
3102 if MONOSPACE_FAMILIES
3103 .iter()
3104 .any(|family| squashed.contains(family))
3105 {
3106 Some(true)
3107 } else {
3108 None
3109 }
3110}
3111
3112fn derive_glyph_style(glyph: &Glyph<'_>, document_font_names: bool) -> GlyphStyle {
3113 let mut style = glyph_style_from_font(glyph, document_font_names);
3114 // THE ENGINE'S OWN VOICE. Text set in the OCR layer resource is the
3115 // layer the engine itself wrote, not ink the author hid: the hidden-text
3116 // drop keeps it. The resource name is the only mark that survives, since
3117 // the layer stands on Helvetica like any author text.
3118 style.is_ocr_layer = glyph
3119 .font_resource_name()
3120 .is_some_and(|name| name.as_bytes() == pdf_ocr::pipeline::OCR_FONT_NAME);
3121 // THE PAGE'S NAME, BESIDE THE FACE'S (#623). A font that stands on a
3122 // standard face -- an embedded CFF or Type1 whose program the renderer
3123 // replaced, a named face that is not embedded -- reaches here as that
3124 // face: `RotisSansSerif-Light`, its regular and its `-Bold` were all
3125 // `Helvetica`, and a `-Bold` or `-Italic` the page asked for was lost.
3126 // `font_name` stays the face's, because span merging compares it and a
3127 // formula's faces must keep merging as they did; bold and italic read
3128 // the page's name as well.
3129 if let Glyph::Outline(outline) = glyph {
3130 if let Some(raw) = outline.declared_name() {
3131 let declared = strip_subset_prefix(&raw).to_string();
3132 if !declared.is_empty() && style.font_name.as_deref() != Some(declared.as_str()) {
3133 let (bold, italic) = name_style_hints(&declared);
3134 style.is_bold |= bold;
3135 style.is_italic |= italic;
3136 style.is_monospace = style.is_monospace.or(name_monospace_hint(&declared));
3137 }
3138 }
3139 }
3140 style
3141}
3142
3143fn glyph_style_from_font(glyph: &Glyph<'_>, document_font_names: bool) -> GlyphStyle {
3144 match glyph {
3145 Glyph::Outline(outline) => {
3146 if let Some(data) = outline.font_data() {
3147 let raw = data.postscript_name.as_deref().unwrap_or("");
3148 let name = strip_subset_prefix(raw).to_string();
3149 let weight_bold = data.weight.is_some_and(|w| w >= 700);
3150 let (name_bold, name_italic) = name_style_hints(&name);
3151 let font_name = if name.is_empty() { None } else { Some(name) };
3152 GlyphStyle {
3153 merge_face: font_name.clone(),
3154 font_name,
3155 is_bold: weight_bold || name_bold,
3156 is_italic: data.is_italic || name_italic,
3157 font_weight: data.weight.map(|w| w.clamp(1, 1000) as u16),
3158 is_serif: Some(data.is_serif),
3159 is_monospace: Some(data.is_monospace),
3160 // Read back in `derive_glyph_style`, the only caller.
3161 is_ocr_layer: false,
3162 font_metrics: match (data.ascent, data.descent) {
3163 (Some(ascent), Some(descent)) => Some(FontMetrics {
3164 ascent,
3165 descent,
3166 cap_height: data.cap_height,
3167 x_height: data.x_height,
3168 }),
3169 _ => None,
3170 },
3171 }
3172 } else if document_font_names {
3173 // Type1 / non-embedded font — descriptor not surfaced
3174 // via font_data(). Fall back to the name-only
3175 // accessor which works for standard-14 fallbacks,
3176 // and use the canonical Standard-14 AFM metrics
3177 // when the outline font resolves to a known face.
3178 //
3179 // THE MERGE KEY STAYS THE FACE'S, THE REPORTED NAME IS THE
3180 // PAGE'S (#643 E3). Spans merge on the face the program
3181 // names (or the substitute it stands on), exactly as they
3182 // did (#574); `font_name` is the name the page set its
3183 // text in. Style reads both, as it did (#623): a `Bd` the
3184 // page names is bold because the face says `Bold`, and a
3185 // `Bliss-LightItalic` no face names is italic because the
3186 // page says so.
3187 let face =
3188 strip_subset_prefix(&outline.postscript_name().unwrap_or_default()).to_string();
3189 let merge_face = if face.is_empty() {
3190 None
3191 } else {
3192 Some(face.clone())
3193 };
3194 let raw = outline.document_name().unwrap_or_default();
3195 let name = strip_subset_prefix(&raw).to_string();
3196 // AND WHERE THE PROGRAM NAMES NOTHING, THE PAGE'S NAME (#574).
3197 // An embedded Type1 or CFF reaches here with no descriptor and,
3198 // unless it stands on a standard face, no PostScript name: every
3199 // span of `DIN-Medium`, `CMR12`, `MetalworkBold` had no face at
3200 // all -- five of the rig-like proxy's 36 documents, 105 of its
3201 // 389 pages, on which `font_is_the_body_face` could say nothing.
3202 // A Type1 always declares a /BaseFont. Spans still merge on the
3203 // program's name (`merge_face`).
3204 let name = if name.is_empty() {
3205 outline
3206 .declared_name()
3207 .map(|d| strip_subset_prefix(&d).to_string())
3208 .unwrap_or_default()
3209 } else {
3210 name
3211 };
3212 let (name_bold, name_italic) = name_style_hints(&name);
3213 let (face_bold, face_italic) = name_style_hints(&face);
3214 let metrics = outline.font_metrics().map(|(a, d, c, x)| FontMetrics {
3215 ascent: a,
3216 descent: d,
3217 cap_height: c,
3218 x_height: x,
3219 });
3220 GlyphStyle {
3221 font_name: if name.is_empty() { None } else { Some(name) },
3222 merge_face,
3223 is_bold: name_bold || face_bold,
3224 is_italic: name_italic || face_italic,
3225 // Non-embedded font: descriptor metrics are not reachable.
3226 font_weight: None,
3227 is_serif: None,
3228 is_monospace: None,
3229 // Read back in `derive_glyph_style`, the only caller.
3230 is_ocr_layer: false,
3231 font_metrics: metrics,
3232 }
3233 } else {
3234 // THE DEFAULT ANSWERS AS cc2f37b DID (#643 follow-up). The
3235 // option is off: the substitute face's name and AFM pair,
3236 // style hints from that name alone -- the merge key and
3237 // `font_is_the_body_face` see exactly what they saw before.
3238 // `substitute_metrics` is the pre-E4 metrics source under
3239 // its new name; the rest is verbatim.
3240 let raw = outline.postscript_name().unwrap_or_default();
3241 let name = strip_subset_prefix(&raw).to_string();
3242 let merge_face = if name.is_empty() {
3243 None
3244 } else {
3245 Some(name.clone())
3246 };
3247 // AND WHERE THE PROGRAM NAMES NOTHING, THE PAGE'S NAME (#574).
3248 // An embedded Type1 or CFF reaches here with no descriptor and,
3249 // unless it stands on a standard face, no PostScript name: every
3250 // span of `DIN-Medium`, `CMR12`, `MetalworkBold` had no face at
3251 // all -- five of the rig-like proxy's 36 documents, 105 of its
3252 // 389 pages, on which `font_is_the_body_face` could say nothing.
3253 // A Type1 always declares a /BaseFont. Spans still merge on the
3254 // program's name (`merge_face`).
3255 let name = if name.is_empty() {
3256 outline
3257 .declared_name()
3258 .map(|d| strip_subset_prefix(&d).to_string())
3259 .unwrap_or_default()
3260 } else {
3261 name
3262 };
3263 let (name_bold, name_italic) = name_style_hints(&name);
3264 let metrics = outline
3265 .substitute_metrics()
3266 .map(|(a, d, c, x)| FontMetrics {
3267 ascent: a,
3268 descent: d,
3269 cap_height: c,
3270 x_height: x,
3271 });
3272 let monospace = name_monospace_hint(&name);
3273 GlyphStyle {
3274 font_name: if name.is_empty() { None } else { Some(name) },
3275 merge_face,
3276 is_bold: name_bold,
3277 is_italic: name_italic,
3278 // Non-embedded font: descriptor metrics are not reachable.
3279 font_weight: None,
3280 is_serif: None,
3281 is_monospace: monospace,
3282 // Read back in `derive_glyph_style`, the only caller.
3283 is_ocr_layer: false,
3284 font_metrics: metrics,
3285 }
3286 }
3287 }
3288 Glyph::Type3(_) => GlyphStyle::default(),
3289 }
3290}
3291
3292fn paint_to_rgba(paint: &Paint<'_>) -> Option<[u8; 4]> {
3293 match paint {
3294 Paint::Color(c) => Some(c.to_rgba().to_rgba8()),
3295 Paint::Pattern(_) => None,
3296 }
3297}
3298
3299/// Map the renderer's coarse glyph draw mode to a PDF text render-mode code.
3300///
3301/// NOTE: the interpreter collapses the eight PDF text render modes (Tr 0–7)
3302/// into three visibility classes, so only `{0, 1, 3}` are representable here —
3303/// fill (0), stroke (1), and invisible (3). The fill+stroke and clip modes
3304/// (2, 4–7) are not distinguished. This is sufficient to identify and filter
3305/// invisible (mode-3) OCR/overlay text; recovering the full `Tr` range would
3306/// require threading it through the `Device` trait (out of scope).
3307fn render_mode_from_draw_mode(mode: &GlyphDrawMode) -> u8 {
3308 match mode {
3309 GlyphDrawMode::Fill => 0,
3310 GlyphDrawMode::Stroke(_) => 1,
3311 GlyphDrawMode::Invisible => 3,
3312 }
3313}
3314
3315#[cfg(test)]
3316mod render_mode_tests {
3317 use super::render_mode_from_draw_mode;
3318 use pdf_render::pdf_interpret::{GlyphDrawMode, StrokeProps};
3319
3320 #[test]
3321 fn render_mode_is_only_zero_one_three() {
3322 assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Fill), 0);
3323 assert_eq!(
3324 render_mode_from_draw_mode(&GlyphDrawMode::Stroke(StrokeProps::default())),
3325 1
3326 );
3327 assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Invisible), 3);
3328 // Exhaustive: the mapping only ever yields 0, 1, or 3 — never 2 or 4–7.
3329 for m in [
3330 GlyphDrawMode::Fill,
3331 GlyphDrawMode::Stroke(StrokeProps::default()),
3332 GlyphDrawMode::Invisible,
3333 ] {
3334 assert!(matches!(render_mode_from_draw_mode(&m), 0 | 1 | 3));
3335 }
3336 }
3337}
3338
3339/// Returns `(advance_in_user_space, WidthSource)` for a glyph.
3340///
3341/// Uses the real advance from `OutlineGlyph::advance_width()` when available
3342/// (returns `WidthSource::Metric`); falls back to 50% em otherwise
3343/// (`WidthSource::Estimate`). The result is clamped to at least 25% em so
3344/// invisible-glyph outliers do not collapse spans.
3345fn glyph_width_and_source(glyph: &Glyph<'_>, font_size: f64) -> (f64, WidthSource) {
3346 match glyph {
3347 Glyph::Outline(outline) => {
3348 if let Some(w) = outline.advance_width() {
3349 let advance = (w as f64 / 1000.0 * font_size).max(font_size * 0.25);
3350 (advance, WidthSource::Metric)
3351 } else {
3352 (font_size * 0.5, WidthSource::Estimate)
3353 }
3354 }
3355 Glyph::Type3(_) => (font_size * 0.5, WidthSource::Estimate),
3356 }
3357}
3358
3359/// Two baselines run in the same direction when they are within half a degree
3360/// of each other. A page draws a run of text with one text matrix, so the
3361/// glyphs of one run agree to the last bit; the tolerance is there for the
3362/// rounding a document writes its matrix with (`0.7071 -0.7071`), not for
3363/// telling two angles apart.
3364const DIRECTION_TOLERANCE_RAD: f64 = 0.008_727; // 0.5 degrees
3365
3366/// How far a baseline may stand from a quarter turn and still read as one:
3367/// ten degrees (#574). The defect's runs stand at a quarter or half turn; a
3368/// typeset line that merely drifts -- the 3-degree skew the guard test pins --
3369/// stays well inside this, and so does the rounding a document writes its
3370/// matrix with.
3371const QUARTER_TURN_TOLERANCE_RAD: f64 = 0.174_533; // 10 degrees
3372
3373/// Whether the span's baseline stands at a quarter turn to the page: within
3374/// ten degrees of 90, 180 or 270 degrees (#574). The defect is a
3375/// quarter-turned run read in page space -- six reversed runs on the tuning
3376/// pages, all at 90 degrees -- and only such a run is ever re-grouped. A
3377/// free-angle region already reads forwards and must not be: merging a
3378/// 60-degree region re-groups text that was never broken (OTC_STKAF p-c3c8:
3379/// spans 197 -> 31, text -0.107). Upright text and small skews are not
3380/// quarter-turned, and neither is anything on a majority-turned page, which
3381/// [`PageTurn::into_frame`] lays upright before this is ever asked.
3382fn span_is_quarter_turned(span: &TextSpan) -> bool {
3383 let direction = span_direction(span);
3384 direction_gap(direction, (0.0, 1.0)) <= QUARTER_TURN_TOLERANCE_RAD
3385 || direction_gap(direction, (-1.0, 0.0)) <= QUARTER_TURN_TOLERANCE_RAD
3386 || direction_gap(direction, (0.0, -1.0)) <= QUARTER_TURN_TOLERANCE_RAD
3387}
3388
3389/// A baseline direction: a unit vector in page space.
3390type Direction = (f64, f64);
3391
3392/// A span taken out of the page's blocks, carrying the position in the page's
3393/// order that the block it came from held.
3394type PlacedSpan = (usize, TextSpan);
3395
3396/// The direction the baseline of a glyph runs in, as a unit vector in page
3397/// space: the first column of its composed transform, normalised.
3398///
3399/// This is what the text matrix rotates. `(1, 0)` for upright text, `(0, 1)`
3400/// for a page's 90-degree marginalia, `(0.7071, -0.7071)` for the diagonal
3401/// watermark of pdfjs issue17069 -- and `(1, 0)` again for a degenerate
3402/// transform, which has no direction to speak of and may as well read as the
3403/// page's own (#435).
3404fn baseline_direction(coeffs: &[f64; 6]) -> Direction {
3405 let (a, b) = (coeffs[0], coeffs[1]);
3406 let length = (a * a + b * b).sqrt();
3407 if length <= f64::EPSILON {
3408 (1.0, 0.0)
3409 } else {
3410 (a / length, b / length)
3411 }
3412}
3413
3414/// The direction a span's baseline runs in. A span not built from a glyph
3415/// carries no transform and reads as upright.
3416fn span_direction(span: &TextSpan) -> Direction {
3417 span.transform
3418 .as_ref()
3419 .map(baseline_direction)
3420 .unwrap_or((1.0, 0.0))
3421}
3422
3423/// The angle between two unit vectors, in radians, without a sign.
3424fn direction_gap(lhs: Direction, rhs: Direction) -> f64 {
3425 let cross = lhs.0 * rhs.1 - lhs.1 * rhs.0;
3426 let dot = lhs.0 * rhs.0 + lhs.1 * rhs.1;
3427 cross.atan2(dot).abs()
3428}
3429
3430fn directions_agree(lhs: Direction, rhs: Direction) -> bool {
3431 direction_gap(lhs, rhs) <= DIRECTION_TOLERANCE_RAD
3432}
3433
3434/// How far along its own baseline, and how far across it, a span's origin sits.
3435///
3436/// The pair is the page's `(x, y)` seen from the baseline: `along` grows in
3437/// reading direction, `across` grows to the left of it, so a line of rotated
3438/// text has one `across` and a range of `along` exactly as an upright line has
3439/// one `y` and a range of `x`.
3440fn baseline_coordinates(span: &TextSpan, direction: Direction) -> (f64, f64) {
3441 (
3442 span.x * direction.0 + span.y * direction.1,
3443 -span.x * direction.1 + span.y * direction.0,
3444 )
3445}
3446
3447/// Where a span stops along its own baseline.
3448///
3449/// `width` is the widest a glyph of the span reached in PAGE x, which is the
3450/// along-baseline extent only while the text is upright: a run drawn at thirty
3451/// degrees advances `cos 30` of its length in x, and reading that as the length
3452/// leaves an eighth of the run behind it as phantom gap -- enough to put a
3453/// space in the middle of a word where a style change ends one span and starts
3454/// the next. So the last glyph's own bound says where the run stopped, and
3455/// `width` answers only when there are no bounds to ask (every hand-built span)
3456/// or when it reaches further, which is what an overprinted or backwards-drawn
3457/// run does and what the seam allowance of #432 is measured against (#435).
3458fn baseline_end(span: &TextSpan, direction: Direction) -> f64 {
3459 let start = baseline_coordinates(span, direction).0;
3460 let from_width = start + span.width;
3461 match span.char_bounds.last() {
3462 Some(bound) => {
3463 let last = bound[0] * direction.0 + bound[1] * direction.1;
3464 let advance = bound[2] - bound[0];
3465 (last + advance).max(from_width)
3466 }
3467 None => from_width,
3468 }
3469}
3470
3471/// Threshold for detecting rotation or shear in the composed transform.
3472/// Any |b| or |c| above this is treated as rotation; any difference between
3473/// a and d above this is treated as non-uniform scale (= shear potential).
3474const ROTATION_EPSILON: f64 = 1e-6;
3475const SHEAR_EPSILON: f64 = 1e-3;
3476
3477/// Transform a bounding-box `[x0,y0,x1,y1]` (in local space) to page space
3478/// by applying the affine to its four corners and taking the envelope.
3479fn transform_bbox_corners(local_bbox: &[f64; 4], affine: &Affine) -> [f64; 4] {
3480 use kurbo::Point;
3481 let corners = [
3482 *affine * Point::new(local_bbox[0], local_bbox[1]),
3483 *affine * Point::new(local_bbox[2], local_bbox[1]),
3484 *affine * Point::new(local_bbox[2], local_bbox[3]),
3485 *affine * Point::new(local_bbox[0], local_bbox[3]),
3486 ];
3487 let x0 = corners.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
3488 let y0 = corners.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
3489 let x1 = corners
3490 .iter()
3491 .map(|p| p.x)
3492 .fold(f64::NEG_INFINITY, f64::max);
3493 let y1 = corners
3494 .iter()
3495 .map(|p| p.y)
3496 .fold(f64::NEG_INFINITY, f64::max);
3497 [x0, y0, x1, y1]
3498}
3499
3500/// Collapse fake-bold / overprint duplicates inside one band.
3501///
3502/// Real-word corpus failures such as 0105.pdf draw the same text several times
3503/// with sub-point x drift to simulate heavier weight. Text extraction should
3504/// keep the most informative span once rather than concatenate every overprint.
3505/// A span the page drew over split around the narrower span it covers.
3506///
3507/// Merged spans join a glyph run with the spaces between its glyphs, so one
3508/// span can cover the x of a span the page drew between them: the form's
3509/// `MM DD YYYY` field span covers the `/` separators set between
3510/// its groups, and left whole no x-order of spans interleaves them again --
3511/// the line reads `MM DD YYYY/ /` (#636). Where a narrow sibling sits fully
3512/// inside the span on the same baseline and the span maps its characters to
3513/// its glyphs one to one, the span is cut at the whitespace gap holding the
3514/// sibling, so the reading order puts the separator back between its groups.
3515/// A sibling over ink rather than white never cuts: the split point must sit
3516/// under the sibling at a whitespace character.
3517fn split_spans_covering_siblings(spans: &mut Vec<TextSpan>) {
3518 let mut changed = true;
3519 while changed {
3520 changed = false;
3521 let mut at = 0usize;
3522 while at < spans.len() {
3523 if let Some((left, right)) = split_cover_at_sibling(spans, at) {
3524 spans[at] = left;
3525 spans.insert(at + 1, right);
3526 changed = true;
3527 at += 2;
3528 } else {
3529 at += 1;
3530 }
3531 if spans.len() > 4096 {
3532 return;
3533 }
3534 }
3535 }
3536}
3537
3538/// The two halves of `spans[cover]` cut around one span it covers, or `None`.
3539///
3540/// `SIBLING_WIDTH_EM` and `COVER_MARGIN` are above; the y check keeps the cut
3541/// to the band the sort already grouped, since a tall span can reach into the
3542/// band over or under its own.
3543fn split_cover_at_sibling(spans: &[TextSpan], cover: usize) -> Option<(TextSpan, TextSpan)> {
3544 const SIBLING_WIDTH_EM: f64 = 0.6;
3545 const COVER_MARGIN: f64 = 1.0;
3546 let (cover_left, cover_right) = {
3547 let span = &spans[cover];
3548 (span.x, span.measured_right())
3549 };
3550 if cover_right <= cover_left {
3551 return None;
3552 }
3553 let chars: Vec<char> = spans[cover].text.chars().collect();
3554 if chars.len() < 3 || chars.len() != spans[cover].char_bounds.len() {
3555 return None;
3556 }
3557 for (sib, sibling) in spans.iter().enumerate() {
3558 if sib == cover {
3559 continue;
3560 }
3561 let em = sibling.font_size.max(1.0);
3562 if sibling.width > SIBLING_WIDTH_EM * em {
3563 continue;
3564 }
3565 if (sibling.y - spans[cover].y).abs() > 0.35 * spans[cover].font_size.max(em) {
3566 continue;
3567 }
3568 let (sib_left, sib_right) = (sibling.x, sibling.measured_right());
3569 if !(sib_left > cover_left + COVER_MARGIN && sib_right < cover_right - COVER_MARGIN) {
3570 continue;
3571 }
3572 // The whitespace gap under the sibling: the char boundary closest to
3573 // the sibling's centre whose glyphs stand on either side of it.
3574 let centre = (sib_left + sib_right) * 0.5;
3575 let boxes = &spans[cover].char_bounds;
3576 let mut cut: Option<usize> = None;
3577 let mut best = f64::INFINITY;
3578 for i in 1..chars.len() {
3579 if !(chars[i - 1].is_whitespace() || chars[i].is_whitespace()) {
3580 continue;
3581 }
3582 let edge = (boxes[i - 1][2] + boxes[i][0]) * 0.5;
3583 if edge < sib_left || edge > sib_right {
3584 continue;
3585 }
3586 let distance = (edge - centre).abs();
3587 if distance < best {
3588 best = distance;
3589 cut = Some(i);
3590 }
3591 }
3592 let Some(i) = cut else { continue };
3593 let mut left = spans[cover].clone();
3594 let mut right = spans[cover].clone();
3595 left.text = chars[..i].iter().collect();
3596 right.text = chars[i..].iter().collect();
3597 left.char_bounds = boxes[..i].to_vec();
3598 right.char_bounds = boxes[i..].to_vec();
3599 split_parallel_glyph_vecs(&mut left, &mut right, i, chars.len());
3600 left.width = (boxes[i - 1][2] - left.x).max(0.0);
3601 right.x = boxes[i][0];
3602 right.width = (cover_right - right.x).max(0.0);
3603 return Some((left, right));
3604 }
3605 None
3606}
3607
3608/// Split the per-glyph vectors alongside [`split_cover_at_sibling`]'s cut.
3609///
3610/// A vector that does not run with `char_bounds` (nothing names such a case
3611/// today) is dropped on both halves rather than cut at the wrong place.
3612fn split_parallel_glyph_vecs(left: &mut TextSpan, right: &mut TextSpan, at: usize, len: usize) {
3613 if left.tight_char_bounds.len() == len {
3614 right.tight_char_bounds = left.tight_char_bounds[at..].to_vec();
3615 left.tight_char_bounds.truncate(at);
3616 } else {
3617 left.tight_char_bounds.clear();
3618 right.tight_char_bounds.clear();
3619 }
3620 if left.glyph_advances.len() == len {
3621 right.glyph_advances = left.glyph_advances[at..].to_vec();
3622 left.glyph_advances.truncate(at);
3623 } else {
3624 left.glyph_advances.clear();
3625 right.glyph_advances.clear();
3626 }
3627 if left.glyph_bounds_sources.len() == len {
3628 right.glyph_bounds_sources = left.glyph_bounds_sources[at..].to_vec();
3629 left.glyph_bounds_sources.truncate(at);
3630 } else {
3631 left.glyph_bounds_sources.clear();
3632 right.glyph_bounds_sources.clear();
3633 }
3634}
3635
3636fn collapse_overprinted_spans(spans: &mut Vec<TextSpan>) {
3637 if spans.len() < 2 {
3638 return;
3639 }
3640
3641 let mut deduped: Vec<TextSpan> = Vec::with_capacity(spans.len());
3642 for span in spans.drain(..) {
3643 if let Some(last) = deduped.last_mut() {
3644 if spans_are_overprint_duplicates(last, &span) {
3645 let choose_incoming = span.text.chars().count() > last.text.chars().count()
3646 || (span.text.chars().count() == last.text.chars().count()
3647 && span.width > last.width);
3648 let preferred_text = if choose_incoming {
3649 span.text.clone()
3650 } else {
3651 last.text.clone()
3652 };
3653 let left = last.x.min(span.x);
3654 let right = last.right().max(span.right());
3655 last.x = left;
3656 last.y = (last.y + span.y) * 0.5;
3657 last.width = (right - left).max(last.width).max(span.width);
3658 last.height = last.height.max(span.height);
3659 last.font_size = last.font_size.max(span.font_size);
3660 last.text = preferred_text;
3661 continue;
3662 }
3663 }
3664
3665 deduped.push(span);
3666 }
3667
3668 *spans = deduped;
3669}
3670
3671fn spans_are_overprint_duplicates(lhs: &TextSpan, rhs: &TextSpan) -> bool {
3672 let lhs_text = lhs.text.trim();
3673 let rhs_text = rhs.text.trim();
3674 if lhs_text.is_empty() || rhs_text.is_empty() {
3675 return false;
3676 }
3677
3678 let size = lhs.font_size.max(rhs.font_size);
3679 let drift = (lhs.y - rhs.y).abs();
3680 // Ink over ink: `right()` widens a short span to half an em per character
3681 // for layout, and through that estimate two spans standing side by side
3682 // count as mostly overlapping -- `t ` beside `t`, `oll` beside `o` -- so
3683 // the second reads as a fake-bold overprint and a mid-word letter is lost
3684 // (`follws`, `heir`). A strike over the first sits on the same ink, which
3685 // the measured edges still overlap; neighbours only touch there.
3686 // Quarter-turned spans already measure this way: their `right()` is the
3687 // measured edge.
3688 let lhs_left = lhs.x;
3689 let lhs_right = lhs.measured_right();
3690 let rhs_left = rhs.x;
3691 let rhs_right = rhs.measured_right();
3692 let overlap = (lhs_right.min(rhs_right) - lhs_left.max(rhs_left)).max(0.0);
3693 let min_width = (lhs_right - lhs_left).min(rhs_right - rhs_left).max(1.0);
3694 let share = overlap / min_width;
3695
3696 // A SHADOW IS THE SAME TEXT A LITTLE OFF (#1973). A label drawn twice --
3697 // a halo or drop shadow under it, sometimes a size larger -- sits up to a
3698 // quarter of an em off the first copy's baseline and overlaps it by less
3699 // than a fake-bold overprint does: `KFTC` 0.85 point lower at 4.9 points,
3700 // `NRST` 5.52 over 4.90. Concatenated, it read `KFTCKFTC`. Only for the SAME
3701 // text, and still only where the two lie mostly over one another, so two
3702 // cells or two words that happen to agree never meet here. And of two
3703 // characters or more: one character's shadow and its neighbour are the
3704 // same picture -- the two `1`s of a matrix, the `ll` of `Ersatzteilliste`
3705 // -- and a letter lost there is worse than a letter doubled.
3706 //
3707 // HALF THE WIDTH IS ENOUGH FROM THREE CHARACTERS ON (#623). NASDAQ_SHEN_2003
3708 // (DocLayNet) sets its current-year column head twice, the second copy 8.0
3709 // points right and 0.86 higher at 10.08 points: the two `2003`s overlap by
3710 // 60 per cent, and the page read `20032003` in ten of its tables where the
3711 // annotation reads `2003`. Two different cells cannot lie half over one
3712 // another, and a word of three characters or more drawn twice at that
3713 // distance is a copy; two characters keep the stricter share, for the
3714 // reason one character is left alone.
3715 let marks = lhs_text.chars().filter(|c| !c.is_whitespace()).count();
3716 let needed = if marks >= 3 { 0.5 } else { 0.7 };
3717 if lhs_text == rhs_text && marks >= 2 && drift <= size * 0.25 && share >= needed {
3718 return true;
3719 }
3720
3721 // Fake bold: the same line struck again with sub-point drift.
3722 if drift > size * 0.12 || share < 0.85 {
3723 return false;
3724 }
3725 lhs_text == rhs_text || lhs_text.starts_with(rhs_text) || rhs_text.starts_with(lhs_text)
3726}
3727
3728/// Does `curr` carry on from `prev`, or does it lie under it?
3729///
3730/// The join in `TextBlock::text` drops a word `curr` repeats from the end of
3731/// `prev`, because a document that draws that word twice at the seam between
3732/// two runs holds it once (0105.pdf, #1014). That reading needs `curr` to
3733/// begin where `prev` stopped. A run that begins a whole character *inside*
3734/// its predecessor is not the continuation of it: a text field draws its value
3735/// with the advance the font asks for, which is regularly wider than the field
3736/// box, so each of three fields on one row of a form reaches tens of points
3737/// into the next field's column. With the same value in all three -- a
3738/// repeated date, a column of `N/A`, or Acroform-PDFBOX-2333's own value -- the
3739/// second and the third read as the first one's tail said again, and dropping
3740/// them loses two filled fields. On a form that is the loss of entered data
3741/// (#432, class R-E of #389).
3742///
3743/// One glyph of `curr`'s own mean advance is the allowance, and it is there
3744/// for the seam itself: a measured width carries the last glyph's side bearing
3745/// and a rounding, never a column of another field.
3746///
3747/// The mean is per GLYPH, not per character, and `char_bounds` holds one entry
3748/// per glyph drawn. A glyph that decodes to several characters -- a `fi`
3749/// ligature, any `bfrange` that names a string -- would otherwise divide one
3750/// advance by two or three characters and shrink the allowance below the seam
3751/// it exists for. The character count answers only for a span that carries no
3752/// bounds, which is every hand-built one.
3753fn second_run_carries_on_from_the_first(prev: &TextSpan, curr: &TextSpan) -> bool {
3754 let glyphs = if curr.char_bounds.is_empty() {
3755 curr.text.chars().count()
3756 } else {
3757 curr.char_bounds.len()
3758 };
3759 if glyphs == 0 {
3760 return false;
3761 }
3762
3763 let mean_advance = curr.width / glyphs as f64;
3764 // Along the previous run's baseline, so that a rotated seam is measured in
3765 // the direction the two runs actually overlap in (#435).
3766 let direction = span_direction(prev);
3767 let (curr_along, _) = baseline_coordinates(curr, direction);
3768 baseline_end(prev, direction) - curr_along <= mean_advance
3769}
3770
3771/// A space drawn over the letter before it is no word space (#574).
3772///
3773/// The page draws the word as two runs, and the second run starts with a
3774/// SPACE character placed LEFT of where the first run ends: after a ligature
3775/// glyph the next run starts ` cally` 0.3 em back, after an initial capital
3776/// ` ransition` 0.6 em back. A real word space never starts left of the
3777/// letter before it, so where the next run overlaps the previous by more
3778/// than a tenth of an em and opens with a space and a lower-case letter,
3779/// the join carries no space and the leading space is dropped.
3780///
3781/// Lower-case only, and that is the whole of the guard: leading space plus
3782/// overlap alone fires a thousand times across formulas and tables (`v 2`,
3783/// `0 N`), and some of those are real gaps. A capital or a digit after the
3784/// space keeps its space.
3785///
3786/// `gap` is the join's own gap, measured along the baseline the same way the
3787/// 0.25 em rule measures it. Where this can fire the previous run ends in a
3788/// letter, so it is never a marks-only span and the anchor the gap was
3789/// measured from is the previous run itself.
3790///
3791/// This runs before the ligature fold, which reads the assembled text: the
3792/// previous run still ends in the ligature code point here.
3793fn space_drawn_over_previous_run(prev: &TextSpan, curr: &TextSpan, gap: f64) -> bool {
3794 if gap >= -0.1 * prev.font_size {
3795 return false;
3796 }
3797 let mut next = curr.text.chars();
3798 if next.next() != Some(' ') || !next.next().is_some_and(|c| c.is_lowercase()) {
3799 return false;
3800 }
3801 prev.text
3802 .chars()
3803 .last()
3804 .is_some_and(|c| c.is_alphabetic() || ('\u{FB00}'..='\u{FB06}').contains(&c))
3805}
3806
3807fn trim_overlapping_word_prefix(prev: &str, curr: &str) -> Option<String> {
3808 let prev_chars: Vec<char> = prev.trim_end().chars().collect();
3809 let curr_chars: Vec<char> = curr.trim_start().chars().collect();
3810 let max = prev_chars.len().min(curr_chars.len());
3811
3812 for len in (4..=max).rev() {
3813 let prev_start = prev_chars.len() - len;
3814 if prev_chars[prev_start..] != curr_chars[..len] {
3815 continue;
3816 }
3817
3818 if !curr_chars[..len].iter().all(|ch| ch.is_alphanumeric()) {
3819 continue;
3820 }
3821
3822 let prev_boundary = prev_start == 0 || !prev_chars[prev_start - 1].is_alphanumeric();
3823 let curr_boundary = len == curr_chars.len() || !curr_chars[len].is_alphanumeric();
3824 if !prev_boundary || !curr_boundary {
3825 continue;
3826 }
3827
3828 return Some(curr_chars[len..].iter().collect());
3829 }
3830
3831 None
3832}
3833
3834/// Compute an adaptive column gap threshold from a set of bands.
3835///
3836/// Collects all positive inter-span gaps within each band, computes the
3837/// median, and returns `COLUMN_GAP_MEDIAN_MULTIPLIER × median`, clamped to
3838/// `[COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX]`. Falls back to
3839/// `COLUMN_GAP_THRESHOLD_FALLBACK` when there are no measurable gaps.
3840fn compute_adaptive_column_gap(bands: &[TextBand]) -> f64 {
3841 let mut all_gaps: Vec<f64> = Vec::new();
3842
3843 for band in bands {
3844 if band.spans.len() < 2 {
3845 continue;
3846 }
3847 let mut sorted = band.spans.clone();
3848 sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
3849 let mut prev_right = sorted[0].right();
3850 for span in sorted.iter().skip(1) {
3851 let gap = span.x - prev_right;
3852 if gap > 0.0 {
3853 all_gaps.push(gap);
3854 }
3855 prev_right = prev_right.max(span.right());
3856 }
3857 }
3858
3859 if all_gaps.is_empty() {
3860 return COLUMN_GAP_THRESHOLD_FALLBACK;
3861 }
3862
3863 all_gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3864
3865 let min_gap = all_gaps[0];
3866
3867 // When all inter-span gaps are already large (> MIN threshold), they are
3868 // likely all column gaps — the draw_glyph merger absorbed word-level
3869 // spaces into span text. Use a fraction of the smallest gap so that
3870 // ALL column gaps exceed the threshold.
3871 if min_gap > COLUMN_GAP_THRESHOLD_MIN {
3872 return (min_gap * 0.75).clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
3873 }
3874
3875 // Look for a natural break: the largest relative jump between consecutive
3876 // sorted gaps separates word-level gaps from column gaps.
3877 let mut best_break_threshold = 0.0f64;
3878 let mut best_ratio = 1.5f64; // require at least 1.5× jump
3879 for pair in all_gaps.windows(2) {
3880 if pair[0] > 0.5 {
3881 let ratio = pair[1] / pair[0];
3882 if ratio > best_ratio {
3883 best_ratio = ratio;
3884 best_break_threshold = (pair[0] + pair[1]) * 0.5;
3885 }
3886 }
3887 }
3888
3889 if best_break_threshold > 0.0 {
3890 return best_break_threshold.clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
3891 }
3892
3893 // Fallback: median × multiplier.
3894 let mid = all_gaps.len() / 2;
3895 let median = if all_gaps.len().is_multiple_of(2) {
3896 (all_gaps[mid - 1] + all_gaps[mid]) * 0.5
3897 } else {
3898 all_gaps[mid]
3899 };
3900
3901 (median * COLUMN_GAP_MEDIAN_MULTIPLIER)
3902 .clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX)
3903}
3904
3905/// Group spans into reading-order blocks, using column-aware reordering when
3906/// a contiguous region repeatedly exposes the same gutters.
3907/// Per-page adaptive parameters derived from the span set before any
3908/// grouping happens. Centralising these here (TEX4) means the rest of
3909/// the pipeline — band grouping, XY-Cut cuts, in-block space insertion
3910/// — all speak the same typographic baseline for this specific page,
3911/// rather than each helper reaching for an independent fixed constant.
3912#[derive(Debug, Clone, Copy)]
3913struct PageStats {
3914 /// Median font size across all spans (pt).
3915 median_font_size: f64,
3916 /// Median measured character width (pt). Zero-guarded fallback is
3917 /// 0.5 × median_font_size when there aren't enough samples.
3918 /// Currently populated for diagnostics / future tuning; allow dead_code
3919 /// under `-D warnings` until a reader is added.
3920 #[allow(dead_code)]
3921 median_char_width: f64,
3922 /// Tight line-to-line spacing (25th percentile of pairwise band
3923 /// gaps), representing the body-text leading on this page. The
3924 /// quartile is used instead of the median so large paragraph /
3925 /// zone gaps don't inflate the baseline. Zero if the page has
3926 /// only one band.
3927 median_line_spacing: f64,
3928}
3929
3930impl PageStats {
3931 fn from_spans(spans: &[TextSpan]) -> Self {
3932 if spans.is_empty() {
3933 return Self {
3934 median_font_size: 12.0,
3935 median_char_width: 6.0,
3936 median_line_spacing: 0.0,
3937 };
3938 }
3939
3940 // Median font size.
3941 let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
3942 sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3943 let median_font_size = sizes[sizes.len() / 2];
3944
3945 // Median char width — measured width / char count, per span.
3946 let mut char_widths: Vec<f64> = spans
3947 .iter()
3948 .filter_map(|s| {
3949 let chars = s.text.chars().count();
3950 if chars > 0 && s.width > 0.0 {
3951 Some(s.width / chars as f64)
3952 } else {
3953 None
3954 }
3955 })
3956 .collect();
3957 let median_char_width = if char_widths.is_empty() {
3958 median_font_size * 0.5
3959 } else {
3960 char_widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3961 char_widths[char_widths.len() / 2]
3962 };
3963
3964 // Median line spacing — pairwise gaps between consecutive band
3965 // y-values.
3966 let band_tolerance = (median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
3967 let mut ys: Vec<f64> = spans.iter().map(|s| s.y).collect();
3968 ys.sort_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
3969 let mut band_ys: Vec<f64> = Vec::new();
3970 for y in ys {
3971 if band_ys
3972 .last()
3973 .map(|prev: &f64| (prev - y).abs() > band_tolerance)
3974 .unwrap_or(true)
3975 {
3976 band_ys.push(y);
3977 }
3978 }
3979 // ANN[r17/TEX4] "Line spacing" here means the TIGHT line-to-line
3980 // gap inside a text block — not the median of all gaps. Using
3981 // the median drags the estimate up when the page has
3982 // paragraph / zone breaks (which are the very gaps the
3983 // paragraph-break threshold is supposed to EXCEED). The 25th
3984 // percentile is the smallest gap that still shows up in more
3985 // than one place on the page; it captures body-text leading
3986 // robustly even when large zone gaps dominate.
3987 let median_line_spacing = if band_ys.len() < 2 {
3988 0.0
3989 } else {
3990 let mut spacings: Vec<f64> = band_ys
3991 .windows(2)
3992 .map(|pair| (pair[0] - pair[1]).abs())
3993 .collect();
3994 spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3995 let q1_index = spacings.len() / 4;
3996 spacings[q1_index]
3997 };
3998
3999 Self {
4000 median_font_size,
4001 median_char_width,
4002 median_line_spacing,
4003 }
4004 }
4005}
4006
4007// ANN[r17/TEX2] Maximum recursion depth for XY-Cut. Any real page layout
4008// is decomposable in well under 10 alternating cuts; the cap guards
4009// against pathological inputs where the cut predicate keeps triggering
4010// due to floating-point drift.
4011const XY_CUT_MAX_DEPTH: usize = 12;
4012// THE GUTTER IS MEASURED AGAINST THE TYPE, NOT AGAINST THE PAGE. There was a
4013// third rail here -- a vertical gap had to reach 4% of the region's width --
4014// and on the page it was written for it is the only rail that ever binds. A
4015// two-column paper is set in 9pt type inside a 500pt text block, so 4% asks for
4016// 20pt of white where the typographer left 13pt: wider than the gutter of every
4017// journal that ever printed two columns, which is what "narrow-gutter academic
4018// layouts are still detected" in the comment below was meant to allow and did
4019// not. It refused the cut on tracemonkey's pages 2 and 13 and on the same pages
4020// of highlights and tracemonkey_with_editable_annotations -- six of the eleven
4021// pages that were left under 95% on the gate set (#389).
4022//
4023// What separates a gutter from a word space is the type, and that is what the
4024// two remaining rails say: the median font size (a word space is a quarter of
4025// an em, a gutter is more than a whole one) and a 10pt absolute floor. A page
4026// four times as wide does not get four times the word spaces, so scaling the
4027// question with the page was never measuring the thing it asked about.
4028/// Floor (in pt) for vertical gap regardless of region width. Matches
4029/// the previous `COLUMN_GAP_THRESHOLD_MIN` and keeps XY-Cut conservative
4030/// on narrow regions (sidebars, tall columns).
4031const XY_CUT_VERTICAL_GAP_FLOOR: f64 = 10.0;
4032/// Multiplier applied to median font size to produce the horizontal-gap
4033/// threshold. 1.8 × line-height matches typical paragraph spacing.
4034const XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER: f64 = 1.8;
4035
4036/// Whether the horizontal cut at `cut_y` falls INSIDE one grid (#596).
4037///
4038/// The defect this refuses is the largest single item on the tables axis, and it
4039/// is not in `tables.rs` at all. A table whose rows are grouped under labels --
4040/// `Study Design`, `Gender studied`, `Region` -- leaves a little more white
4041/// between the groups than between the rows of a group, and
4042/// `try_horizontal_cut`'s threshold is a multiple of the ROW pitch. On
4043/// `PMC3037919-p0007` the rows sit 12.0 points apart and the groups 23.8, against
4044/// a threshold of 12 * 1.8 = 21.6: so the cut fires between every group, each
4045/// group becomes its own region, and `blocks::paragraphs` -- whose stretches are
4046/// bounded per region -- hands `tables::find` one group at a time. The page comes
4047/// back as twelve tables where the document holds one, and `find` is blameless:
4048/// it is called twelve times with between one and six rows and does the right
4049/// thing with each of them. Measured over both sets, that shape is 34 pages of
4050/// DocLayNet and 29 of PubTables-1M, and 0.3378 of the tables axis's 0.5442.
4051///
4052/// WHAT SAYS "ONE GRID" IS THE COLUMN EDGES, and the measurement already exists:
4053/// `TextBand::gap_midpoints` gives the x where a band's runs are separated, and
4054/// `boundaries_match` asks whether two bands are separated at the same places.
4055/// `region_is_a_grid` has asked exactly this question since #429 -- and is
4056/// reachable only from `group_spans_into_blocks_legacy_from_bands`, the path whose
4057/// own comment says XY-Cut supersedes it. The vertical cut carries two guards of
4058/// this kind (`region_is_ruled` and `the_cut_is_a_column_of_a_table`); the
4059/// horizontal cut carried none, so a guard that would have refused the dominant
4060/// table defect was left on the dead side of a replacement.
4061///
4062/// BOTH SIDES, and at least two bands each. One band above a cut agreeing with
4063/// one below is two lines of prose that happen to break at the same place; four
4064/// bands agreeing across the cut is a grid. The bands are taken adjacent to the
4065/// cut rather than over the whole region, because a table's own caption and the
4066/// prose above it break nowhere and would refuse every table.
4067fn the_cut_is_inside_a_grid(bands: &[TextBand], cut_y: f64, gap: f64) -> bool {
4068 let threshold = compute_adaptive_column_gap(bands);
4069 // `TextBand` has no y accessor of its own; a band's y is its spans' and they
4070 // are within the band tolerance of each other, so the maximum will do.
4071 let band_y = |band: &TextBand| {
4072 band.spans
4073 .iter()
4074 .map(|span| span.y)
4075 .fold(f64::NEG_INFINITY, f64::max)
4076 };
4077 let mut above: Vec<&TextBand> = bands.iter().filter(|band| band_y(band) > cut_y).collect();
4078 let mut below: Vec<&TextBand> = bands.iter().filter(|band| band_y(band) <= cut_y).collect();
4079 // Nearest the cut first on each side: ascending y above it, descending below.
4080 above.sort_by(|a, b| band_y(a).partial_cmp(&band_y(b)).unwrap_or(Ordering::Equal));
4081 below.sort_by(|a, b| band_y(b).partial_cmp(&band_y(a)).unwrap_or(Ordering::Equal));
4082 if above.len() < GRID_SIDE_ROWS || below.len() < GRID_SIDE_ROWS {
4083 return false;
4084 }
4085 // The bands touching the cut, nearest first on each side.
4086 let touching: Vec<&TextBand> = above
4087 .iter()
4088 .take(GRID_SIDE_ROWS)
4089 .chain(below.iter().take(GRID_SIDE_ROWS))
4090 .copied()
4091 .collect();
4092 // AND THE GAP HAS TO BE A GROUP'S GAP AND NOT A ZONE'S. Refusing every cut
4093 // whose two sides share column edges refuses the cut that takes the prose off
4094 // the top of the table as well -- and then the region handed to
4095 // `tables::find` opens with more prose than its `MAX_TRIMMED_ROWS` will trim,
4096 // so the grid is refused outright and the page comes back with NO table.
4097 // Measured: `PMC1277013-p0003` went from six tables to none that way, which is
4098 // worse than the six. The white between the label groups of one table is a
4099 // small multiple of its row pitch; the white above a table is not.
4100 let mut steps: Vec<f64> = touching
4101 .windows(2)
4102 .map(|pair| (band_y(pair[0]) - band_y(pair[1])).abs())
4103 .filter(|step| *step > 0.0)
4104 .collect();
4105 steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4106 let pitch = match steps.first() {
4107 Some(pitch) => *pitch,
4108 None => return false,
4109 };
4110 if gap > GRID_GROUP_GAP_PITCHES * pitch {
4111 return false;
4112 }
4113
4114 // A ROW WITH AN EMPTY CELL IS STILL A ROW OF THE GRID, and this is the whole
4115 // difficulty. `boundaries_match` asks for the same NUMBER of separations on
4116 // both bands, which is right for two lines of a column cut and wrong here: the
4117 // rows that make a table fragment are exactly the ones whose label cell is
4118 // empty, so they hold one gap fewer than the row above them. A guard built on
4119 // equal counts would refuse the grid precisely where the grid has the empty
4120 // cells that caused the split -- the same way round as a detector I wrote for
4121 // the interleave class, which could not fire on the pages it was for.
4122 //
4123 // So the widest band sets the edges and every other band has to agree with a
4124 // SUBSET of them: each of its separations falls on one of the edges, and no
4125 // separation of its own sits where the widest band has ink.
4126 let reference = touching
4127 .iter()
4128 .map(|band| band.gap_midpoints(threshold))
4129 .max_by_key(Vec::len)
4130 .unwrap_or_default();
4131 if reference.len() < GRID_MIN_BOUNDARIES {
4132 return false;
4133 }
4134 let tolerance = (threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
4135 // A BAND WITH ONE RUN OF INK IS A ROW OF THE TABLE TOO, and refusing on it
4136 // would refuse exactly the tables this guard is for: the section heads set
4137 // across a grid -- `Region`, `Health status` -- are what the label groups are
4138 // divided by, so they touch almost every cut worth refusing. `tables::find`
4139 // already reads such a band as a row of the table when the grid goes on under
4140 // it (#576); this is the same judgement one layer up. One is allowed and not
4141 // two, because two in a row is prose between two tables rather than a head
4142 // inside one.
4143 let mut unseparated = 0usize;
4144 for band in &touching {
4145 let midpoints = band.gap_midpoints(threshold);
4146 if midpoints.is_empty() {
4147 unseparated += 1;
4148 if unseparated > GRID_MAX_UNSEPARATED {
4149 return false;
4150 }
4151 continue;
4152 }
4153 if !midpoints
4154 .iter()
4155 .all(|mid| reference.iter().any(|edge| (edge - mid).abs() <= tolerance))
4156 {
4157 return false;
4158 }
4159 }
4160 true
4161}
4162
4163/// How many bands on EACH side of a horizontal cut must agree on the column
4164/// edges before the cut is inside one grid (#596). Two: one band against one is
4165/// a coincidence two lines of prose can produce.
4166const GRID_SIDE_ROWS: usize = 2;
4167/// How many row pitches of white a gap may hold and still be a gap BETWEEN THE
4168/// GROUPS of one table rather than the white above the table (#596).
4169const GRID_GROUP_GAP_PITCHES: f64 = 5.0;
4170/// How many column separations the widest band across a cut must show before the
4171/// bands can be one grid (#596). Two, so three columns: a single separation is
4172/// what every line of prose with one wide word space has.
4173const GRID_MIN_BOUNDARIES: usize = 2;
4174/// How many of the bands touching a cut may hold a single run of ink and still
4175/// leave the rest a grid (#596): a section head set across the table.
4176const GRID_MAX_UNSEPARATED: usize = 1;
4177
4178/// A table's extent on the page, found before the XY-cut runs (#574).
4179#[derive(Debug, Clone, Copy, PartialEq)]
4180struct TableRegion {
4181 left: f64,
4182 right: f64,
4183 /// The highest baseline of the region's first row.
4184 top: f64,
4185 /// The lowest baseline of its last row.
4186 bottom: f64,
4187 /// Whether the region was found by something stronger than aligned text --
4188 /// rules above and below, two columns of keys, a leader -- so that a column
4189 /// cut through it is the cut dividing a table and not a page.
4190 ruled: bool,
4191 /// How many rows the detector counted in it.
4192 rows: usize,
4193}
4194
4195impl TableRegion {
4196 fn holds(&self, span: &TextSpan) -> bool {
4197 let centre = span.x + (span.right() - span.x) * 0.5;
4198 span.y <= self.top && span.y >= self.bottom && centre >= self.left && centre <= self.right
4199 }
4200}
4201
4202/// One run of ink inside a band: its left and right edge and how many
4203/// characters it holds.
4204#[derive(Debug, Clone)]
4205struct InkRun {
4206 left: f64,
4207 right: f64,
4208 chars: usize,
4209 /// Whether the run opens with a bullet glyph and goes on in text.
4210 bulleted: bool,
4211 /// The run's text, its spans joined by a space.
4212 text: String,
4213}
4214
4215/// Whether a span is nothing but a dot leader's dots: the filler a table of
4216/// contents or a statement runs from a label out to its figure. It is white to
4217/// the eye that reads the row, and to the table found in it (#574).
4218fn is_leader(span: &TextSpan) -> bool {
4219 let text = span.text.trim();
4220 !text.is_empty()
4221 && text
4222 .chars()
4223 .all(|c| matches!(c, '.' | '\u{00B7}' | '\u{2026}' | ' '))
4224}
4225
4226/// Whether a run's first span opens with a bullet -- one that is not also a
4227/// dash, since a dash alone is the nil of a financial table and a minus opens a
4228/// figure. The bullet is often a span of its own, so whether text follows it is
4229/// asked of the whole run (`chars`).
4230fn opens_with_bullet(text: &str) -> bool {
4231 matches!(text.chars().find(|c| !c.is_whitespace()),
4232 Some(c) if crate::lists::BULLET_CHARS.contains(&c)
4233 && !matches!(c, '-' | '\u{2013}' | '\u{2014}'))
4234}
4235
4236fn ink_runs(band: &TextBand, gap: f64) -> Vec<InkRun> {
4237 let mut runs: Vec<InkRun> = Vec::new();
4238 for span in &band.spans {
4239 let chars = span.text.chars().filter(|c| !c.is_whitespace()).count();
4240 if chars == 0 || is_leader(span) {
4241 continue;
4242 }
4243 // The glyphs' own advance where the page gave one: the estimate is
4244 // deliberately wide, and a long label measured by it runs into the
4245 // value column it stops well short of.
4246 let right = if span.width > 0.0 {
4247 span.measured_right()
4248 } else {
4249 span.right()
4250 };
4251 match runs.last_mut() {
4252 Some(run) if span.x - run.right < gap => {
4253 run.right = run.right.max(right);
4254 run.chars += chars;
4255 run.text.push(' ');
4256 run.text.push_str(span.text.trim());
4257 }
4258 _ => runs.push(InkRun {
4259 left: span.x,
4260 right,
4261 chars,
4262 bulleted: opens_with_bullet(&span.text),
4263 text: span.text.trim().to_string(),
4264 }),
4265 }
4266 }
4267 runs
4268}
4269
4270/// The white strips between a band's runs, left edge and right edge.
4271fn run_gaps(runs: &[InkRun]) -> Vec<(f64, f64)> {
4272 runs.windows(2)
4273 .map(|pair| (pair[0].right, pair[1].left))
4274 .collect()
4275}
4276
4277/// Whether a row repeats the column heads of the row that opened the table:
4278/// the same text in every value column, three columns or more.
4279fn heads_again(first: &[InkRun], here: &[InkRun]) -> bool {
4280 first.len() >= 3
4281 && here.len() == first.len()
4282 && first
4283 .iter()
4284 .zip(here)
4285 .skip(1)
4286 .all(|(a, b)| a.text == b.text && a.text.chars().any(char::is_alphabetic))
4287}
4288
4289/// Which of the table's strips this gap falls in, if any. A strip is the white
4290/// every row so far leaves between two columns, so a gap belongs to it when the
4291/// two overlap: right-aligned figures move their left edges row by row and a
4292/// long label moves its right edge, and neither moves the strip.
4293fn strip_of(strips: &[(f64, f64)], gap: (f64, f64), slack: f64) -> Option<usize> {
4294 strips
4295 .iter()
4296 .position(|strip| gap.1.min(strip.1) - gap.0.max(strip.0) > -slack)
4297}
4298
4299/// The tables of a page, found on the WHOLE page before the XY-cut (#574).
4300///
4301/// The XY-cut decides the regions and `tables::find` only ever sees one region
4302/// at a time, so a table the cut runs through comes back as several -- or as a
4303/// table, a few loose paragraphs and another table. That is the largest single
4304/// item on the DocLayNet tables axis: the annotated table drawn as several
4305/// (33 of 64 pages of the round of 24-09, 58 % of the gap), and of the text the
4306/// annotation puts in a table a third goes to prose (T4, 27-09). Grouping the
4307/// cells we already produce by the annotation's own region takes detection from
4308/// 0.300 to 0.849 on DocLayNet's train split (T2-577), so the cells are right
4309/// and the region is the defect.
4310///
4311/// So the region is decided first, from the page's own bands. A ROW is a band
4312/// that breaks into three runs or more at a gap of two ems, at least two of them
4313/// short (a value, a year, a `$`). A TABLE is a stack of at least
4314/// `TABLE_REGION_MIN_ROWS` such rows that break at the SAME places, with room
4315/// between them for what a financial statement puts there: a group label in the
4316/// stub column (`Leasehold buildings and improvements`), a row with one value,
4317/// and the white between groups -- up to `TABLE_REGION_MAX_STEP_PITCHES` of the
4318/// page's line pitch. The cut then runs round each region and never through it.
4319fn table_regions(
4320 spans: &[TextSpan],
4321 stats: &PageStats,
4322 rules: &[HorizontalRule],
4323) -> Vec<TableRegion> {
4324 table_regions_seen(spans, stats, rules, None)
4325}
4326
4327/// [`table_regions`], and when `seen` is given, every region each detector
4328/// accepted before one gave way to another -- the proposals a judge chooses
4329/// between (#574, T4's judge).
4330fn table_regions_seen(
4331 spans: &[TextSpan],
4332 stats: &PageStats,
4333 rules: &[HorizontalRule],
4334 seen: Option<&mut Vec<(TableRegion, &'static str)>>,
4335) -> Vec<TableRegion> {
4336 if spans.len() < TABLE_REGION_MIN_ROWS * 2 {
4337 return Vec::new();
4338 }
4339 let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
4340 let em = stats.median_font_size.max(1.0);
4341 let gap = TABLE_REGION_GAP_EM * em;
4342 let tolerance = TABLE_REGION_EDGE_EM * em;
4343 // Never under a line of the body type: a page with superscripts or a
4344 // footnote block measures a tighter spacing than any of its rows keep,
4345 // and every step between two rows would then read as a break.
4346 let pitch = stats.median_line_spacing.max(em * 1.2);
4347 let runs: Vec<Vec<InkRun>> = bands.iter().map(|band| ink_runs(band, gap)).collect();
4348 // A key and its description may stand closer than two cells of a grid:
4349 // the second column of a two-column table starts where the longest key
4350 // ends, and the white between them can be a single em.
4351 let pair_runs: Vec<Vec<InkRun>> = bands
4352 .iter()
4353 .map(|band| ink_runs(band, TABLE_REGION_PAIR_GAP_EM * em))
4354 .collect();
4355 // Columns of bulleted items side by side line up like a table and are
4356 // lists (a brochure's `• Power Plants • Marine Propulsion • Oil and Gas`).
4357 let is_row = |runs: &[InkRun]| {
4358 runs.len() >= 3
4359 && runs
4360 .iter()
4361 .filter(|run| run.bulleted && run.chars > 1)
4362 .count()
4363 < 2
4364 && runs
4365 .iter()
4366 .filter(|run| run.chars <= TABLE_REGION_SHORT_CELL)
4367 .count()
4368 >= 2
4369 && runs
4370 .iter()
4371 .skip(1)
4372 .all(|run| run.chars <= TABLE_REGION_LONG_CELL)
4373 };
4374
4375 // Asked for what the detectors considered, the loop keeps the stacks of
4376 // two rows as well; they are proposals the judge may rescue, and they are
4377 // left out of what the page itself uses below.
4378 let min_rows = if seen.is_some() {
4379 2
4380 } else {
4381 TABLE_REGION_MIN_ROWS
4382 };
4383 let mut regions = Vec::new();
4384 let mut i = 0;
4385 while i < bands.len() {
4386 if !is_row(&runs[i]) {
4387 i += 1;
4388 continue;
4389 }
4390 let mut strips = run_gaps(&runs[i]);
4391 // Where the first value column starts: a label may run up to it.
4392 let mut values_left = runs[i]
4393 .iter()
4394 .skip(1)
4395 .map(|run| run.left)
4396 .fold(f64::INFINITY, f64::min);
4397 let mut last_row = i;
4398 let mut rows = 1;
4399 let mut loose = 0;
4400 let mut above = bands[i].y;
4401 let mut j = i + 1;
4402 while j < bands.len() {
4403 let here = &runs[j];
4404 // A band of nothing but blank glyphs is white, not a row.
4405 if here.is_empty() {
4406 j += 1;
4407 continue;
4408 }
4409 if above - bands[j].y > TABLE_REGION_MAX_STEP_PITCHES * pitch {
4410 break;
4411 }
4412 above = bands[j].y;
4413 let gaps = run_gaps(here);
4414 let found: Vec<Option<usize>> = gaps
4415 .iter()
4416 .map(|gap| strip_of(&strips, *gap, tolerance))
4417 .collect();
4418 // THE HEAD AGAIN IS A SECOND TABLE: a page that sets two years'
4419 // statements one under the other repeats the column heads over
4420 // the second, and the annotation draws two tables there.
4421 if heads_again(&runs[i], here) {
4422 break;
4423 }
4424 // A CAPTION IS WHERE THE NEXT TABLE STARTS, however short and
4425 // wherever it stands: `Table 2 The same by subtype` between two
4426 // banks is two tables.
4427 let line: Vec<&str> = here.iter().map(|run| run.text.as_str()).collect();
4428 if crate::tables::announces_a_table(&line.join(" ")) {
4429 break;
4430 }
4431 if is_row(here) {
4432 // Most of this row's gaps must fall in strips the table already
4433 // has; a row with one gap more (a column the header did not
4434 // fill) adds it.
4435 let known = found.iter().flatten().count();
4436 if known < 2.min(strips.len()) || known * 2 < gaps.len() {
4437 break;
4438 }
4439 for (gap, at) in gaps.iter().zip(&found) {
4440 match at {
4441 Some(k) => {
4442 let (lo, hi) = (strips[*k].0.max(gap.0), strips[*k].1.min(gap.1));
4443 if hi - lo >= TABLE_REGION_MIN_STRIP {
4444 strips[*k] = (lo, hi);
4445 }
4446 }
4447 None => strips.push(*gap),
4448 }
4449 }
4450 values_left = here
4451 .iter()
4452 .skip(1)
4453 .map(|run| run.left)
4454 .fold(values_left, f64::min);
4455 last_row = j;
4456 rows += 1;
4457 loose = 0;
4458 } else {
4459 // A label in the stub column, or a row with fewer cells filled
4460 // whose breaks fall on the table's own edges.
4461 let first_edge = strips.iter().map(|s| s.1).fold(f64::INFINITY, f64::min);
4462 // A label stops short of the figures; a section head set
4463 // across the table may run over them, and is short.
4464 let label = here.len() == 1
4465 && here[0].left < first_edge
4466 && (here[0].right < values_left - tolerance
4467 || here[0].chars <= TABLE_REGION_LONG_CELL);
4468 let sparse = here.len() == 2 && found.iter().all(Option::is_some);
4469 // A cell's second line: one run that closes none of the strips
4470 // -- it may be longer than the line above it, but it does not
4471 // reach across into the next column.
4472 let wrapped = here.len() == 1
4473 && strips
4474 .iter()
4475 .all(|strip| here[0].left > strip.0 || here[0].right < strip.1);
4476 if !(label || sparse || wrapped) {
4477 break;
4478 }
4479 // A row with a cell left empty is a row of the table even as
4480 // its last line; a label is only a row of it with a row under it.
4481 if sparse {
4482 last_row = j;
4483 }
4484 loose += 1;
4485 if loose > TABLE_REGION_MAX_LOOSE {
4486 break;
4487 }
4488 }
4489 j += 1;
4490 }
4491 // THE HEAD OF THE TABLE, above its first row: a line of years or units
4492 // set over the value columns with nothing in the stub, or a head that
4493 // spans two of them, is not a row by the test above, and it is the
4494 // first thing the annotation's box holds.
4495 let mut first_row = i;
4496 let mut k = i;
4497 let mut heads = 0;
4498 while k > 0 && heads < TABLE_REGION_MAX_HEADS {
4499 k -= 1;
4500 let here = &runs[k];
4501 if here.is_empty() {
4502 continue;
4503 }
4504 if bands[k].y - bands[first_row].y > TABLE_REGION_MAX_STEP_PITCHES * pitch {
4505 break;
4506 }
4507 // Over the value columns only, or a row of heads whose breaks fall
4508 // in the table's strips -- a head spanning two columns among them.
4509 let over_values = here.iter().all(|run| run.left >= values_left - tolerance);
4510 let gaps = run_gaps(here);
4511 if (!over_values && gaps.is_empty())
4512 || !gaps
4513 .iter()
4514 .all(|gap| strip_of(&strips, *gap, tolerance).is_some())
4515 {
4516 break;
4517 }
4518 first_row = k;
4519 heads += 1;
4520 }
4521 if rows >= min_rows && strips.len() >= 2 {
4522 let chosen = &bands[first_row..=last_row];
4523 let mut left = chosen
4524 .iter()
4525 .map(TextBand::left)
4526 .fold(f64::INFINITY, f64::min);
4527 // A STUB THAT IS A COLUMN OF PROSE is the page's other column: on a
4528 // paper set in two columns a table's rows share their bands with
4529 // the lines of the paragraph beside it, and each such line reads as
4530 // the row's first cell. Labels are ragged on the right; justified
4531 // prose is not. Where most rows open with a long run and those runs
4532 // end flush, the region starts where the rows' second run does.
4533 if let Some(cut) = prose_stub_edge(&runs[first_row..=last_row], em) {
4534 left = cut;
4535 }
4536 let right = chosen
4537 .iter()
4538 .map(TextBand::right)
4539 .fold(f64::NEG_INFINITY, f64::max);
4540 let top = bands[first_row]
4541 .spans
4542 .iter()
4543 .map(|span| span.y)
4544 .fold(f64::NEG_INFINITY, f64::max);
4545 let bottom = bands[last_row]
4546 .spans
4547 .iter()
4548 .map(|span| span.y)
4549 .fold(f64::INFINITY, f64::min);
4550 regions.push(TableRegion {
4551 left,
4552 right,
4553 top,
4554 bottom,
4555 ruled: false,
4556 rows,
4557 });
4558 }
4559 i = last_row + 1;
4560 }
4561 if let Some(seen) = seen {
4562 seen.extend(regions.iter().map(|r| (*r, "aligned")));
4563 regions.retain(|r| r.rows >= TABLE_REGION_MIN_ROWS);
4564 seen.extend(
4565 booktabs_regions(&bands, &runs, rules, pitch, em)
4566 .into_iter()
4567 .map(|r| (r, "booktabs")),
4568 );
4569 seen.extend(
4570 pair_regions(&bands, &pair_runs, pitch, tolerance, &[], 2)
4571 .into_iter()
4572 .map(|r| (r, "pair")),
4573 );
4574 seen.extend(
4575 leader_regions(&bands, &runs, pitch, &[], 2)
4576 .into_iter()
4577 .map(|r| (r, "leader")),
4578 );
4579 }
4580 // A table the document ruled above and below is the table the rules
4581 // enclose, head and all: what the alignment found inside it gives way.
4582 for ruled in booktabs_regions(&bands, &runs, rules, pitch, em) {
4583 regions.retain(|r| !(r.top <= ruled.top + em && r.bottom >= ruled.bottom - em));
4584 if !regions
4585 .iter()
4586 .any(|r| r.bottom <= ruled.top && r.top >= ruled.bottom)
4587 {
4588 regions.push(ruled);
4589 }
4590 }
4591 let taken: Vec<(f64, f64)> = regions.iter().map(|r| (r.bottom, r.top)).collect();
4592 regions.extend(pair_regions(
4593 &bands,
4594 &pair_runs,
4595 pitch,
4596 tolerance,
4597 &taken,
4598 TABLE_REGION_MIN_ROWS,
4599 ));
4600 let taken: Vec<(f64, f64)> = regions.iter().map(|r| (r.bottom, r.top)).collect();
4601 regions.extend(leader_regions(
4602 &bands,
4603 &runs,
4604 pitch,
4605 &taken,
4606 TABLE_REGION_MIN_ROWS,
4607 ));
4608 regions
4609}
4610
4611/// Tables the document drew a rule above and below: the booktabs style of a
4612/// paper or a report, with no vertical lines and no rules between the rows
4613/// (#574, the idea from LiteParse's detector, #632).
4614///
4615/// A top rule and a rule below it of the same extent, at least
4616/// `TABLE_REGION_MIN_ROWS` bands between them all inside that extent, at least
4617/// two of them broken into cells, and no more white between two of them than a
4618/// table holds. The rule farthest down that still encloses such a stack is the
4619/// foot, so a mid-rule under the head is inside the table and not its end.
4620fn booktabs_regions(
4621 bands: &[TextBand],
4622 runs: &[Vec<InkRun>],
4623 rules: &[HorizontalRule],
4624 pitch: f64,
4625 em: f64,
4626) -> Vec<TableRegion> {
4627 // A GRID DRAWS ITS RULES CELL BY CELL: the rule under a row is often one
4628 // segment per cell, end to end, and no one of them spans the table. They are
4629 // one rule for this purpose, so segments at one height that meet are joined
4630 // first (EN tender annexes on DocLayNet: four segments to every rule).
4631 let mut joined: Vec<HorizontalRule> = rules.to_vec();
4632 joined.sort_by(|a, b| {
4633 b.y.partial_cmp(&a.y)
4634 .unwrap_or(Ordering::Equal)
4635 .then(a.left.partial_cmp(&b.left).unwrap_or(Ordering::Equal))
4636 });
4637 let mut merged: Vec<HorizontalRule> = Vec::with_capacity(joined.len());
4638 for rule in joined {
4639 match merged.last_mut() {
4640 Some(last)
4641 if (last.y - rule.y).abs() <= RULE_JOIN_TOLERANCE
4642 && rule.left - last.right <= RULE_JOIN_TOLERANCE =>
4643 {
4644 last.right = last.right.max(rule.right);
4645 }
4646 _ => merged.push(rule),
4647 }
4648 }
4649 let mut rules: Vec<&HorizontalRule> = merged
4650 .iter()
4651 .filter(|rule| rule.right - rule.left >= TABLE_REGION_RULE_MIN_EM * em)
4652 .collect();
4653 rules.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
4654 let same_extent = |a: &HorizontalRule, b: &HorizontalRule| {
4655 let overlap = a.right.min(b.right) - a.left.max(b.left);
4656 let narrower = (a.right - a.left).min(b.right - b.left);
4657 overlap >= 0.9 * narrower && narrower >= 0.8 * (a.right - a.left).max(b.right - b.left)
4658 };
4659 let mut regions: Vec<TableRegion> = Vec::new();
4660 let mut t = 0;
4661 while t < rules.len() {
4662 let top = rules[t];
4663 let mut found: Option<(usize, TableRegion)> = None;
4664 for (f, foot) in rules.iter().enumerate().skip(t + 1) {
4665 if !same_extent(top, foot) {
4666 continue;
4667 }
4668 let inside: Vec<usize> = (0..bands.len())
4669 .filter(|&k| bands[k].y < top.y && bands[k].y > foot.y && !runs[k].is_empty())
4670 .collect();
4671 // The ink's own extent, from the runs: a band's `right()` is the
4672 // estimate's, deliberately wide, and a long line of a description
4673 // column measured by it runs out past the rule it stops short of.
4674 // A mark in the margin -- a manual's change bar `|`, a footnote
4675 // dagger -- stands outside the rules and is no ink of the table's:
4676 // runs of at most two characters are not asked.
4677 let within = |k: usize| {
4678 runs[k]
4679 .iter()
4680 .filter(|r| r.chars > TABLE_REGION_MARGIN_MARK)
4681 .all(|r| r.left >= top.left - em && r.right <= top.right + em)
4682 };
4683 let contained = inside.iter().all(|&k| within(k));
4684 let celled = inside.iter().filter(|&&k| runs[k].len() >= 2).count();
4685 let tight = inside
4686 .windows(2)
4687 .all(|w| bands[w[0]].y - bands[w[1]].y <= TABLE_REGION_MAX_STEP_PITCHES * pitch);
4688 if !contained || !tight {
4689 break;
4690 }
4691 if inside.len() >= TABLE_REGION_MIN_ROWS && celled >= 2 {
4692 let top_y = inside
4693 .first()
4694 .map(|&k| {
4695 bands[k]
4696 .spans
4697 .iter()
4698 .map(|s| s.y)
4699 .fold(f64::NEG_INFINITY, f64::max)
4700 })
4701 .unwrap_or(top.y);
4702 // A table ruled ABOVE each row has its last row under the last
4703 // rule: rows directly below it, in the extent and broken into
4704 // cells, are still the table's.
4705 let mut last = *inside.last().unwrap_or(&0);
4706 for k in last + 1..bands.len() {
4707 if runs[k].is_empty() {
4708 continue;
4709 }
4710 let in_extent = within(k);
4711 if runs[k].len() < 2
4712 || !in_extent
4713 || bands[last].y - bands[k].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch
4714 || foot.y - bands[k].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch
4715 {
4716 break;
4717 }
4718 last = k;
4719 }
4720 let bottom_y = bands[last]
4721 .spans
4722 .iter()
4723 .map(|s| s.y)
4724 .fold(f64::INFINITY, f64::min);
4725 found = Some((
4726 f,
4727 TableRegion {
4728 left: top.left.min(foot.left),
4729 right: top.right.max(foot.right),
4730 top: top_y,
4731 bottom: bottom_y,
4732 ruled: true,
4733 rows: inside.len(),
4734 },
4735 ));
4736 }
4737 }
4738 match found {
4739 Some((f, region)) => {
4740 regions.push(region);
4741 t = f + 1;
4742 }
4743 None => t += 1,
4744 }
4745 }
4746 regions
4747}
4748
4749/// Where a region's rows start when their first runs are a column of justified
4750/// prose rather than labels: just left of the leftmost second run. `None` when
4751/// the first runs are ragged, short, or too few (#574).
4752fn prose_stub_edge(rows: &[Vec<InkRun>], em: f64) -> Option<f64> {
4753 let firsts: Vec<&InkRun> = rows
4754 .iter()
4755 .filter(|r| r.len() >= 2)
4756 .map(|r| &r[0])
4757 .collect();
4758 if firsts.len() < TABLE_REGION_MIN_ROWS {
4759 return None;
4760 }
4761 // The long runs, since a paragraph's last line and a short one of a new
4762 // paragraph are neither long nor flush; half the rows at least.
4763 let long: Vec<&&InkRun> = firsts
4764 .iter()
4765 .filter(|r| r.chars > TABLE_REGION_LONG_CELL)
4766 .collect();
4767 if long.len() * 2 < firsts.len() {
4768 return None;
4769 }
4770 let mut rights: Vec<f64> = long.iter().map(|r| r.right).collect();
4771 rights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
4772 let median = rights[rights.len() / 2];
4773 let flush = rights
4774 .iter()
4775 .filter(|r| (*r - median).abs() <= TABLE_REGION_JUSTIFIED_EM * em)
4776 .count();
4777 if flush * 3 < rights.len() * 2 {
4778 return None;
4779 }
4780 rows.iter()
4781 .filter(|r| r.len() >= 2)
4782 .map(|r| r[1].left)
4783 .min_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
4784 .map(|x| x - 0.5 * em)
4785}
4786
4787/// How far apart, in ems, the right edges of a column's lines may be and still
4788/// be one justified edge.
4789const TABLE_REGION_JUSTIFIED_EM: f64 = 0.5;
4790
4791/// Tables of two columns: a short key and what it stands for (#574).
4792///
4793/// `Option | Function`, `F3 | Exit`, `11 | Work with PFD definitions` -- a
4794/// manual sets its menus and its function keys this way, and DocLayNet calls
4795/// every one a table. A row of two runs is also what a page set in two columns
4796/// is, which is why `table_regions` asks for three; here the two columns are
4797/// told from a page's by what they hold and how they stand: the first cell is
4798/// short and no list marker, the second is no bullet, and both start at the
4799/// same place on at least `TABLE_REGION_MIN_ROWS` rows running. The second
4800/// cell may wrap onto a line of its own under itself.
4801fn pair_regions(
4802 bands: &[TextBand],
4803 runs: &[Vec<InkRun>],
4804 pitch: f64,
4805 tolerance: f64,
4806 taken: &[(f64, f64)],
4807 min_rows: usize,
4808) -> Vec<TableRegion> {
4809 let free = |y: f64| !taken.iter().any(|(bottom, top)| y >= *bottom && y <= *top);
4810 let is_pair = |runs: &[InkRun]| {
4811 runs.len() == 2
4812 && runs[0].chars <= TABLE_REGION_SHORT_CELL
4813 && !runs[0].bulleted
4814 && !runs[1].bulleted
4815 && !is_list_marker(&runs[0].text)
4816 };
4817 let mut regions = Vec::new();
4818 let mut i = 0;
4819 while i < bands.len() {
4820 if !is_pair(&runs[i]) || !free(bands[i].y) {
4821 i += 1;
4822 continue;
4823 }
4824 let (key, value) = (runs[i][0].left, runs[i][1].left);
4825 let (mut last_row, mut rows, mut loose) = (i, 1, 0);
4826 let mut above = bands[i].y;
4827 let mut j = i + 1;
4828 while j < bands.len() {
4829 let here = &runs[j];
4830 if here.is_empty() {
4831 j += 1;
4832 continue;
4833 }
4834 if above - bands[j].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch || !free(bands[j].y) {
4835 break;
4836 }
4837 above = bands[j].y;
4838 if is_pair(here)
4839 && (here[0].left - key).abs() <= tolerance
4840 && (here[1].left - value).abs() <= tolerance
4841 {
4842 last_row = j;
4843 rows += 1;
4844 loose = 0;
4845 } else if here.len() == 1 && (here[0].left - value).abs() <= tolerance {
4846 loose += 1;
4847 if loose > TABLE_REGION_MAX_LOOSE {
4848 break;
4849 }
4850 } else {
4851 break;
4852 }
4853 j += 1;
4854 }
4855 if rows >= min_rows {
4856 let chosen = &bands[i..=last_row];
4857 regions.push(TableRegion {
4858 left: chosen
4859 .iter()
4860 .map(TextBand::left)
4861 .fold(f64::INFINITY, f64::min),
4862 right: chosen
4863 .iter()
4864 .map(TextBand::right)
4865 .fold(f64::NEG_INFINITY, f64::max),
4866 top: bands[i]
4867 .spans
4868 .iter()
4869 .map(|span| span.y)
4870 .fold(f64::NEG_INFINITY, f64::max),
4871 bottom: bands[last_row]
4872 .spans
4873 .iter()
4874 .map(|span| span.y)
4875 .fold(f64::INFINITY, f64::min),
4876 ruled: true,
4877 rows,
4878 });
4879 }
4880 i = last_row + 1;
4881 }
4882 regions
4883}
4884
4885/// A span that holds its own leader -- `Characters per line ........ 10` drawn in
4886/// one string -- as the label with its dots and, apart, the figure after them
4887/// (#574). The label's box stops where its words do, so the white the dots
4888/// stand in is a gutter to `table_regions` and `tables::find` alike; the dots
4889/// stay in the label's text, since the reader keeps them. A span with no leader
4890/// of at least `TABLE_REGION_LEADER_DOTS` dots, or with more than a short
4891/// figure after it, comes back as it went in.
4892fn split_at_leader(span: TextSpan) -> Vec<TextSpan> {
4893 let chars: Vec<char> = span.text.chars().collect();
4894 // The last run of at least `TABLE_REGION_LEADER_DOTS` dots, single spaces
4895 // allowed between them: the figure after it may hold a point of its own.
4896 let (mut start, mut end, mut dots) = (0usize, 0usize, 0usize);
4897 let mut k = 0;
4898 while k < chars.len() {
4899 if chars[k] != '.' {
4900 k += 1;
4901 continue;
4902 }
4903 let (from, mut count, mut to) = (k, 0usize, k);
4904 while to < chars.len()
4905 && (chars[to] == '.'
4906 || (chars[to] == ' ' && to + 1 < chars.len() && chars[to + 1] == '.'))
4907 {
4908 if chars[to] == '.' {
4909 count += 1;
4910 }
4911 to += 1;
4912 }
4913 if count >= TABLE_REGION_LEADER_DOTS {
4914 (start, end, dots) = (from, to, count);
4915 }
4916 k = to.max(k + 1);
4917 }
4918 let label: String = chars[..start].iter().collect();
4919 let tail: String = chars[end..].iter().collect::<String>().trim().to_string();
4920 if dots < TABLE_REGION_LEADER_DOTS
4921 || label.trim().is_empty()
4922 || tail.chars().count() > TABLE_REGION_SHORT_CELL
4923 {
4924 return vec![span];
4925 }
4926 let n = chars.len().max(1) as f64;
4927 let exact = span.char_bounds.len() == chars.len();
4928 let at = |i: usize, right: bool| -> f64 {
4929 if exact {
4930 span.char_bounds[i][if right { 2 } else { 0 }]
4931 } else {
4932 span.x + span.width * ((i + usize::from(right)) as f64 / n)
4933 }
4934 };
4935 let label_end = label.trim_end().chars().count().max(1) - 1;
4936 let label_right = at(label_end, true);
4937 let mut out = Vec::with_capacity(2);
4938 let mut head = span.clone();
4939 head.text = chars[..end].iter().collect();
4940 head.width = (label_right - span.x).max(0.0);
4941 head.char_bounds.clear();
4942 head.tight_char_bounds.clear();
4943 head.glyph_advances.clear();
4944 head.glyph_bounds_sources.clear();
4945 out.push(head);
4946 if !tail.is_empty() {
4947 let first = chars[end..]
4948 .iter()
4949 .position(|c| !c.is_whitespace())
4950 .map_or(end, |k| end + k);
4951 let mut figure = span.clone();
4952 figure.text = tail;
4953 figure.x = at(first, false);
4954 figure.width = (span.x + span.width - figure.x).max(0.0);
4955 figure.char_bounds.clear();
4956 figure.tight_char_bounds.clear();
4957 figure.glyph_advances.clear();
4958 figure.glyph_bounds_sources.clear();
4959 out.push(figure);
4960 }
4961 out
4962}
4963
4964/// Each leader span's dots appended to the text of the span before it, and the
4965/// leader span removed; `spans` is in reading order along the band.
4966///
4967/// The dots' glyphs ride along: their boxes are appended to the label's, so a
4968/// word the fold glued keeps one box per glyph. Dropped, a folded dot left its
4969/// word boxless and the word fell back to the label's whole box -- a decimal
4970/// dot read on top of its digit, and no join rule could put `1.1.1` together
4971/// again.
4972///
4973/// Where the dots abut the label on its own line -- a decimal dot, not a
4974/// leader run across white -- they join it as text and box: `2` and `.` read
4975/// `2.`, and the label's box grows to cover them, because the box claims the
4976/// text and a gap measured past a stale edge reads white the ink fills. Dots
4977/// standing off across white keep their space and leave the box alone, so
4978/// the gutter past them still reads as one.
4979///
4980/// The abutting join is on both paths (#655): a decimal dot is a decimal
4981/// dot whatever the option says. What `cell_path_words` still gates is the
4982/// standoff fold: with it the dots' boxes ride along, without it the dots
4983/// join with a space and leave the glyph boxes behind, exactly as before.
4984fn fold_leaders(spans: &mut Vec<TextSpan>, cell_path_words: bool) {
4985 let mut kept: Vec<TextSpan> = Vec::with_capacity(spans.len());
4986 for span in spans.drain(..) {
4987 match kept.last_mut() {
4988 Some(label) if is_leader(&span) => {
4989 // Abutting on the label's own line: no daylight between the
4990 // label's right edge and the dots' left, and their heights
4991 // overlap. Anything past a hair is a leader across white.
4992 const ABUT_EPS: f64 = 1e-6;
4993 let dots_left = span
4994 .char_bounds
4995 .iter()
4996 .map(|b| b[0])
4997 .fold(f64::INFINITY, f64::min);
4998 let dots_right = span
4999 .char_bounds
5000 .iter()
5001 .map(|b| b[2])
5002 .fold(f64::NEG_INFINITY, f64::max);
5003 let dots_top = span
5004 .char_bounds
5005 .iter()
5006 .map(|b| b[3])
5007 .fold(f64::NEG_INFINITY, f64::max);
5008 let dots_bottom = span
5009 .char_bounds
5010 .iter()
5011 .map(|b| b[1])
5012 .fold(f64::INFINITY, f64::min);
5013 let abuts = dots_left <= label.x + label.width + ABUT_EPS
5014 && dots_top > label.y
5015 && dots_bottom < label.y + label.height;
5016 if abuts {
5017 label.text.push_str(span.text.trim());
5018 label.char_bounds.extend(span.char_bounds.iter().copied());
5019 label
5020 .tight_char_bounds
5021 .extend(span.tight_char_bounds.iter().copied());
5022 label
5023 .glyph_advances
5024 .extend(span.glyph_advances.iter().copied());
5025 label
5026 .glyph_bounds_sources
5027 .extend(span.glyph_bounds_sources.iter().copied());
5028 label.width = (dots_right - label.x).max(label.width).max(0.0);
5029 continue;
5030 }
5031 if !cell_path_words {
5032 label.text.push(' ');
5033 label.text.push_str(span.text.trim());
5034 continue;
5035 }
5036 label.text.push(' ');
5037 label.text.push_str(span.text.trim());
5038 label.char_bounds.extend(span.char_bounds.iter().copied());
5039 label
5040 .tight_char_bounds
5041 .extend(span.tight_char_bounds.iter().copied());
5042 label
5043 .glyph_advances
5044 .extend(span.glyph_advances.iter().copied());
5045 label
5046 .glyph_bounds_sources
5047 .extend(span.glyph_bounds_sources.iter().copied());
5048 }
5049 _ => kept.push(span),
5050 }
5051 }
5052 *spans = kept;
5053}
5054
5055/// Rows joined to their figures by a dot leader: a table of contents, a list of
5056/// tables, a statement set with leaders (#574). The leader is dropped by
5057/// `ink_runs`, so a row is a label and a short figure with white between them
5058/// where the dots were; `leader_rows` counts the dots, since a label and a page
5059/// number with white between them and no leader is a line of prose that ends
5060/// in a number. At least `TABLE_REGION_MIN_ROWS` such rows running, a label
5061/// that wraps onto a line of its own allowed between them.
5062fn leader_regions(
5063 bands: &[TextBand],
5064 runs: &[Vec<InkRun>],
5065 pitch: f64,
5066 taken: &[(f64, f64)],
5067 min_rows: usize,
5068) -> Vec<TableRegion> {
5069 let free = |y: f64| !taken.iter().any(|(bottom, top)| y >= *bottom && y <= *top);
5070 let is_leader_row = |k: usize| {
5071 // The dots, whether each is a span of its own or they run on inside
5072 // the label's string (`split_at_leader` has cut the figure off).
5073 let dots: usize = bands[k]
5074 .spans
5075 .iter()
5076 .map(|span| {
5077 if is_leader(span) {
5078 span.text.chars().filter(|c| !c.is_whitespace()).count()
5079 } else {
5080 span.text
5081 .trim_end()
5082 .chars()
5083 .rev()
5084 .take_while(|c| *c == '.' || *c == ' ')
5085 .filter(|c| *c == '.')
5086 .count()
5087 }
5088 })
5089 .sum();
5090 dots >= TABLE_REGION_LEADER_DOTS
5091 && runs[k].len() >= 2
5092 && runs[k]
5093 .last()
5094 .is_some_and(|run| run.chars <= TABLE_REGION_SHORT_CELL)
5095 };
5096 let mut regions = Vec::new();
5097 let mut i = 0;
5098 while i < bands.len() {
5099 if !is_leader_row(i) || !free(bands[i].y) {
5100 i += 1;
5101 continue;
5102 }
5103 let (mut last_row, mut rows, mut loose) = (i, 1, 0);
5104 let mut above = bands[i].y;
5105 let mut j = i + 1;
5106 while j < bands.len() {
5107 if runs[j].is_empty() {
5108 j += 1;
5109 continue;
5110 }
5111 if above - bands[j].y > TABLE_REGION_PAIR_STEP_PITCHES * pitch || !free(bands[j].y) {
5112 break;
5113 }
5114 above = bands[j].y;
5115 if is_leader_row(j) {
5116 last_row = j;
5117 rows += 1;
5118 loose = 0;
5119 } else if runs[j].len() == 1 {
5120 loose += 1;
5121 if loose > 2 {
5122 break;
5123 }
5124 } else {
5125 break;
5126 }
5127 j += 1;
5128 }
5129 if rows >= min_rows {
5130 let chosen = &bands[i..=last_row];
5131 regions.push(TableRegion {
5132 left: chosen
5133 .iter()
5134 .map(TextBand::left)
5135 .fold(f64::INFINITY, f64::min),
5136 right: chosen
5137 .iter()
5138 .map(TextBand::right)
5139 .fold(f64::NEG_INFINITY, f64::max),
5140 top: bands[i]
5141 .spans
5142 .iter()
5143 .map(|span| span.y)
5144 .fold(f64::NEG_INFINITY, f64::max),
5145 bottom: bands[last_row]
5146 .spans
5147 .iter()
5148 .map(|span| span.y)
5149 .fold(f64::INFINITY, f64::min),
5150 ruled: true,
5151 rows,
5152 });
5153 }
5154 i = last_row + 1;
5155 }
5156 regions
5157}
5158
5159/// Whether a short run is a list's marker rather than a key: `1.`, `a)`, `(iv)`.
5160fn is_list_marker(text: &str) -> bool {
5161 let text = text.trim();
5162 text.chars().count() <= 5
5163 && (text.ends_with('.') || text.ends_with(')') || text.starts_with('('))
5164}
5165
5166/// A table region one of the page's detectors proposed (#574): what a judge
5167/// chooses between. `hull` is `[left, bottom, right, top]` in page space, the
5168/// extent of the rows' ink, y up.
5169#[doc(hidden)]
5170#[derive(Debug, Clone, PartialEq)]
5171pub struct TableProposal {
5172 /// `aligned`, `booktabs`, `pair` or `leader`.
5173 pub detector: &'static str,
5174 /// Whether the detector would keep it: `false` for a stack of two rows,
5175 /// which is a proposal for the judge and not a table for the page.
5176 pub accepted: bool,
5177 /// The region's extent.
5178 pub hull: [f64; 4],
5179}
5180
5181/// Every table region the detectors accept on a page, before any gives way to
5182/// another, from the same spans and rules the page's blocks are built from.
5183///
5184/// A PAGE THAT IS ITSELF TURNED proposes nothing here: the regions are found in
5185/// the frame the page was written in, and until a proposal can be turned back
5186/// with it exactly, an empty list is the honest answer. Hidden: this is the
5187/// feature dump's and the table judge's, not a promise.
5188#[doc(hidden)]
5189pub fn table_proposals(spans: &[TextSpan], rules: &[HorizontalRule]) -> Vec<TableProposal> {
5190 if spans.is_empty() || PageTurn::of(spans) != PageTurn::None {
5191 return Vec::new();
5192 }
5193 let stats = PageStats::from_spans(spans);
5194 let spans: Vec<TextSpan> = spans.iter().cloned().flat_map(split_at_leader).collect();
5195 let mut seen = Vec::new();
5196 table_regions_seen(&spans, &stats, rules, Some(&mut seen));
5197 // And each column of a page set in columns, as a page of its own: a table
5198 // in one column shares its bands with the other column's prose, which no
5199 // detector reads as rows on the whole page (see `xy_cut_round_tables`).
5200 if let Some((columns, _)) = try_vertical_cut(&spans, &stats, rules) {
5201 for column in &columns {
5202 let mut more = Vec::new();
5203 table_regions_seen(column, &stats, rules, Some(&mut more));
5204 for found in more {
5205 if !seen.contains(&found) {
5206 seen.push(found);
5207 }
5208 }
5209 }
5210 }
5211 seen.into_iter()
5212 .map(|(r, detector)| {
5213 let top = r.top
5214 + spans
5215 .iter()
5216 .filter(|s| r.holds(s))
5217 .map(|s| s.height)
5218 .fold(0.0, f64::max);
5219 TableProposal {
5220 detector,
5221 accepted: r.rows >= TABLE_REGION_MIN_ROWS,
5222 hull: [r.left, r.bottom, r.right, top],
5223 }
5224 })
5225 .collect()
5226}
5227
5228/// The page cut round its tables (#574): each table region is one leaf, the
5229/// rest goes through the XY-cut, and each table is put back in front of the
5230/// first region below it that it shares any width with. Each region comes back
5231/// with whether it is one of those tables, which `blocks::paragraphs` reads as
5232/// "one grid if it makes one" (`tables::find_whole`).
5233fn xy_cut_round_tables(
5234 spans: Vec<TextSpan>,
5235 stats: &PageStats,
5236 rules: &[HorizontalRule],
5237 merge_turned_runs: bool,
5238 cell_path_words: bool,
5239) -> Vec<(Vec<TextBlock>, bool)> {
5240 let tables = table_regions(&spans, stats, rules);
5241 // Each quarter-turned run the layout would break as one span for the
5242 // layout below -- the cut, the bands, the grid -- while the detectors
5243 // above read the spans as drawn (#574). A marginal run merged first
5244 // stops being the bands, steps and short marks the detectors count: on
5245 // one proxy page thirteen one-glyph bands veto an aligned region that
5246 // blocks a ruled one, and merged to one span the veto count drops under
5247 // the threshold, the page comes back one ruled region, and two columns
5248 // of prose are gridded as a table.
5249 //
5250 // On an upright page only: a turned page is already in the frame it was
5251 // written in here, where its own runs read upright and its MINORITY --
5252 // the upright furniture of a landscape table -- reads turned, and merging
5253 // that minority re-decides the table around it (#574: a landscape segment
5254 // report comes back more duplicated and fewer of its words match).
5255 let spans = if merge_turned_runs {
5256 merge_rotated_runs(spans)
5257 } else {
5258 spans
5259 };
5260 if tables.is_empty() {
5261 return xy_cut_recursive(spans, 0, stats, rules)
5262 .into_iter()
5263 .map(|region| (region, false))
5264 .collect();
5265 }
5266 // A PAGE SET IN COLUMNS IS LOOKED AT ONE COLUMN AT A TIME. Across the
5267 // gutter a line of prose and a row of a table share a band, and the prose
5268 // reads as the row's stub: the table's region took the column beside it
5269 // with it. Where the cut would divide the page into columns anyway, each
5270 // column is its own page for this.
5271 // Unless the cut would divide a table found by more than its alignment --
5272 // ruled above and below, keys and what they stand for, a leader: the cut
5273 // would take the labels from what they label.
5274 let ruled_across = |columns: &[Vec<TextSpan>]| {
5275 tables.iter().filter(|table| table.ruled).any(|table| {
5276 columns
5277 .iter()
5278 .filter(|column| column.iter().any(|span| table.holds(span)))
5279 .count()
5280 > 1
5281 })
5282 };
5283 if let Some((columns, _)) =
5284 try_vertical_cut(&spans, stats, rules).filter(|(columns, _)| !ruled_across(columns))
5285 {
5286 return columns
5287 .into_iter()
5288 .flat_map(|column| {
5289 xy_cut_round_tables(column, stats, rules, merge_turned_runs, cell_path_words)
5290 })
5291 .collect();
5292 }
5293 let mut held: Vec<Vec<TextSpan>> = vec![Vec::new(); tables.len()];
5294 let mut rest = Vec::new();
5295 for span in spans {
5296 match tables.iter().position(|table| table.holds(&span)) {
5297 Some(at) => held[at].push(span),
5298 None => rest.push(span),
5299 }
5300 }
5301 let mut regions: Vec<(Vec<TextBlock>, bool)> = xy_cut_recursive(rest, 0, stats, rules)
5302 .into_iter()
5303 .map(|region| (region, false))
5304 .collect();
5305 for (table, spans) in tables.iter().zip(held) {
5306 if spans.is_empty() {
5307 continue;
5308 }
5309 let at = regions
5310 .iter()
5311 .position(|(region, _)| {
5312 let spans = region.iter().flat_map(|line| line.spans.iter());
5313 let (mut top, mut left, mut right) =
5314 (f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
5315 for span in spans {
5316 top = top.max(span.y);
5317 left = left.min(span.x);
5318 right = right.max(span.right());
5319 }
5320 top < table.top && left < table.right && right > table.left
5321 })
5322 .unwrap_or(regions.len());
5323 // One line per row, every cell of the row in it and a wrapped cell's
5324 // lines kept with it (`push_row`): what `tables::find` reads a row as.
5325 // The leaf grouping the cut uses would look for columns of prose in it
5326 // and find the table's.
5327 // A leader's dots go into the text of the label they run out from and
5328 // not into its box: the reader keeps them, and `tables::find` sees the
5329 // gutter between the label and its figure that the dots filled.
5330 let mut bands = group_spans_into_bands_with_stats(spans, stats);
5331 for band in &mut bands {
5332 fold_leaders(&mut band.spans, cell_path_words);
5333 }
5334 let mut rows = Vec::with_capacity(bands.len());
5335 let mut k = 0;
5336 while k < bands.len() {
5337 k = push_row(&bands, k, &mut rows);
5338 }
5339 regions.insert(at, (rows, true));
5340 }
5341 regions
5342}
5343
5344/// The gap, in ems of the page's body size, that separates two cells of a
5345/// table row when its region is looked for on the whole page (#574).
5346const TABLE_REGION_GAP_EM: f64 = 2.0;
5347/// The narrowest a strip of white between two columns may become, in points,
5348/// before a row stops narrowing it.
5349const TABLE_REGION_MIN_STRIP: f64 = 2.0;
5350/// How far, in ems, a row's gap may miss a strip and still be in it.
5351const TABLE_REGION_EDGE_EM: f64 = 1.5;
5352/// A cell this short (non-blank characters) is a value, a year or a unit.
5353const TABLE_REGION_SHORT_CELL: usize = 16;
5354/// No cell but the stub may be longer than this: a run of prose beside a table
5355/// is a column of the page, not a cell of the table.
5356const TABLE_REGION_LONG_CELL: usize = 40;
5357/// The fewest rows that make a table region.
5358const TABLE_REGION_MIN_ROWS: usize = 3;
5359/// How many bands in a row may be labels or sparse rows inside a table.
5360const TABLE_REGION_MAX_LOOSE: usize = 5;
5361/// How many head lines over the value columns a table may carry above its
5362/// first row.
5363const TABLE_REGION_MAX_HEADS: usize = 2;
5364/// The most characters a run outside a ruled table's extent may hold and still
5365/// be a mark in the margin rather than text beside the table.
5366const TABLE_REGION_MARGIN_MARK: usize = 2;
5367/// How far apart, in points, two rule segments at one height may be and still
5368/// be one rule.
5369const RULE_JOIN_TOLERANCE: f64 = 2.0;
5370/// How long a rule must be, in ems, to open or close a table.
5371const TABLE_REGION_RULE_MIN_EM: f64 = 10.0;
5372/// The gap, in ems, that separates a key from its description.
5373const TABLE_REGION_PAIR_GAP_EM: f64 = 1.0;
5374/// How many leader dots make a row a leader row.
5375const TABLE_REGION_LEADER_DOTS: usize = 4;
5376/// How much white between two rows of a two-column table, in line pitches: less
5377/// than a statement's, since such a table has no groups to set apart.
5378const TABLE_REGION_PAIR_STEP_PITCHES: f64 = 2.5;
5379/// How much white between two bands, in line pitches, still lies inside one
5380/// table: the space between a statement's groups.
5381const TABLE_REGION_MAX_STEP_PITCHES: f64 = 4.0;
5382
5383/// The leading the body of THIS SET OF SPANS is set on: the commonest step
5384/// between its bands (#580).
5385///
5386/// PER REGION AND NOT PER PAGE, which is the whole of what makes this safe. A
5387/// page-wide leading is the leading of whatever the page has most of, and a
5388/// document that mixes dense form boxes with columns of prose has two: on
5389/// `pdfjs__test_pdfs_issue6127.pdf` the prose sets it to 12 points while the
5390/// form's own zone gaps are under that, so a page-wide floor suppressed a cut
5391/// the form needed and glued a heading to the marginal notes beside it
5392/// (`order_agreement` 97.8 -> 81.1 over the gate set). The region being cut
5393/// answers about itself.
5394///
5395/// Bounded on both sides, and both bounds earn their place:
5396/// * at least 0.8 of the line height, because two bands closer than that are
5397/// one line's sub- and superscripts, a split run or a rule -- the very
5398/// steps that drag `median_line_spacing` down. That bound is what tells
5399/// 6.0pt of table pitch from 12.0pt of leading on the pages this fixes. It
5400/// is on the HEIGHT and not on the em, because a leading can sit below the
5401/// em -- a 10-on-12 setting does, and at 0.9 this refused
5402/// `xy_cut_leaf_falls_back_to_legacy_columns_for_header_plus_three_columns`.
5403/// * at most three ems, because a wider step is a paragraph break, a zone
5404/// break or the white above a heading. Without the ceiling a region that is
5405/// mostly table reports its one big gap as the leading, 57.6pt on an 8pt
5406/// page.
5407///
5408/// Zero when the region has no step that could be a leading at all, which says
5409/// "do not floor anything" rather than "the leading is nothing".
5410fn leading_of_a_region(spans: &[TextSpan], stats: &PageStats) -> f64 {
5411 let tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
5412 let mut ordered: Vec<&TextSpan> = spans.iter().collect();
5413 ordered.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
5414 // Each band keeps the leftmost ink on it, because whether two bands are
5415 // lines of one paragraph is a question about their left edge.
5416 let mut bands: Vec<(f64, f64)> = Vec::new();
5417 for span in ordered {
5418 match bands.last_mut() {
5419 Some(band) if (band.0 - span.y).abs() <= tolerance => {
5420 band.1 = band.1.min(span.x);
5421 }
5422 _ => bands.push((span.y, span.x)),
5423 }
5424 }
5425 let aligned = LEADING_ALIGNED_EM * stats.median_font_size;
5426 let mut steps: Vec<f64> = bands
5427 .windows(2)
5428 // CONTINUOUS TEXT, and not merely a step of the right size (codex, #580
5429 // round two). Two bands are lines of one paragraph when they start at the
5430 // same left edge; a masthead over a title over a byline is three bands
5431 // that may be equally spaced by coincidence but are not flush with each
5432 // other. Without this, three equidistant one-line zones read as a leading
5433 // and the floor suppresses both of the region's cuts -- and a rule
5434 // counting occurrences alone cannot tell the two apart, because two equal
5435 // gaps is every gap such a region has.
5436 .filter(|pair| (pair[0].1 - pair[1].1).abs() <= aligned)
5437 .map(|pair| (pair[0].0 - pair[1].0).abs())
5438 .filter(|step| {
5439 *step >= LEADING_MIN_EM * stats.median_font_size
5440 && *step <= LEADING_MAX_EM * stats.median_font_size
5441 })
5442 .map(|step| (step * 10.0).round() / 10.0)
5443 .collect();
5444 steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
5445 let mut best = (0usize, 0.0f64);
5446 let mut i = 0;
5447 while i < steps.len() {
5448 let mut j = i;
5449 while j < steps.len() && steps[j] == steps[i] {
5450 j += 1;
5451 }
5452 if j - i > best.0 {
5453 best = (j - i, steps[i]);
5454 }
5455 i = j;
5456 }
5457 // A LEADING IS A REPETITION THE REGION DOES NOT EXHAUST (codex, #580, both
5458 // rounds). One step is not a repetition: a region of exactly two bands has a
5459 // single gap, and that gap would win this count with a tally of one, so a
5460 // header over a body would be read as the leading of a body it is not part
5461 // of. The floor would then refuse the only horizontal cut the region has,
5462 // leave the full-width band in place, and through that block the column split
5463 // underneath it -- the interleave this change exists to avoid, reintroduced
5464 // by the change itself.
5465 //
5466 // AND A COUNT THAT EVERY GAP IN THE REGION SATISFIES IS NOT EVIDENCE EITHER,
5467 // which is the second round's point: three bands set equally apart have two
5468 // equal gaps whatever they are, so raising this count to three only moves the
5469 // same hole from two bands to three. What answers it is the filter above --
5470 // the gaps have to be between bands that are FLUSH with each other, which is
5471 // what tells three lines of a paragraph from a masthead over a centred title
5472 // over an indented byline. The count then only has to say "more than one".
5473 //
5474 // Raising the count to three was measured too and rejected: it changed ten of
5475 // the seventy-five pages this machine may hold and left four of the
5476 // twenty-one the floor had fixed unfixed, for a hole the alignment filter
5477 // closes without giving anything up.
5478 if best.0 < LEADING_MIN_OCCURRENCES {
5479 return 0.0;
5480 }
5481 best.1
5482}
5483
5484/// How many gaps of one size, between bands flush with each other, a region must
5485/// show before that size counts as its leading (#580). Two: three lines set
5486/// together on one leading is the least a body can be and still have one, and
5487/// what keeps a small region from inventing a leading out of its own two gaps is
5488/// that those gaps have to be between left-aligned bands, not that there are
5489/// more of them.
5490const LEADING_MIN_OCCURRENCES: usize = 2;
5491
5492/// The narrowest step that can be a line of text rather than one line's own
5493/// parts, as a share of the line height (#580).
5494const LEADING_MIN_EM: f64 = 0.8;
5495/// The widest step that can still be a leading rather than a break (#580).
5496const LEADING_MAX_EM: f64 = 3.0;
5497/// How far two bands' left edges may differ and still be lines of one paragraph
5498/// (#580). Half an em: flush enough to be set together, loose enough that the
5499/// measured left edge of a line opening with a narrow glyph still counts.
5500const LEADING_ALIGNED_EM: f64 = 0.5;
5501
5502/// How far above the body's leading the paragraph-break threshold has to stay
5503/// (#580). Just above, and deliberately so: the threshold also has to stay
5504/// BELOW the page's paragraph breaks or the zone cuts stop firing, and a page
5505/// whose paragraph break is only a fifth wider than its leading exists.
5506const HORIZONTAL_CUT_LEADING_FLOOR: f64 = 1.05;
5507/// Minimum number of spans a column must contain before it is eligible
5508/// for acceptance — one-span "columns" are almost always sidebar noise
5509/// or table-cell fragments.
5510/// The white, in ems of its own size, after a single glyph that sets it apart
5511/// as a marker hung into a gutter rather than a word of the line (#574).
5512const HUNG_MARKER_GAP_EM: f64 = 0.6;
5513/// The letters, not counting figures or signs, the median line of each side
5514/// of a gutter found past hung markers must hold to be a column of text.
5515const RUNNING_LETTERS: usize = 30;
5516const XY_CUT_MIN_SPANS_PER_COLUMN: usize = 2;
5517/// Average characters per band a column must have before it's accepted
5518/// as dense prose (vs. a short-cell table column).
5519const XY_CUT_MIN_CHARS_PER_BAND: f64 = 8.0;
5520
5521/// The page's spans as blocks, in reading order, given the horizontal rules the
5522/// page drew (#545): a region the document itself rules into rows is a table,
5523/// and a table is not read column by column.
5524///
5525/// ANN[r17/TEX2][r17/TEX4] Top-level grouping uses recursive XY-Cut
5526/// with a density guard. Per-page stats are computed once up front so
5527/// every decision downstream speaks the same typographic baseline.
5528fn group_spans_into_blocks(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<TextBlock> {
5529 if spans.is_empty() {
5530 return Vec::new();
5531 }
5532 // A page that is itself turned is read in the frame it was written in, and
5533 // an upright page is turned by nothing at all (#546).
5534 let turn = PageTurn::of(&spans);
5535 let spans = turn.into_frame(spans);
5536 let stats = PageStats::from_spans(&spans);
5537 let rules = turn.rules_in_frame(rules);
5538 // Each quarter-turned run as one span, so the cut cannot part it at a
5539 // word gap (#574). After the stats, which describe the spans as drawn. On
5540 // an upright page only: in the frame of a turned page the minority reads
5541 // turned, and it is not this merge's to touch.
5542 let spans = if turn == PageTurn::None {
5543 merge_rotated_runs(spans)
5544 } else {
5545 spans
5546 };
5547 let blocks = xy_cut_recursive(spans, 0, &stats, &rules)
5548 .into_iter()
5549 .flatten()
5550 .collect();
5551 let blocks = reassemble_cross_direction_runs(blocks);
5552 turn.back(blocks)
5553}
5554
5555/// The page's spans as paragraphs, in reading order (#567).
5556///
5557/// The same layout as [`group_spans_into_blocks`], up to the point where it
5558/// hands back lines: the page is turned into the frame it was written in, cut,
5559/// and its cross-direction runs reassembled. What is added is the merge, and it
5560/// runs in that same frame -- "the next line down" and "the left margin" are
5561/// statements about the frame the type was set in, not about page space. The
5562/// spans a caller receives are in page space, exactly as `into_blocks` leaves
5563/// them.
5564/// The reading direction of a run, quantized to quarter turns (#643): 0 is
5565/// upright or unknown, then 90, 180 and 270 degrees counter-clockwise. Only
5566/// equality matters: runs that read in different directions never share a
5567/// raw block.
5568fn raw_direction(span: &TextSpan) -> u8 {
5569 let Some(t) = span.transform else {
5570 return 0;
5571 };
5572 let (a, b) = (t[0], t[1]);
5573 if a.abs() >= b.abs() {
5574 if a >= 0.0 {
5575 0
5576 } else {
5577 2
5578 }
5579 } else if b >= 0.0 {
5580 1
5581 } else {
5582 3
5583 }
5584}
5585
5586/// Whether the face is one of the fourteen the viewer always has, by the
5587/// name the spans carry (#643).
5588fn raw_is_base14(font_name: Option<&str>) -> bool {
5589 font_name.is_some_and(|name| {
5590 name.starts_with("Helvetica")
5591 || name.starts_with("Times")
5592 || name.starts_with("Courier")
5593 || name.starts_with("Symbol")
5594 || name.starts_with("ZapfDingbats")
5595 })
5596}
5597
5598/// A run's vertical metrics in ems, for the tall boxes the raw grouping
5599/// overlaps (#643): the recorded base-14 pair when the face is one, else the
5600/// embedded font's ascent and descent, else Helvetica's recorded pair for a
5601/// base-14 face with no binary, else the engine's embedded fallback. A face
5602/// the metrics lookup missed is an embedded face, not base-14: the caption
5603/// over the form's title gaps it by 3pt in embedded metrics and would drown
5604/// in Helvetica's.
5605fn raw_metrics(span: &TextSpan) -> (f64, f64) {
5606 match span.font_name.as_deref() {
5607 Some("Helvetica") => (f64::from(1.075f32), f64::from(-0.299f32)),
5608 Some("Helvetica-Bold") => (1.07, -0.307),
5609 _ => span
5610 .font_metrics
5611 .map(|m| (m.ascent / 1000.0, m.descent / 1000.0))
5612 .unwrap_or_else(|| {
5613 if raw_is_base14(span.font_name.as_deref()) {
5614 (1.075, -0.299)
5615 } else {
5616 (0.718, -0.207)
5617 }
5618 }),
5619 }
5620}
5621
5622/// A run's tall box: the advance width with the ascender above the baseline
5623/// and the descender below (#643). Bullet rows set 12pt apart at 10pt type
5624/// overlap in these boxes and join one block; in advance boxes they would
5625/// stand 2pt apart and part.
5626fn raw_tall_box(span: &TextSpan) -> [f64; 4] {
5627 let (ascender, descender) = raw_metrics(span);
5628 [
5629 span.x,
5630 span.y + descender * span.font_size,
5631 span.x + span.width,
5632 span.y + ascender * span.font_size,
5633 ]
5634}
5635
5636/// How far apart two runs may stand along the line and still share it, as a
5637/// multiple of the larger type size (#643). The recordings join the 2.65pt
5638/// gap at 7pt type (0.38em, `Deceased` to `MM`) and part the 6.5pt gap at 8pt
5639/// type (0.81em, the dot leaders) and the 10.5pt gap at 10pt type (1.05em,
5640/// the bullets from their text).
5641const RAW_LINE_GAP_EM: f64 = 0.6;
5642
5643/// How far back past the line's end a run may start and still join it, as a
5644/// multiple of the larger type size (#643). The `1a` line number reads back
5645/// 2.2pt at 8pt type (0.28em) into its own line, while a new row reads back
5646/// whole ems (`/` 46pt at 7pt type past `YYYY`). Shares the forward gap's
5647/// threshold; the back side is untested between 0.28em and 6.6em.
5648const RAW_LINE_BACK_EM: f64 = 0.6;
5649
5650/// Cut a block's runs into lines: the x-runs (#643).
5651///
5652/// A run joins the open line while it reads in the line's direction, its
5653/// tall box overlaps the run drawn just before it, and it starts within
5654/// sixtenths of an em of the line's end on either side. Anything else --
5655/// the bullets past their 10.5pt gap, `25` back left of `U.S.`, the caption
5656/// under the title -- opens the next line. Returns the line each run lands
5657/// on, in run order.
5658fn raw_line_of(spans: &[TextSpan]) -> Vec<usize> {
5659 let mut lines = Vec::with_capacity(spans.len());
5660 let mut line = 0;
5661 let mut end = f64::NEG_INFINITY;
5662 let mut low = f64::NEG_INFINITY;
5663 let mut high = f64::NEG_INFINITY;
5664 let mut direction = 0;
5665 let mut size = 0.0f64;
5666 for span in spans {
5667 let dir = raw_direction(span);
5668 let tall = raw_tall_box(span);
5669 let overlap = tall[1] <= high && tall[3] >= low;
5670 let gap = span.x - end;
5671 let em = size.max(span.font_size);
5672 if !lines.is_empty()
5673 && (dir != direction
5674 || !overlap
5675 || span.x < end - RAW_LINE_BACK_EM * em
5676 || gap > RAW_LINE_GAP_EM * em)
5677 {
5678 line += 1;
5679 }
5680 direction = dir;
5681 end = span.x + span.width;
5682 low = tall[1];
5683 high = tall[3];
5684 size = span.font_size;
5685 lines.push(line);
5686 }
5687 lines
5688}
5689
5690/// Rebuild a raw block's text and box from its spans (#643).
5691///
5692/// Lines join with a newline, runs within a line concatenate as drawn -- the
5693/// spaces are inside the runs, and the fillers between them arrive with E2c.
5694/// Shared with the hidden-text drop, so a raw block that loses spans keeps
5695/// raw text.
5696pub(crate) fn raw_text_and_box(spans: &[TextSpan]) -> (String, [f64; 4]) {
5697 let mut text = String::new();
5698 let lines = raw_line_of(spans);
5699 for (i, span) in spans.iter().enumerate() {
5700 if i > 0 && lines[i] != lines[i - 1] {
5701 text.push('\n');
5702 }
5703 text.push_str(&span.text);
5704 }
5705 let mut bbox = [
5706 f64::INFINITY,
5707 f64::INFINITY,
5708 f64::NEG_INFINITY,
5709 f64::NEG_INFINITY,
5710 ];
5711 for span in spans {
5712 bbox[0] = bbox[0].min(span.x);
5713 bbox[1] = bbox[1].min(span.y);
5714 bbox[2] = bbox[2].max(span.x + span.width);
5715 bbox[3] = bbox[3].max(span.y + span.height);
5716 }
5717 (text, bbox)
5718}
5719
5720/// Seal the runs drawn so far into one raw paragraph block.
5721fn push_raw_block(blocks: &mut Vec<Block>, spans: Vec<TextSpan>, page: usize, index: usize) {
5722 if spans.is_empty() {
5723 return;
5724 }
5725 let (text, bbox) = raw_text_and_box(&spans);
5726 let mut block = Block::new(BlockKind::Paragraph, page, bbox, text, index);
5727 block.spans = spans;
5728 blocks.push(block);
5729}
5730
5731/// How much of the previous line's tall reach an overlapping row must cover
5732/// to join its block when it reads forward (#643). The recordings join from
5733/// 0.69 up (`Digital Assets` to its row) and part at 0.375 (the staggered
5734/// checkbox rows) and 0.10 (into `2a`).
5735const RAW_FWD_OVERLAP_FRAC: f64 = 0.5;
5736
5737/// How far a gapped row may stand past the previous line and still read on,
5738/// in ems of the larger type size (#643). Past a gap the recordings join
5739/// to 0.125 and part from 0.186; the size-up joins reach 0.15 through the
5740/// size rule below, not through this gap.
5741const RAW_BLOCK_GAP_EM: f64 = 0.15;
5742
5743/// How wide a gapped line may be and still read on, in points (#643). The
5744/// recordings join to 128pt at the same size and part from 178pt (the
5745/// `Married filing jointly` row) and 348pt; the two wider joins (199pt,
5746/// 224pt) read on as larger type through the size rule below.
5747const RAW_BLOCK_WIDTH_PT: f64 = 150.0;
5748
5749/// How much larger a gapped row may be and always read on, as a multiple of
5750/// the previous line's size (#643). The recordings join size-ups from 1.25
5751/// (section heads after wrapped lines; the two wider ones only through this
5752/// rule). Same-size rows part through the gap and width rules; nothing
5753/// between 1.0 and 1.25 tests the threshold from below.
5754const RAW_SIZE_UP_RATIO: f64 = 1.2;
5755
5756/// One cut line with the features the block rule reads (#643).
5757struct RawLine {
5758 spans: Vec<TextSpan>,
5759 bdc_first: Option<u32>,
5760 bdc_last: Option<u32>,
5761 x0: f64,
5762 x1: f64,
5763 low: f64,
5764 high: f64,
5765 direction: u8,
5766 size: f64,
5767}
5768
5769impl RawLine {
5770 fn width(&self) -> f64 {
5771 self.x1 - self.x0
5772 }
5773
5774 fn height(&self) -> f64 {
5775 self.high - self.low
5776 }
5777}
5778
5779/// Whether the next line opens a new block after the previous one (#643).
5780///
5781/// One marked-content segment never parts; a turn of the reading direction
5782/// always does. Between rows that overlap, a row that reads back joins, and
5783/// a row that reads forward joins while it covers half the previous reach.
5784/// Past a gap, a row that reads forward parts, and a row that reads back
5785/// joins when it opens larger type or reads on: same size or larger, a
5786/// short line, a small gap.
5787fn raw_block_split(before: &RawLine, after: &RawLine) -> bool {
5788 if after.direction != before.direction {
5789 return true;
5790 }
5791 if let (Some(a), Some(b)) = (before.bdc_last, after.bdc_first) {
5792 if a == b {
5793 return false;
5794 }
5795 }
5796 let overlap = before.high.min(after.high) - before.low.max(after.low);
5797 let motion = after.x0 - before.x1;
5798 if overlap > 0.0 {
5799 if motion <= 0.5 {
5800 return false;
5801 }
5802 return overlap / before.height() < RAW_FWD_OVERLAP_FRAC;
5803 }
5804 if motion > 0.5 {
5805 return true;
5806 }
5807 let ratio = after.size / before.size;
5808 if ratio >= RAW_SIZE_UP_RATIO {
5809 return false;
5810 }
5811 let gap_em = -overlap / before.size.max(after.size);
5812 !(ratio >= 1.0 && gap_em <= RAW_BLOCK_GAP_EM && before.width() <= RAW_BLOCK_WIDTH_PT)
5813}
5814
5815/// Group the page's runs into blocks in draw order (#643).
5816///
5817/// Lines cut first, over the whole page, and blocks group whole lines: a
5818/// line never spans a block boundary. The frames turn with the page exactly
5819/// as the default path's do; `bdc` carries each run's marked-content
5820/// segment, in run order.
5821fn raw_blocks(
5822 spans: Vec<TextSpan>,
5823 bdc: &[Option<u32>],
5824 page: usize,
5825 first_index: usize,
5826) -> Vec<Block> {
5827 if spans.is_empty() {
5828 return Vec::new();
5829 }
5830 debug_assert_eq!(bdc.len(), spans.len());
5831 let turn = PageTurn::of(&spans);
5832 let spans = turn.into_frame(spans);
5833 let line_of = raw_line_of(&spans);
5834 let mut lines: Vec<RawLine> = Vec::new();
5835 for (i, span) in spans.into_iter().enumerate() {
5836 let tall = raw_tall_box(&span);
5837 let dir = raw_direction(&span);
5838 let fresh = lines.is_empty() || line_of[i] != line_of[i - 1];
5839 if fresh {
5840 lines.push(RawLine {
5841 spans: Vec::new(),
5842 bdc_first: bdc[i],
5843 bdc_last: bdc[i],
5844 x0: span.x,
5845 x1: span.x + span.width,
5846 low: tall[1],
5847 high: tall[3],
5848 direction: dir,
5849 size: span.font_size,
5850 });
5851 }
5852 if let Some(line) = lines.last_mut() {
5853 line.bdc_last = bdc[i];
5854 line.x0 = line.x0.min(span.x);
5855 line.x1 = line.x1.max(span.x + span.width);
5856 line.low = line.low.min(tall[1]);
5857 line.high = line.high.max(tall[3]);
5858 line.spans.push(span);
5859 }
5860 }
5861 let mut blocks = Vec::new();
5862 let mut current: Vec<TextSpan> = Vec::new();
5863 for i in 0..lines.len() {
5864 let (head, tail) = lines.split_at_mut(i);
5865 let line = &mut tail[0];
5866 let cut = i > 0 && raw_block_split(&head[i - 1], line);
5867 if cut {
5868 let index = first_index + blocks.len();
5869 push_raw_block(&mut blocks, std::mem::take(&mut current), page, index);
5870 }
5871 current.append(&mut line.spans);
5872 }
5873 if !current.is_empty() {
5874 let index = first_index + blocks.len();
5875 push_raw_block(&mut blocks, current, page, index);
5876 }
5877 turn.back_blocks(blocks)
5878}
5879
5880fn paragraph_blocks(
5881 spans: Vec<TextSpan>,
5882 rules: &[HorizontalRule],
5883 page: usize,
5884 first_index: usize,
5885 table_strategy: TableStrategy,
5886) -> Vec<Block> {
5887 paragraph_blocks_with_cell_path_words(spans, rules, page, first_index, table_strategy, false)
5888}
5889
5890/// [`paragraph_blocks`] with the cell-path word rules where `cell_path_words`
5891/// is true: the leader fold joins abutting dots, and line and cell texts read
5892/// the quarter-em boundary as a space.
5893fn paragraph_blocks_with_cell_path_words(
5894 spans: Vec<TextSpan>,
5895 rules: &[HorizontalRule],
5896 page: usize,
5897 first_index: usize,
5898 table_strategy: TableStrategy,
5899 cell_path_words: bool,
5900) -> Vec<Block> {
5901 if spans.is_empty() {
5902 return Vec::new();
5903 }
5904 let turn = PageTurn::of(&spans);
5905 let spans = turn.into_frame(spans);
5906 let stats = PageStats::from_spans(&spans);
5907 let rules = turn.rules_in_frame(rules);
5908 // Once, before anything is cut: a leader drawn inside its label's string
5909 // is where the label ends and the figure begins.
5910 let spans: Vec<TextSpan> = spans.into_iter().flat_map(split_at_leader).collect();
5911 let (regions, whole): (Vec<Vec<TextBlock>>, Vec<bool>) = xy_cut_round_tables(
5912 spans,
5913 &stats,
5914 &rules,
5915 turn == PageTurn::None,
5916 cell_path_words,
5917 )
5918 .into_iter()
5919 .unzip();
5920 let direction = page_direction(®ions.iter().flatten().cloned().collect::<Vec<_>>());
5921 let regions: Vec<Vec<TextBlock>> = regions
5922 .into_iter()
5923 .map(|region| match direction {
5924 Some(direction) => reassemble_with_direction(direction, region),
5925 None => region,
5926 })
5927 .collect();
5928 let blocks = crate::blocks::paragraphs_with_cell_path_words(
5929 page,
5930 first_index,
5931 ®ions,
5932 &whole,
5933 &rules,
5934 stats.median_line_spacing,
5935 table_strategy,
5936 cell_path_words,
5937 );
5938 // After the merge and before the heading classifier, so the joined block
5939 // is classified like any other (#574).
5940 let blocks = join_marker_continuations(blocks, first_index);
5941 turn.back_blocks(blocks)
5942}
5943
5944/// Join the lines of a double-spaced paragraph back into one block (#574).
5945///
5946/// A passage set on wider leading than the rest of the page clears the
5947/// paragraph-break threshold at every line, so each of its lines arrives here
5948/// as a block of its own: the double-spaced abstract of an arXiv preprint, a
5949/// double-spaced patent description. This pass joins runs of such one-line
5950/// paragraph blocks, guarded so lists, addresses, labels and contents lines
5951/// never join. The rule is a port of the simulated one (`dsim.py`); the guards
5952/// are drawn from the false merges the simulation found.
5953///
5954/// AFTER the heading classifier, and only over [`BlockKind::Paragraph`]:
5955/// stacked headings arrive on the same wide pitch as a double-spaced run, and
5956/// joining before they are marked turns two headings into one paragraph
5957/// (#636).
5958pub(crate) fn join_double_spaced_runs(blocks: Vec<Block>, first_index: usize) -> Vec<Block> {
5959 let sizes: Vec<f64> = blocks.iter().map(block_size).collect();
5960 let single = |i: usize| {
5961 matches!(blocks[i].kind, BlockKind::Paragraph) && block_line_count(&blocks[i]) == 1
5962 };
5963 // The white between a block and the one above it, in ems of the upper.
5964 let gap_above = |i: usize| {
5965 let em = sizes[i - 1];
5966 let em = if em > 0.0 { em } else { 10.0 };
5967 (blocks[i - 1].bbox[1] - blocks[i].bbox[3]) / em
5968 };
5969 let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
5970 let mut i = 0;
5971 while i < blocks.len() {
5972 if !single(i) {
5973 out.push(blocks[i].clone());
5974 i += 1;
5975 continue;
5976 }
5977 let mut run = vec![i];
5978 let mut j = i + 1;
5979 while j < blocks.len() {
5980 let a = run[run.len() - 1];
5981 if blocks[j].page != blocks[a].page || !single(j) {
5982 break;
5983 }
5984 let em = if sizes[a] > 0.0 { sizes[a] } else { 10.0 };
5985 if (sizes[j] - sizes[a]).abs() > DOUBLE_SPACED_SIZE_TOLERANCE_PT {
5986 break;
5987 }
5988 if (blocks[j].bbox[0] - blocks[run[0]].bbox[0]).abs() > DOUBLE_SPACED_LEFT_EM * em {
5989 break;
5990 }
5991 let gap = gap_above(j);
5992 if !(DOUBLE_SPACED_GAP_MIN_EM..=DOUBLE_SPACED_GAP_MAX_EM).contains(&gap) {
5993 break;
5994 }
5995 run.push(j);
5996 j += 1;
5997 }
5998 if run.len() < 2 {
5999 out.push(blocks[i].clone());
6000 i += 1;
6001 continue;
6002 }
6003 let gaps: Vec<f64> = run.iter().skip(1).map(|&k| gap_above(k)).collect();
6004 let median = median_gap(gaps);
6005 let right = run
6006 .iter()
6007 .map(|&k| blocks[k].bbox[2])
6008 .fold(f64::NEG_INFINITY, f64::max);
6009 let mut current = blocks[run[0]].clone();
6010 for pair in run.windows(2) {
6011 let (a, c) = (pair[0], pair[1]);
6012 let em = if sizes[a] > 0.0 { sizes[a] } else { 10.0 };
6013 let upper = blocks[a].text.trim();
6014 let lower = blocks[c].text.trim();
6015 let fills_the_measure = blocks[a].bbox[2] >= right - DOUBLE_SPACED_SHORT_EM * em;
6016 let join = fills_the_measure
6017 && gap_above(c) <= DOUBLE_SPACED_GAP_RATIO * median
6018 && !starts_with_marker(lower)
6019 && !double_spaced_guard(upper, lower);
6020 if join {
6021 if is_hyphen_wrap_candidate(¤t.text, lower) {
6022 current.text.pop();
6023 current.text.push_str(lower);
6024 } else {
6025 current.text.push(' ');
6026 current.text.push_str(lower);
6027 }
6028 current.spans.extend(blocks[c].spans.iter().cloned());
6029 current.bbox = [
6030 current.bbox[0].min(blocks[c].bbox[0]),
6031 current.bbox[1].min(blocks[c].bbox[1]),
6032 current.bbox[2].max(blocks[c].bbox[2]),
6033 current.bbox[3].max(blocks[c].bbox[3]),
6034 ];
6035 } else {
6036 out.push(current);
6037 current = blocks[c].clone();
6038 }
6039 }
6040 out.push(current);
6041 i = j;
6042 }
6043 for (n, block) in out.iter_mut().enumerate() {
6044 block.index = first_index + n;
6045 }
6046 out
6047}
6048
6049/// Join a marker line back to its hanging continuation (#574).
6050///
6051/// The merge cuts a marker-led line off its continuation at a change of
6052/// weight -- `(74) Agent: ...` over `Road, Kennett Square ...`, bold over
6053/// regular -- and the heading classifier then reads the marker line as a
6054/// heading. Where the continuation hangs under the marker line's text at
6055/// normal leading, the cut is undone here, before classification, so the
6056/// joined block is classified like any other. Indices are renumbered from
6057/// `first_index`; nothing else moves.
6058fn join_marker_continuations(blocks: Vec<Block>, first_index: usize) -> Vec<Block> {
6059 let mut out: Vec<Block> = Vec::with_capacity(blocks.len());
6060 for block in blocks {
6061 let join = match out.last() {
6062 Some(prev) if prev.page == block.page => {
6063 let prev_lines = lines_of_block(prev);
6064 let next_lines = lines_of_block(&block);
6065 match (prev_lines.first(), prev_lines.last(), next_lines.first()) {
6066 (Some(first), Some(last), Some(c)) => {
6067 marker_line_hangs_onto(prev, first, last, &block, c)
6068 }
6069 _ => false,
6070 }
6071 }
6072 _ => false,
6073 };
6074 if join {
6075 let prev = out.last_mut().expect("a previous block was checked");
6076 let text = crate::blocks::join_lines(&[prev.text.clone(), block.text.clone()]);
6077 prev.spans.extend(block.spans);
6078 prev.text = text;
6079 crate::blocks::recompute_bbox(prev);
6080 } else {
6081 out.push(block);
6082 }
6083 }
6084 for (n, block) in out.iter_mut().enumerate() {
6085 block.index = first_index + n;
6086 }
6087 out
6088}
6089
6090/// The white a line of a double-spaced run stands under, in ems of the line
6091/// above it (#574). Under six tenths the lines were never split; over three
6092/// the white is a zone break however regularly it recurs.
6093const DOUBLE_SPACED_GAP_MIN_EM: f64 = 0.6;
6094/// See [`DOUBLE_SPACED_GAP_MIN_EM`].
6095const DOUBLE_SPACED_GAP_MAX_EM: f64 = 3.0;
6096
6097/// How far the type size may differ between two lines of one run, in points.
6098const DOUBLE_SPACED_SIZE_TOLERANCE_PT: f64 = 0.5;
6099
6100/// How far a line's left edge may stand from the run's first, in ems.
6101const DOUBLE_SPACED_LEFT_EM: f64 = 0.5;
6102
6103/// How far short of the run's measure a line may fall and still be read as
6104/// filling it, in ems: a justified line does, an address line does not.
6105const DOUBLE_SPACED_SHORT_EM: f64 = 1.5;
6106
6107/// How far a line's gap may stand above the run's own median and still join.
6108const DOUBLE_SPACED_GAP_RATIO: f64 = 1.5;
6109
6110/// The type size most of the block's glyphs are set in, as the feature dump
6111/// measures it: one vote per glyph box, one per span without boxes.
6112fn block_size(block: &Block) -> f64 {
6113 let mut sizes: Vec<f64> = block
6114 .spans
6115 .iter()
6116 .flat_map(|span| {
6117 let votes = span.char_bounds.len().max(1);
6118 std::iter::repeat_n(span.font_size, votes)
6119 })
6120 .collect();
6121 if sizes.is_empty() {
6122 return 0.0;
6123 }
6124 sizes.sort_by(|a, b| a.total_cmp(b));
6125 sizes[sizes.len() / 2]
6126}
6127
6128/// How many lines the block holds, as the feature dump counts them: the
6129/// distinct baselines, rounded to half a point.
6130fn block_line_count(block: &Block) -> usize {
6131 let mut baselines: Vec<f64> = block
6132 .spans
6133 .iter()
6134 .map(|span| (span.y * 2.0).round() / 2.0)
6135 .collect();
6136 baselines.sort_by(|a, b| a.total_cmp(b));
6137 baselines.dedup();
6138 baselines.len()
6139}
6140
6141/// The middle of the run's own gaps, averaged where the run has an even
6142/// number of them, as `statistics.median` answers.
6143fn median_gap(mut gaps: Vec<f64>) -> f64 {
6144 gaps.sort_by(|a, b| a.total_cmp(b));
6145 match gaps.len() {
6146 0 => 0.0,
6147 len if len % 2 == 1 => gaps[len / 2],
6148 len => (gaps[len / 2 - 1] + gaps[len / 2]) / 2.0,
6149 }
6150}
6151
6152/// Whether a line opens with a list marker: `[0012]`, `1.` / `1)` / `(1)`,
6153/// `a)` / `(a)`, a Roman `IV. `, a bullet, or a section marker (`§`, `Art.`).
6154fn starts_with_marker(text: &str) -> bool {
6155 starts_with_bracket_number(text)
6156 || starts_with_numbered_marker(text)
6157 || starts_with_letter_marker(text)
6158 || starts_with_roman_marker(text)
6159 || starts_with_bullet(text)
6160 || starts_with_section_marker(text)
6161}
6162
6163/// `§ 2 ...`, `Art. 3 ...`: a section line never joins the one above it
6164/// (#636).
6165fn starts_with_section_marker(text: &str) -> bool {
6166 let trimmed = text.trim_start();
6167 trimmed.starts_with('§') || trimmed.starts_with("Art.")
6168}
6169
6170/// `[0012]`: an opening bracket, three to five digits, a closing bracket.
6171fn starts_with_bracket_number(text: &str) -> bool {
6172 let Some(rest) = text.trim_start().strip_prefix('[') else {
6173 return false;
6174 };
6175 let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
6176 (3..=5).contains(&digits) && rest.chars().nth(digits) == Some(']')
6177}
6178
6179/// `1.` / `1)` / `(1)`: an optional opening bracket, one to three digits, a
6180/// stop or a closing bracket.
6181fn starts_with_numbered_marker(text: &str) -> bool {
6182 let trimmed = text.trim_start();
6183 let rest = trimmed.strip_prefix('(').unwrap_or(trimmed);
6184 let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
6185 (1..=3).contains(&digits) && matches!(rest.chars().nth(digits), Some('.') | Some(')'))
6186}
6187
6188/// `a)` / `(a)`: an optional opening bracket, a lowercase letter, a stop or a
6189/// closing bracket, and the space after it.
6190fn starts_with_letter_marker(text: &str) -> bool {
6191 let trimmed = text.trim_start();
6192 let rest = trimmed.strip_prefix('(').unwrap_or(trimmed);
6193 let mut chars = rest.chars();
6194 matches!(
6195 (chars.next(), chars.next(), chars.next()),
6196 (Some(letter), Some(stop), Some(space))
6197 if letter.is_ascii_lowercase()
6198 && matches!(stop, '.' | ')')
6199 && space.is_whitespace()
6200 )
6201}
6202
6203/// `IV. `: one to four Roman capitals, a stop, and the space after it.
6204fn starts_with_roman_marker(text: &str) -> bool {
6205 let trimmed = text.trim_start();
6206 let numerals = trimmed
6207 .chars()
6208 .take_while(|c| matches!(c, 'I' | 'V' | 'X'))
6209 .count();
6210 if !(1..=4).contains(&numerals) {
6211 return false;
6212 }
6213 let mut chars = trimmed.chars().skip(numerals);
6214 matches!((chars.next(), chars.next()), (Some('.'), Some(space)) if space.is_whitespace())
6215}
6216
6217/// `• item`: a bullet and the space after it.
6218fn starts_with_bullet(text: &str) -> bool {
6219 let mut chars = text.trim_start().chars();
6220 matches!(
6221 (chars.next(), chars.next()),
6222 (Some(bullet), Some(space))
6223 if matches!(bullet, '•' | '●' | '▪' | '■' | '–' | '—' | '-' | '*')
6224 && space.is_whitespace()
6225 )
6226}
6227
6228/// Whether the pair must stay two blocks: every guard is drawn from a false
6229/// merge the simulation found (#574).
6230fn double_spaced_guard(upper: &str, lower: &str) -> bool {
6231 starts_with_number_then_capital(lower)
6232 || ends_in_digit(upper)
6233 || holds_key_value(upper)
6234 || lower.ends_with(':')
6235 || reads_as_heading(lower)
6236 || ends_sentence_then_capital(upper, lower)
6237}
6238
6239/// `26 Consolidated Statements`: a contents line, a number and a capital word.
6240fn starts_with_number_then_capital(text: &str) -> bool {
6241 let digits = text.chars().take_while(|c| c.is_ascii_digit()).count();
6242 if !(1..=4).contains(&digits) {
6243 return false;
6244 }
6245 let mut chars = text.chars().skip(digits);
6246 let mut spaces = 0;
6247 for c in chars.by_ref() {
6248 if c.is_whitespace() {
6249 spaces += 1;
6250 } else {
6251 return spaces > 0 && c.is_ascii_uppercase();
6252 }
6253 }
6254 false
6255}
6256
6257/// `Ausfertigungsdatum: 20.12.1963`: a contents line ends in a digit, whatever
6258/// punctuation follows it.
6259fn ends_in_digit(text: &str) -> bool {
6260 text.trim_end()
6261 .trim_end_matches(|c: char| !(c.is_alphanumeric() || c == '_'))
6262 .chars()
6263 .next_back()
6264 .is_some_and(|c| c.is_ascii_digit())
6265}
6266
6267/// `Key: value`: a line holding a colon followed by text is a label and its
6268/// value, not the start of a sentence.
6269fn holds_key_value(text: &str) -> bool {
6270 text.match_indices(':')
6271 .any(|(at, _)| !text[at + 1..].trim_start().is_empty())
6272}
6273
6274/// `Leases`: three words or fewer with no closing punctuation is a heading.
6275fn reads_as_heading(text: &str) -> bool {
6276 text.split_whitespace().count() <= 3 && !text.ends_with(['.', '!', '?', ';', ','])
6277}
6278
6279/// A full stop and a capital on the same pitch: two paragraphs, not one.
6280fn ends_sentence_then_capital(upper: &str, lower: &str) -> bool {
6281 upper.ends_with(['.', '!', '?']) && lower.chars().next().is_some_and(|c| c.is_uppercase())
6282}
6283
6284/// Characters a marker-led line opens with as a bullet (#574).
6285///
6286/// The dozen the patent front pages and the annual reports use. Both
6287/// triangular bullets are here because the two codepoints render as one
6288/// glyph and either may be what the page drew.
6289const MARKER_BULLETS: [char; 13] = [
6290 '\u{2022}', // bullet
6291 '\u{25CF}', // black circle
6292 '\u{25AA}', // black small square
6293 '\u{25E6}', // white bullet
6294 '\u{2023}', // triangular bullet
6295 '\u{2043}', // hyphen bullet
6296 '\u{2219}', // bullet operator
6297 '\u{00B7}', // middle dot
6298 '\u{2013}', // en dash
6299 '\u{2014}', // em dash
6300 '-', // hyphen-minus
6301 '*', // asterisk
6302 '>', // greater-than
6303];
6304
6305/// Whether a letter is a roman digit, either case (#574).
6306fn is_roman_digit(c: char) -> bool {
6307 matches!(
6308 c.to_ascii_lowercase(),
6309 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm'
6310 )
6311}
6312
6313/// The marker a line opens with, if it opens with one (#574).
6314///
6315/// A bullet, `(n)` or `[n]` of one to three digits, `n.` or `n)`, `a.`,
6316/// `a)` or `(a)`, or a roman numeral `(i)` or `iv.`. Ordered markers want
6317/// white space and text after the delimiter, so `3.14` and a name's `A.`
6318/// stay prose; a bullet wants text after it. Answers the marker's length
6319/// in bytes without the trailing space, and its inked characters, which is
6320/// what maps the marker onto the spans that drew it.
6321fn marker_prefix(text: &str) -> Option<(usize, usize)> {
6322 let trimmed = text.trim_start();
6323 if trimmed.is_empty() {
6324 return None;
6325 }
6326 let first = trimmed.chars().next()?;
6327 if MARKER_BULLETS.contains(&first) {
6328 let rest = &trimmed[first.len_utf8()..];
6329 if rest.trim().is_empty() {
6330 return None;
6331 }
6332 return Some((first.len_utf8(), 1));
6333 }
6334 if first == '(' || first == '[' {
6335 let closing = if first == '(' { ')' } else { ']' };
6336 let body = &trimmed[first.len_utf8()..];
6337 let digits = body.chars().take_while(|c| c.is_ascii_digit()).count();
6338 if (1..=3).contains(&digits) {
6339 let mut rest = &body[digits..];
6340 if rest.starts_with(closing) {
6341 rest = &rest[closing.len_utf8()..];
6342 if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
6343 return Some((first.len_utf8() + digits + closing.len_utf8(), 2 + digits));
6344 }
6345 }
6346 }
6347 if first == '(' {
6348 let letters = body.chars().take_while(|c| c.is_ascii_alphabetic()).count();
6349 if (1..=6).contains(&letters) {
6350 let word = &body[..letters];
6351 if letters == 1 || word.chars().all(is_roman_digit) {
6352 let mut rest = &body[letters..];
6353 if rest.starts_with(')') {
6354 rest = &rest[1..];
6355 if rest.starts_with(char::is_whitespace) && !rest.trim().is_empty() {
6356 return Some((1 + letters + 1, 2 + letters));
6357 }
6358 }
6359 }
6360 }
6361 }
6362 return None;
6363 }
6364 if first.is_ascii_digit() {
6365 let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
6366 if (1..=3).contains(&digits) {
6367 let rest = &trimmed[digits..];
6368 if matches!(rest.chars().next(), Some('.') | Some(')')) {
6369 let after = &rest[1..];
6370 if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
6371 return Some((digits + 1, digits + 1));
6372 }
6373 }
6374 }
6375 return None;
6376 }
6377 if first.is_ascii_alphabetic() {
6378 let letters = trimmed
6379 .chars()
6380 .take_while(|c| c.is_ascii_alphabetic())
6381 .count();
6382 if letters == 1 {
6383 let rest = &trimmed[1..];
6384 if matches!(rest.chars().next(), Some('.') | Some(')')) {
6385 let after = &rest[1..];
6386 if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
6387 return Some((2, 2));
6388 }
6389 }
6390 return None;
6391 }
6392 if (2..=6).contains(&letters) {
6393 let word = &trimmed[..letters];
6394 if word.chars().all(is_roman_digit) {
6395 let rest = &trimmed[letters..];
6396 if matches!(rest.chars().next(), Some('.') | Some(')')) {
6397 let after = &rest[1..];
6398 if after.starts_with(char::is_whitespace) && !after.trim().is_empty() {
6399 return Some((letters + 1, letters + 1));
6400 }
6401 }
6402 }
6403 }
6404 return None;
6405 }
6406 None
6407}
6408
6409/// The vertical extent of a span, from its glyph boxes where they exist and
6410/// from the type size where they do not.
6411fn span_bottom_top(span: &TextSpan) -> (f64, f64) {
6412 let mut bottom = f64::INFINITY;
6413 let mut top = f64::NEG_INFINITY;
6414 for bounds in &span.char_bounds {
6415 bottom = bottom.min(bounds[1]);
6416 top = top.max(bounds[3]);
6417 }
6418 if bottom.is_finite() && top.is_finite() && top > bottom {
6419 return (bottom, top);
6420 }
6421 (span.y - span.font_size * 0.2, span.y + span.font_size * 0.8)
6422}
6423
6424/// Characters that are not white space: what a marker is counted in, because
6425/// the line's text joins its spans with spaces the spans never drew.
6426fn inked_chars(text: &str) -> usize {
6427 text.chars().filter(|c| !c.is_whitespace()).count()
6428}
6429
6430/// Where the text after a marker line's marker begins: the left edge of the
6431/// first span the marker does not reach into (#574).
6432///
6433/// `None` where the marker is glued inside its line's first span and has no
6434/// column of its own to measure. A run of white space after the marker is
6435/// the spacing the page set it apart with, not where its text begins.
6436fn text_left_after_marker(block: &Block, line: &[usize], marker_inked: usize) -> Option<f64> {
6437 if marker_inked == 0 {
6438 return None;
6439 }
6440 let mut seen = 0usize;
6441 for i in line {
6442 let span = &block.spans[*i];
6443 let count = inked_chars(&span.text);
6444 if seen == marker_inked {
6445 if count == 0 {
6446 continue;
6447 }
6448 return Some(span.x);
6449 }
6450 if seen > marker_inked {
6451 return None;
6452 }
6453 seen += count;
6454 }
6455 None
6456}
6457
6458/// Whether the line c continues the marker block a: all six clauses of the
6459/// marker-hang rule (#574).
6460///
6461/// a's FIRST line opens with a marker and its LAST line does not end in a
6462/// colon, c hangs under the first line's text at normal leading without
6463/// opening a marker of its own, and the last line ran to the measure rather
6464/// than turning back. The hang is read off the first line's own text column
6465/// where the page drew the marker in a span of its own, and off its left
6466/// edge where it did not.
6467///
6468/// Two lines of a rather than one, because the marker item may wrap over
6469/// several lines before the weight cut parts it from its continuation: the
6470/// marker is on the first line and the wrap on the last, and reading both
6471/// off the last line leaves a wrapped item uncovered.
6472fn marker_line_hangs_onto(
6473 a_block: &Block,
6474 a_first: &[usize],
6475 a_last: &[usize],
6476 c_block: &Block,
6477 c_line: &[usize],
6478) -> bool {
6479 let a_text = line_text(a_block, a_first);
6480 let c_text = line_text(c_block, c_line);
6481 let Some((_, a_inked)) = marker_prefix(&a_text) else {
6482 return false;
6483 };
6484 if marker_prefix(&c_text).is_some() {
6485 return false;
6486 }
6487 if line_text(a_block, a_last).trim_end().ends_with(':') {
6488 return false;
6489 }
6490 let anchor_of = |block: &Block, line: &[usize]| -> Option<usize> {
6491 line.iter()
6492 .copied()
6493 .max_by_key(|i| inked_chars(&block.spans[*i].text))
6494 };
6495 let (Some(a_at), Some(c_at)) = (anchor_of(a_block, a_last), anchor_of(c_block, c_line)) else {
6496 return false;
6497 };
6498 let a_anchor = &a_block.spans[a_at];
6499 let c_anchor = &c_block.spans[c_at];
6500 let em = a_anchor.font_size;
6501 if !em.is_finite() || em <= 0.0 {
6502 return false;
6503 }
6504 if a_anchor.y <= c_anchor.y {
6505 return false;
6506 }
6507 let left_of = |block: &Block, line: &[usize]| {
6508 line.iter()
6509 .map(|i| block.spans[*i].x)
6510 .fold(f64::INFINITY, f64::min)
6511 };
6512 let right_of = |block: &Block, line: &[usize]| {
6513 line.iter()
6514 .map(|i| crate::blocks::span_right(&block.spans[*i]))
6515 .fold(f64::NEG_INFINITY, f64::max)
6516 };
6517 let a_left = left_of(a_block, a_first);
6518 let a_right = right_of(a_block, a_last);
6519 let c_left = left_of(c_block, c_line);
6520 let c_right = right_of(c_block, c_line);
6521 if !(a_left.is_finite() && a_right.is_finite() && c_left.is_finite() && c_right.is_finite()) {
6522 return false;
6523 }
6524 if let Some(text_left) = text_left_after_marker(a_block, a_first, a_inked) {
6525 if (c_left - text_left).abs() > MARKER_TEXT_LEFT_TOL_EM * em {
6526 return false;
6527 }
6528 } else if !(c_left > a_left + MARKER_HANG_MIN_EM * em
6529 && c_left < a_left + MARKER_HANG_MAX_EM * em)
6530 {
6531 return false;
6532 }
6533 if a_right < a_right.max(c_right) - MARKER_WRAP_SLACK_EM * em {
6534 return false;
6535 }
6536 let a_bottom = a_last
6537 .iter()
6538 .map(|i| span_bottom_top(&a_block.spans[*i]).0)
6539 .fold(f64::INFINITY, f64::min);
6540 let c_top = c_line
6541 .iter()
6542 .map(|i| span_bottom_top(&c_block.spans[*i]).1)
6543 .fold(f64::NEG_INFINITY, f64::max);
6544 if !(a_bottom.is_finite() && c_top.is_finite()) {
6545 return false;
6546 }
6547 if ((a_bottom - c_top) / em) >= MARKER_GAP_MAX_EM {
6548 return false;
6549 }
6550 true
6551}
6552
6553/// The spans of a block grouped into lines, top first, each line left to
6554/// right. The same grouping the feature dump reads words by: sorted by
6555/// baseline, then by x, a new line where the baseline steps by half an em.
6556///
6557/// `pub(crate)` for the markdown serializer, which reads an all-monospace
6558/// paragraph's lines back out of it as a fenced block (#636).
6559pub(crate) fn lines_of_block(block: &Block) -> Vec<Vec<usize>> {
6560 let mut order: Vec<usize> = (0..block.spans.len()).collect();
6561 order.sort_by(|a, b| {
6562 block.spans[*b]
6563 .y
6564 .partial_cmp(&block.spans[*a].y)
6565 .unwrap_or(Ordering::Equal)
6566 .then_with(|| {
6567 block.spans[*a]
6568 .x
6569 .partial_cmp(&block.spans[*b].x)
6570 .unwrap_or(Ordering::Equal)
6571 })
6572 });
6573 let mut lines: Vec<Vec<usize>> = Vec::new();
6574 let mut tops: Vec<f64> = Vec::new();
6575 for idx in order {
6576 let size = block.spans[idx].font_size;
6577 let tolerance = if size > 0.0 { 0.5 * size } else { 1e-6 };
6578 if let (Some(last), Some(top)) = (lines.last_mut(), tops.last().copied()) {
6579 if (top - block.spans[idx].y).abs() < tolerance {
6580 last.push(idx);
6581 continue;
6582 }
6583 }
6584 lines.push(vec![idx]);
6585 tops.push(block.spans[idx].y);
6586 }
6587 for line in &mut lines {
6588 line.sort_by(|a, b| {
6589 block.spans[*a]
6590 .x
6591 .partial_cmp(&block.spans[*b].x)
6592 .unwrap_or(Ordering::Equal)
6593 });
6594 }
6595 lines
6596}
6597
6598/// One line as it reads: its spans left to right through the same assembly
6599/// the merge built the block's text from.
6600///
6601/// `pub(crate)` beside [`lines_of_block`], for the same fenced block (#636).
6602pub(crate) fn line_text(block: &Block, line: &[usize]) -> String {
6603 let spans: Vec<TextSpan> = line.iter().map(|i| block.spans[*i].clone()).collect();
6604 TextBlock { spans }.text()
6605}
6606
6607/// The quarter turn that puts a page which is itself turned back in the frame
6608/// it was written in (#546).
6609///
6610/// A page carries a `/Rotate`, of its own or inherited from the `Pages` tree,
6611/// and `Page::initial_transform` turns every glyph on it with the page: the
6612/// text device sees a page of lines running up, down or backwards. Bands are
6613/// horizontal and they read one direction, so on such a page they lie ACROSS
6614/// its lines: each glyph of a line lands in a band of its own and the page
6615/// reads as letter salad -- `ro t a ti o n 9 0` where pdfbox `PDFBOX-6049`
6616/// says `rotation 90`, and `081 noitator` on its half-turned page. This is the
6617/// same failure #435 fixed for a run drawn against the direction of its page,
6618/// and it stayed on the pages where the turned direction is the MAJORITY: what
6619/// #435 assembles there is the upright text on a turned page.
6620///
6621/// So the layout runs in the frame the page was written in. Every span is
6622/// turned into the direction most of the page's glyphs are drawn in before
6623/// `PageStats`, the cut and the bands, and the blocks are turned back before
6624/// they are returned, which leaves the spans the caller gets in page space to
6625/// the bit.
6626///
6627/// QUARTER TURNS ONLY, and the reason is exactness rather than taste: the sine
6628/// and cosine of a quarter turn are 0 and ±1, so the turn and its inverse move
6629/// no coordinate by a single bit, and a page this pass declines to turn comes
6630/// out of it byte for byte as it went in. A page `/Rotate` is a multiple of 90
6631/// by §7.7.3.3, which is the whole of what this is about; a page drawn entirely
6632/// at a free angle keeps its own bands, exactly as it did before (they read it:
6633/// `a_page_written_entirely_at_an_angle_keeps_its_own_bands`).
6634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6635enum PageTurn {
6636 /// The page reads left to right already, or in no direction this undoes.
6637 None,
6638 /// Its lines run up the page: `/Rotate 90`.
6639 Quarter,
6640 /// Its lines run right to left: `/Rotate 180`.
6641 Half,
6642 /// Its lines run down the page: `/Rotate 270`.
6643 ThreeQuarter,
6644}
6645
6646impl PageTurn {
6647 /// The turn that makes the direction the page is WRITTEN in upright.
6648 ///
6649 /// Written in, and not merely the heaviest of: this turns the layout of a
6650 /// whole page, so the direction has to carry more than half its glyphs.
6651 /// The plurality rule #435 picks its cross-direction runs by is the right
6652 /// one there -- whatever is not the page's direction is a run on it -- and
6653 /// the wrong one here, because a page with no majority direction has no
6654 /// frame to be read in and turning it by a 26% plurality moves every block
6655 /// on it. pdfbox `AcroFormsRotation` is that page: four blocks of form
6656 /// fields at the four rotations, a quarter of the glyphs each, and the
6657 /// plurality rule turned its UPRIGHT page and put its fields in an order
6658 /// neither mutool nor poppler reads them in (order agreement 100 -> 50 on
6659 /// the gate set, where nothing else on 500 documents moved at all).
6660 /// A REFLECTION IS NOT A TURN, and only the determinant says which it is
6661 /// (codex, review round 2). `heaviest_direction` reads the baseline vector,
6662 /// and a horizontal reflection such as `[-1, 0, 0, 1, e, f]` runs its
6663 /// baseline in `(-1, 0)` exactly as a half turn does. Classifying that page
6664 /// as `Half` makes [`Self::into_frame`] negate BOTH coordinates, so a
6665 /// mirrored block whose lines descend the page comes back bottom to top --
6666 /// a reading order this pass invented out of a page nobody turned.
6667 ///
6668 /// Reflected glyphs are therefore counted in the page's total and in no
6669 /// direction's share. A page mostly drawn mirrored cannot reach a majority
6670 /// and comes out of this pass byte for byte as it went in, which is what
6671 /// `Self::None` means here.
6672 fn of(spans: &[TextSpan]) -> Self {
6673 let page_total: usize = spans.iter().map(glyph_weight).sum();
6674 if page_total == 0 {
6675 return Self::None;
6676 }
6677 let turned: Vec<&TextSpan> = spans
6678 .iter()
6679 .filter(|span| is_a_proper_rotation(span))
6680 .collect();
6681 let turned_total: usize = turned.iter().copied().map(glyph_weight).sum();
6682 let Some((page, share_of_turned)) = heaviest_direction(turned.into_iter()) else {
6683 return Self::None;
6684 };
6685 // The share is of the WHOLE page, mirrored glyphs included: measured
6686 // against the rotations alone, one upright line on a mirrored page
6687 // would carry 100 % of the vote.
6688 let share = share_of_turned * turned_total as f64 / page_total as f64;
6689 if share <= 0.5 {
6690 return Self::None;
6691 }
6692 [Self::Quarter, Self::Half, Self::ThreeQuarter]
6693 .into_iter()
6694 .find(|turn| directions_agree(page, turn.direction()))
6695 .unwrap_or(Self::None)
6696 }
6697
6698 /// The direction a page this turn undoes is written in.
6699 fn direction(self) -> Direction {
6700 match self {
6701 Self::None => (1.0, 0.0),
6702 Self::Quarter => (0.0, 1.0),
6703 Self::Half => (-1.0, 0.0),
6704 Self::ThreeQuarter => (0.0, -1.0),
6705 }
6706 }
6707
6708 /// The `(cos, sin)` of the rotation into the frame: the one that takes
6709 /// [`Self::direction`] to `(1, 0)`.
6710 fn rotation(self) -> (f64, f64) {
6711 match self {
6712 Self::None => (1.0, 0.0),
6713 Self::Quarter => (0.0, -1.0),
6714 Self::Half => (-1.0, 0.0),
6715 Self::ThreeQuarter => (0.0, 1.0),
6716 }
6717 }
6718
6719 /// Every span in the frame the page was written in.
6720 fn into_frame(self, mut spans: Vec<TextSpan>) -> Vec<TextSpan> {
6721 if self == Self::None {
6722 return spans;
6723 }
6724 let rotation = self.rotation();
6725 for span in &mut spans {
6726 // In its own frame a run is as wide as it advances along its
6727 // baseline, and that is not what `draw_glyph` accumulated: `width`
6728 // is the furthest a glyph reached in PAGE x, which for a run
6729 // drawn up the page is one advance however long the run is. Every
6730 // reader of `width` above this point -- the column gutters, the
6731 // band gaps, the cell edges -- would take a line of a turned page
6732 // for a single word.
6733 let along = baseline_extent(span);
6734 turn_span(span, rotation);
6735 span.width = along;
6736 }
6737 spans
6738 }
6739
6740 /// The ruling lines of #545 in the frame the page is read in.
6741 ///
6742 /// The device records horizontal rules and no others, and only the half
6743 /// turn maps a horizontal line to a horizontal line -- mirrored, which is
6744 /// why the two ends swap. A quarter turn maps one to a vertical line:
6745 /// what the device caught on such a page are its column separators, and
6746 /// its row rules were never horizontal to begin with, so they were never
6747 /// recorded at all. Those pages are cut without rules, exactly as every
6748 /// page was before #545, rather than with rules that mean the other thing
6749 /// (#546, review round 2).
6750 fn rules_in_frame(self, rules: &[HorizontalRule]) -> Cow<'_, [HorizontalRule]> {
6751 let rotation = self.rotation();
6752 match self {
6753 Self::None => Cow::Borrowed(rules),
6754 Self::Half => Cow::Owned(
6755 rules
6756 .iter()
6757 .map(|rule| {
6758 let (left, y) = turn_point(rule.left, rule.y, rotation);
6759 let (right, _) = turn_point(rule.right, rule.y, rotation);
6760 HorizontalRule {
6761 left: left.min(right),
6762 right: left.max(right),
6763 y,
6764 }
6765 })
6766 .collect(),
6767 ),
6768 Self::Quarter | Self::ThreeQuarter => Cow::Owned(Vec::new()),
6769 }
6770 }
6771
6772 /// The blocks back in page space, with every span's geometry the caller's
6773 /// own again.
6774 fn back(self, mut blocks: Vec<TextBlock>) -> Vec<TextBlock> {
6775 if self == Self::None {
6776 return blocks;
6777 }
6778 let (cos, sin) = self.rotation();
6779 for span in blocks.iter_mut().flat_map(|block| block.spans.iter_mut()) {
6780 turn_span(span, (cos, -sin));
6781 span.width = page_space_width(span);
6782 }
6783 blocks
6784 }
6785
6786 /// The paragraphs back in page space (#567).
6787 ///
6788 /// The same restoration [`Self::back`] performs on lines, and then the
6789 /// block's own box again: a box computed in the frame the page was written
6790 /// in describes a rectangle nobody on the caller's side can find.
6791 fn back_blocks(self, mut blocks: Vec<Block>) -> Vec<Block> {
6792 if self == Self::None {
6793 return blocks;
6794 }
6795 let (cos, sin) = self.rotation();
6796 for block in blocks.iter_mut() {
6797 for span in block.spans.iter_mut() {
6798 turn_span(span, (cos, -sin));
6799 span.width = page_space_width(span);
6800 }
6801 crate::blocks::recompute_bbox(block);
6802 }
6803 blocks
6804 }
6805}
6806
6807/// How far a span reaches along its own baseline, from its origin.
6808fn baseline_extent(span: &TextSpan) -> f64 {
6809 let direction = span_direction(span);
6810 baseline_end(span, direction) - baseline_coordinates(span, direction).0
6811}
6812
6813/// The width `draw_glyph` measures: the furthest any of the span's glyphs
6814/// reaches in page x, from the span's origin.
6815///
6816/// This is what [`PageTurn::back`] restores, so that a caller of
6817/// `extract_text_blocks` on a turned page reads the same `width` off a span as
6818/// one on an upright page does. It is derived from the bounds rather than
6819/// remembered because the bounds are the same measurements `draw_glyph`
6820/// accumulated it from; a span with no bounds -- every hand-built one -- keeps
6821/// the width it came with.
6822fn page_space_width(span: &TextSpan) -> f64 {
6823 span.char_bounds
6824 .iter()
6825 .map(|bound| bound[2] - span.x)
6826 .reduce(f64::max)
6827 .unwrap_or(span.width)
6828}
6829
6830/// Turn one span's geometry by `(cos, sin)`.
6831///
6832/// What turns is what the layout reads as a position: the span's origin, the
6833/// baseline its transform names, and the origin of each glyph bound.
6834///
6835/// What does not turn is what is measured along that baseline and means the
6836/// same in either frame. A `char_bound` is one of those: `draw_glyph` writes
6837/// it as `[x, y, x + advance, y + font size]` around a PAGE-space origin, so
6838/// it is an advance and a font size hung off a point rather than the rectangle
6839/// a turned glyph covers -- which is what `baseline_end` reads back out of it.
6840/// Turning the two numbers as if the entry were a rectangle would turn a
6841/// measurement that was never in page space to begin with.
6842///
6843/// `tight_char_bounds` is deliberately left alone. That one IS a page-space
6844/// rectangle, and it is ink rather than layout: nothing between here and the
6845/// blocks reads it, so leaving it where it is keeps it exact for the caller who
6846/// does (`GeometryMode::RichGeometry`).
6847fn turn_span(span: &mut TextSpan, rotation: (f64, f64)) {
6848 let (x, y) = turn_point(span.x, span.y, rotation);
6849 span.x = x;
6850 span.y = y;
6851 if let Some(coeffs) = span.transform.as_mut() {
6852 let (a, b) = turn_point(coeffs[0], coeffs[1], rotation);
6853 let (c, d) = turn_point(coeffs[2], coeffs[3], rotation);
6854 let (e, f) = turn_point(coeffs[4], coeffs[5], rotation);
6855 *coeffs = [a, b, c, d, e, f];
6856 }
6857 for bound in &mut span.char_bounds {
6858 let (advance, size) = (bound[2] - bound[0], bound[3] - bound[1]);
6859 let (x0, y0) = turn_point(bound[0], bound[1], rotation);
6860 *bound = [x0, y0, x0 + advance, y0 + size];
6861 }
6862}
6863
6864/// A point turned by `(cos, sin)`.
6865fn turn_point(x: f64, y: f64, (cos, sin): (f64, f64)) -> (f64, f64) {
6866 (x * cos - y * sin, x * sin + y * cos)
6867}
6868
6869/// Take the runs drawn in another direction than the page's out of the blocks
6870/// the page's own layout put them in, and give each one back as the line it is
6871/// (#435).
6872///
6873/// Bands are horizontal, and they read one direction. The letters of a diagonal
6874/// watermark, of a rotated table header, of the marginalia down the side of a
6875/// page each land in a band of their own, interleaved with whatever upright text
6876/// shares that band, and the reader gets `rk Xa Orm test value aBte D df wN t pA
6877/// sS te` where pdfjs issue17069 says `test pdf watermark` and `SANDBOX`.
6878///
6879/// WHERE THE PAGE'S OWN DIRECTION COMES FROM. The direction most of the page's
6880/// glyphs are drawn in keeps the page's bands, and on a page that is ITSELF
6881/// turned -- a `/Rotate 90` page, whose every glyph reaches the device turned
6882/// with it -- those bands would lie across its lines and read it as badly as a
6883/// watermark read before this pass (pdfbox `PDFBOX-6049`, `AcroFormsRotation`).
6884/// Such a page is turned back into the frame it was written in before the cut
6885/// and the bands ever see it ([`PageTurn`], #546), so by the time this pass
6886/// runs the page's own direction is upright again and what is left for it is
6887/// what it is for: the run drawn against the direction of the page it is on.
6888///
6889/// This runs AFTER the page's cut and takes nothing away from it. Where the
6890/// page's columns and zones are is decided by every glyph on it, rotated ones
6891/// included -- a run of marginalia down the left edge is part of why a page
6892/// splits where it does, and a version of this that cut the page without them
6893/// moved the reading order of `issue6127` by sixteen points. So the layout is
6894/// the layout, and what changes here is only which line each rotated glyph is
6895/// read as part of.
6896fn reassemble_cross_direction_runs(blocks: Vec<TextBlock>) -> Vec<TextBlock> {
6897 let Some(page) = page_direction(&blocks) else {
6898 return blocks;
6899 };
6900 reassemble_with_direction(page, blocks)
6901}
6902
6903/// The same pass, told which direction the page reads in.
6904///
6905/// Split out for [`paragraph_blocks`], which runs it once per region of the cut
6906/// rather than once over the page: a run drawn across the type is read as part
6907/// of the region it was drawn in, and the page's own direction is still the
6908/// page's -- decided over every block on it, which is why it is an argument
6909/// here and not something each region votes on for itself.
6910fn reassemble_with_direction(page: Direction, blocks: Vec<TextBlock>) -> Vec<TextBlock> {
6911 let mut kept: Vec<TextBlock> = Vec::with_capacity(blocks.len());
6912 // Each cross-direction span, with the position in the page's order that the
6913 // block it was taken from held.
6914 let mut cross: Vec<PlacedSpan> = Vec::new();
6915 for block in blocks {
6916 let at = kept.len();
6917 let mut page_spans = Vec::with_capacity(block.spans.len());
6918 for span in block.spans {
6919 if directions_agree(page, span_direction(&span)) {
6920 page_spans.push(span);
6921 } else {
6922 cross.push((at, span));
6923 }
6924 }
6925 if !page_spans.is_empty() {
6926 kept.push(TextBlock { spans: page_spans });
6927 }
6928 }
6929
6930 if cross.is_empty() {
6931 return kept;
6932 }
6933
6934 // `lines_along_their_own_baselines` returns the runs in the order they are
6935 // to be read. They are inserted back to front -- so that inserting one
6936 // never moves the position another was measured at -- and two runs that
6937 // share a position are inserted in the reverse of their order, because each
6938 // insertion at the same index pushes the last one down (#435, review round
6939 // 2).
6940 let runs = lines_along_their_own_baselines(cross);
6941 for (at, run) in runs.into_iter().rev() {
6942 let at = at.min(kept.len());
6943 kept.insert(at, run);
6944 }
6945 kept
6946}
6947
6948/// The direction most of a page's glyphs are drawn in, or `None` for a page
6949/// with no text at all.
6950fn page_direction(blocks: &[TextBlock]) -> Option<Direction> {
6951 dominant_direction(blocks.iter().flat_map(|block| block.spans.iter()))
6952}
6953
6954/// A span's provenance from its glyphs': an estimate if any glyph was
6955/// estimated, tight if any was measured tight and none estimated, the advance
6956/// otherwise. One definition, for the span as `compute_tight_bounds` makes it
6957/// and for a span that loses glyphs afterwards (codex, #1974).
6958pub(crate) fn aggregate_bounds_source(sources: &[BoundsSource]) -> BoundsSource {
6959 if sources.contains(&BoundsSource::Estimate) {
6960 BoundsSource::Estimate
6961 } else if sources.contains(&BoundsSource::Tight) {
6962 BoundsSource::Tight
6963 } else {
6964 BoundsSource::Advance
6965 }
6966}
6967
6968/// #582's reading of tracking, asked again of a FINISHED span (#1973).
6969///
6970/// #582 believes a run is tracked once four gaps in a row agree, deciding at
6971/// each glyph from the gaps before it. A word of four letters has three --
6972/// `P E N N`, `P A G E` -- and a word space set wider than the tracking starts a
6973/// new span, so on a display line of short words the count never reaches four
6974/// and every letter keeps the space the consensus gave it. Once the span is
6975/// finished all its gaps are known, and the same question has an answer: the
6976/// span's tracking is its median gap, a consensus space at a gap within
6977/// [`TRACKING_TOLERANCE`]-and-a-bit of it -- or tighter, a kerned pair such as
6978/// `TA` -- is tracking and goes, and one clearly wider is a word boundary and
6979/// stays. Which is #582's choice about letters at one constant distance,
6980/// pinned there by `a_line_of_single_letter_words_reads_as_tracking_and_that_is_the_choice`.
6981///
6982/// Asked only of a span of capitals that shows the symptom (four single
6983/// letters between spaces), and only where its glyphs are its letters one for
6984/// one: every space in it is then the consensus's own and none is a glyph the
6985/// page drew. A span with a drawn space, a ligature or lower case is left to
6986/// the rules after this.
6987fn retrack_a_finished_span(span: &mut TextSpan, chars: &[char]) -> bool {
6988 let letters: Vec<char> = chars.iter().copied().filter(|c| *c != ' ').collect();
6989 if letters.len() != span.char_bounds.len() || letters.len() < 4 {
6990 return false;
6991 }
6992 if letters.iter().any(|c| c.is_lowercase()) || !letters.iter().any(|c| c.is_uppercase()) {
6993 return false;
6994 }
6995 let singles = span
6996 .text
6997 .split(' ')
6998 .filter(|w| w.chars().count() == 1 && w.chars().all(char::is_uppercase))
6999 .count();
7000 if singles < 4 {
7001 return false;
7002 }
7003 let gaps: Vec<f64> = span
7004 .char_bounds
7005 .windows(2)
7006 .map(|p| p[1][0] - p[0][2])
7007 .collect();
7008 let mut sorted = gaps.clone();
7009 sorted.sort_by(|a, b| a.total_cmp(b));
7010 // THE LOWER MEDIAN (codex, #1980). A short phrase can have as many word
7011 // gaps as letter gaps -- `I AM OK`: 1, 1, 3, 3 -- and the upper median then
7012 // takes the word space for the tracking and removes every space. The lower
7013 // one stays with the letters, and a kerned pair below them (`TA`) is too
7014 // rare in a word to pull it down.
7015 let tracking = sorted[(sorted.len() - 1) / 2];
7016 // Real tracking: the letters stand apart. In prose the boxes of a word's
7017 // letters touch -- they are advances -- so its median gap is nothing, and
7018 // the consensus's spaces there are word spaces, which this must not take.
7019 if tracking <= TRACKED_GAP_MIN_EM * span.font_size {
7020 return false;
7021 }
7022 let inside_a_word = tracking * (1.0 + TRACKING_TOLERANCE + 0.1);
7023 let mut spaced_before = vec![false; letters.len()];
7024 let mut k = 0;
7025 for c in chars {
7026 if *c == ' ' {
7027 if k < letters.len() {
7028 spaced_before[k] = true;
7029 }
7030 } else {
7031 k += 1;
7032 }
7033 }
7034 let mut text = String::with_capacity(chars.len());
7035 for (k, c) in letters.iter().enumerate() {
7036 if k > 0 && spaced_before[k] && gaps[k - 1] > inside_a_word {
7037 text.push(' ');
7038 }
7039 text.push(*c);
7040 }
7041 if chars.last() == Some(&' ') {
7042 text.push(' ');
7043 }
7044 let changed = text != span.text;
7045 span.text = text;
7046 changed
7047}
7048
7049/// Letter-spacing done with SPACE CHARACTERS, taken back out (#1973).
7050///
7051/// #582 reads tracking that the page draws as GAPS. A good share of display
7052/// lines draw it with space glyphs instead -- `P O W E L L I N D U S T R I E S`,
7053/// a space after every capital and two where the words part -- and those
7054/// arrive as text, so no gap is ever there to judge: on the evidence of the
7055/// 24-09 round it is the largest class of text we write that the page does not
7056/// hold (0.74 % of our words on DocLayNet, against Docling's 0.07 %).
7057///
7058/// A run of four single capitals or more with one space between each is taken
7059/// for letter-spacing only when THE PAGE MARKS ITS WORD BOUNDARIES with a
7060/// double space (`P O W E L L I N D U S T R I E S`). The single spaces then go
7061/// and each double one becomes one. With no such mark, `A B C D` stays as it
7062/// is: letters set apart on purpose and letters tracked by spaces are the same
7063/// bytes, and the boundary is the only thing that tells them apart. A pair of
7064/// capitals inside the run (`O P E R AT I O N S`, a kerned pair drawn as one)
7065/// does not end it.
7066fn untrack_spaced_letters(span: &mut TextSpan) {
7067 let chars: Vec<char> = span.text.chars().collect();
7068 if chars.len() < 7 || !chars.contains(&' ') {
7069 return;
7070 }
7071 // The finished span first; what it leaves is asked of the rules below.
7072 let chars: Vec<char> = if retrack_a_finished_span(span, &chars) {
7073 span.text.chars().collect()
7074 } else {
7075 chars
7076 };
7077 let aligned = span.char_bounds.len() == chars.len();
7078 let tight = span.tight_char_bounds.len() == chars.len();
7079 // Words and the separators between them, as char ranges.
7080 let mut words: Vec<(usize, usize)> = Vec::new();
7081 let mut at = 0;
7082 while at < chars.len() {
7083 if chars[at] == ' ' {
7084 at += 1;
7085 continue;
7086 }
7087 let start = at;
7088 while at < chars.len() && chars[at] != ' ' {
7089 at += 1;
7090 }
7091 words.push((start, at));
7092 }
7093 // Capitals and their punctuation only, as in `untrack_spaced_spans`.
7094 let small = |(a, b): (usize, usize)| {
7095 (b - a == 1 || b - a == 2)
7096 && chars[a..b]
7097 .iter()
7098 .all(|c| c.is_uppercase() || matches!(c, ',' | '.' | '\'' | '’' | '&' | '-'))
7099 && (b - a == 1 || chars[a..b].iter().any(|c| c.is_uppercase()))
7100 };
7101 let mut drop = vec![false; chars.len()];
7102 let mut w = 0;
7103 while w < words.len() {
7104 if !small(words[w]) {
7105 w += 1;
7106 continue;
7107 }
7108 // The run: small words, one or two spaces apart.
7109 let mut end = w + 1;
7110 while end < words.len()
7111 && small(words[end])
7112 && (1..=2).contains(&(words[end].0 - words[end - 1].1))
7113 {
7114 end += 1;
7115 }
7116 // A last "word" of one capital after a double space is not a tracked
7117 // word: `F C F C S` is a label beside a letter, as the row rule
7118 // reads it (codex, #1974).
7119 while end > w + 1 {
7120 let last_double = (w + 1..end)
7121 .rev()
7122 .find(|&i| words[i].0 - words[i - 1].1 >= 2);
7123 match last_double {
7124 Some(at)
7125 if words[at..end]
7126 .iter()
7127 .map(|&(a, b)| chars[a..b].iter().filter(|c| c.is_uppercase()).count())
7128 .sum::<usize>()
7129 < 2 =>
7130 {
7131 end = at;
7132 }
7133 _ => break,
7134 }
7135 }
7136 let run = &words[w..end];
7137 let singles = run
7138 .iter()
7139 .filter(|&&(a, b)| b - a == 1 && chars[a].is_uppercase())
7140 .count();
7141 if singles >= 4 && run.len() >= 4 {
7142 let seps: Vec<(usize, usize)> = run.windows(2).map(|p| (p[0].1, p[1].0)).collect();
7143 // Only where the page marks its word boundaries with a double
7144 // space. Widths and gaps were tried and read figure labels and
7145 // OCR noise (`B C B C`, `I I I`) as tracked words.
7146 let boundary: Option<Vec<bool>> = seps
7147 .iter()
7148 .any(|(a, b)| b - a >= 2)
7149 .then(|| seps.iter().map(|(a, b)| b - a >= 2).collect());
7150 if let Some(boundary) = boundary {
7151 for (&(a, b), keep) in seps.iter().zip(boundary) {
7152 // A boundary keeps its first space; everything else goes.
7153 let from = if keep { a + 1 } else { a };
7154 for d in drop.iter_mut().take(b).skip(from) {
7155 *d = true;
7156 }
7157 }
7158 }
7159 }
7160 w = end;
7161 }
7162 if !drop.contains(&true) {
7163 return;
7164 }
7165 span.text = chars
7166 .iter()
7167 .zip(&drop)
7168 .filter(|(_, d)| !**d)
7169 .map(|(c, _)| *c)
7170 .collect();
7171 if aligned {
7172 let mut k = 0;
7173 span.char_bounds.retain(|_| {
7174 k += 1;
7175 !drop[k - 1]
7176 });
7177 }
7178 if tight {
7179 let mut k = 0;
7180 span.tight_char_bounds.retain(|_| {
7181 k += 1;
7182 !drop[k - 1]
7183 });
7184 }
7185 // The rich-geometry vectors run beside the tight boxes, glyph for glyph.
7186 if span.glyph_advances.len() == chars.len() {
7187 let mut k = 0;
7188 span.glyph_advances.retain(|_| {
7189 k += 1;
7190 !drop[k - 1]
7191 });
7192 }
7193 if span.glyph_bounds_sources.len() == chars.len() {
7194 let mut k = 0;
7195 span.glyph_bounds_sources.retain(|_| {
7196 k += 1;
7197 !drop[k - 1]
7198 });
7199 // A space has no outline and marks its span an estimate; with the
7200 // spaces gone the span is what its letters are (codex, #1974).
7201 span.bounds_source = aggregate_bounds_source(&span.glyph_bounds_sources);
7202 }
7203}
7204
7205/// The direction most of a set of spans' glyphs are drawn in, or `None` for no
7206/// spans at all.
7207///
7208/// Weighted by glyphs and not by spans: one word of upright text is not the
7209/// page's direction because a rotated run happened to arrive as three spans.
7210/// And by GLYPHS and not by characters, which are not the same count: a
7211/// ligature decodes to two or three of them, and the spaces the space consensus
7212/// puts between two glyphs are characters that were never drawn. Either would
7213/// let a minority direction outvote the page's own on a page where the two are
7214/// close. `char_bounds` holds one entry per glyph drawn; the character count
7215/// answers only for a span that carries no bounds, which is every hand-built
7216/// one (#435, review round 2).
7217fn dominant_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<Direction> {
7218 heaviest_direction(spans).map(|(direction, _)| direction)
7219}
7220
7221/// What one span weighs in a vote about direction: the glyphs it drew, or its
7222/// characters when it carries no bounds. One definition, because two of them
7223/// would let the same page have two majorities.
7224fn glyph_weight(span: &TextSpan) -> usize {
7225 if span.char_bounds.is_empty() {
7226 span.text.chars().count().max(1)
7227 } else {
7228 span.char_bounds.len()
7229 }
7230}
7231
7232/// Whether a span was drawn by a proper rotation rather than a reflection.
7233///
7234/// A baseline vector cannot tell the two apart. `[-1, 0, 0, 1, e, f]` mirrors
7235/// the page about its vertical axis and leaves the baseline running in `(-1,
7236/// 0)` -- the same direction a half turn produces -- while the page's vertical
7237/// axis stays upright and nothing has turned. The determinant is what separates
7238/// them: it is positive for a rotation and negative for a reflection.
7239///
7240/// A span with no transform is hand-built and reads upright, which is a proper
7241/// rotation by the identity.
7242fn is_a_proper_rotation(span: &TextSpan) -> bool {
7243 match span.transform {
7244 None => true,
7245 Some([a, b, c, d, _, _]) => a * d - b * c > 0.0,
7246 }
7247}
7248
7249/// The heaviest direction and the share of the glyphs drawn in it, weighed by
7250/// the rule [`dominant_direction`] documents.
7251fn heaviest_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<(Direction, f64)> {
7252 let mut directions: Vec<(Direction, usize)> = Vec::new();
7253 for span in spans {
7254 let direction = span_direction(span);
7255 let weight = glyph_weight(span);
7256 match directions
7257 .iter_mut()
7258 .find(|(known, _)| directions_agree(*known, direction))
7259 {
7260 Some(entry) => entry.1 += weight,
7261 None => directions.push((direction, weight)),
7262 }
7263 }
7264 let total: usize = directions.iter().map(|(_, weight)| *weight).sum();
7265 // The first of the heaviest, so that a page whose two directions carry the
7266 // same number of glyphs reads in the one it was drawn in first rather than
7267 // in whichever the iterator saw last.
7268 let mut winner: Option<(Direction, usize)> = None;
7269 for (direction, weight) in directions {
7270 if winner.map(|(_, best)| weight > best).unwrap_or(true) {
7271 winner = Some((direction, weight));
7272 }
7273 }
7274 winner.map(|(direction, weight)| (direction, weight as f64 / total as f64))
7275}
7276
7277/// Assemble cross-direction spans into lines along their own baselines.
7278///
7279/// The rule the page's bands follow, in each run's own frame: spans within the
7280/// band tolerance of each other across the baseline are one line, ordered along
7281/// it. A line is placed where the page's order held the first of its spans, so
7282/// a watermark drawn across the middle of a page is read there and not after
7283/// the footer.
7284fn lines_along_their_own_baselines(spans: Vec<PlacedSpan>) -> Vec<(usize, TextBlock)> {
7285 // One group per direction: two runs at two angles never join, however close
7286 // their glyphs land.
7287 let mut groups: Vec<(Direction, Vec<PlacedSpan>)> = Vec::new();
7288 for (at, span) in spans {
7289 let direction = span_direction(&span);
7290 match groups
7291 .iter_mut()
7292 .find(|(known, _)| directions_agree(*known, direction))
7293 {
7294 Some((_, group)) => group.push((at, span)),
7295 None => groups.push((direction, vec![(at, span)])),
7296 }
7297 }
7298
7299 let mut lines: Vec<(usize, TextBlock)> = Vec::new();
7300 for (direction, group) in groups {
7301 let mut rows: Vec<(f64, usize, Vec<TextSpan>)> = Vec::new();
7302 for (at, span) in group {
7303 let across = baseline_coordinates(&span, direction).1;
7304 let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
7305 match rows
7306 .iter_mut()
7307 .find(|(row_across, _, _)| (row_across - across).abs() <= tolerance)
7308 {
7309 Some((_, first, row)) => {
7310 *first = (*first).min(at);
7311 row.push(span);
7312 }
7313 None => rows.push((across, at, vec![span])),
7314 }
7315 }
7316
7317 // Read in the order the page's own order first held them, and where
7318 // two lines start at the same place -- which is what a block of
7319 // rotated text does -- from the top of the run's own frame down.
7320 rows.sort_by(|a, b| {
7321 a.1.cmp(&b.1)
7322 .then_with(|| b.0.partial_cmp(&a.0).unwrap_or(Ordering::Equal))
7323 });
7324 for (_, at, mut row) in rows {
7325 row.sort_by(|a, b| {
7326 baseline_coordinates(a, direction)
7327 .0
7328 .partial_cmp(&baseline_coordinates(b, direction).0)
7329 .unwrap_or(Ordering::Equal)
7330 });
7331 lines.push((at, TextBlock { spans: row }));
7332 }
7333 }
7334 // Across directions too: the page's order decides, and a stable sort keeps
7335 // each direction's own line order inside it.
7336 lines.sort_by(|a, b| a.0.cmp(&b.0));
7337 lines
7338}
7339
7340/// How far apart, in ems of the previous span's size, two quarter-turned
7341/// spans may stand along their shared baseline and still be glyphs of one
7342/// run (#574).
7343///
7344/// A word space is a fraction of an em; a tracked display line, and a word gap
7345/// the producer positioned rather than drew, run wider, to about an em. Two
7346/// ems is past every within-run gap the tuning pages show and short of the
7347/// distance between two runs that share a baseline -- the axis labels of a
7348/// chart, the row labels of a stub -- which stand several ems apart or they
7349/// would read as one line.
7350const ROTATED_RUN_MAX_GAP_EM: f64 = 2.0;
7351
7352/// How wide a gap along a turned baseline must be before the run it sits in
7353/// is merged for the cut's sake (#574).
7354///
7355/// The cut parts a run at a gap on the paragraph-break scale: two words
7356/// 1.2 em apart come back two blocks, reversed for a run that climbs the
7357/// page. A gap of an em or less never does -- the marginal runs that stand
7358/// alone in a margin are drawn one text matrix with uniform advances, a word
7359/// gap the width of a glyph -- so a run whose widest gap is an em, standing
7360/// alone outside any table, is left as drawn: #435 already reads it forwards,
7361/// and merging it buys nothing downstream can use.
7362const LONE_RUN_MAX_GAP_EM: f64 = 1.0;
7363
7364/// One minority run drawn turned on an upright page, as one span, before the
7365/// cut ever sees it (#574).
7366///
7367/// The producers of the defect's pages draw a turned run one glyph per show
7368/// operation, so the device keeps every glyph a span of its own: the page
7369/// space adjacency it merges on never holds across a baseline that stands up
7370/// the page. What #435 assembles afterwards is then unmade twice. The cut
7371/// reads a word gap of the run as a paragraph break and parts the run into
7372/// regions, which the page's top-to-bottom order reads backwards for a run
7373/// that climbs it (`rate interest average Weighted`); and the table grid reads
7374/// a stack of turned heads as rows of one-glyph cells, which the stacked split
7375/// then writes out a letter at a time (`T R O P E R L A U N N A`).
7376///
7377/// So each quarter-turned run is one text item before any grid is built:
7378/// consecutive quarter-turned spans that share an orientation and a baseline,
7379/// within a word gap of each other along it, ordered by their coordinate along
7380/// the baseline. One span cannot be cut in two, banded apart, or gridded a
7381/// glyph at a time, and what the grid and the cells then read is the run whole
7382/// and forwards.
7383///
7384/// The text is assembled by the same code that assembles a line of prose --
7385/// the word spacing, the overprint dedupe, the tracking -- so a run this joins
7386/// reads byte for byte as #435 reads it where the cut leaves it alone. Each
7387/// guard below names what it keeps unchanged:
7388///
7389/// * only quarter-turned spans are candidates ([`span_is_quarter_turned`]:
7390/// within ten degrees of 90, 180 or 270 degrees), so upright text, small
7391/// skews and free-angle regions come back untouched -- the defect class is
7392/// quarter turns only (six reversed runs, all at 90 degrees), and a
7393/// free-angle region already reads forwards (OTC_STKAF p-c3c8);
7394/// * its callers pass only upright pages: in the frame of a turned page the
7395/// minority reads turned, and merging it re-decides a landscape table;
7396/// * a style change still ends the run, the way it ends a span in the device,
7397/// so a bold word inside a quarter-turned run keeps its face;
7398/// * a gap wider than any word gap ends the run, so two runs that share a
7399/// baseline stay two;
7400/// * a gap another quarter-turned run's glyph starts in ends the run too, so
7401/// an axis label's capitals never join across the small digits set between
7402/// them;
7403/// * a quarter-turned span with no partner on its baseline is left exactly as
7404/// it was.
7405///
7406/// A group merges only when the layout would otherwise break it: a gap wide
7407/// enough to part it at the cut, or a sibling quarter-turned run on its row
7408/// the grid would read it with. A run standing lone and tight already reads
7409/// forwards through #435, and merging it only changes the shape downstream
7410/// counts -- thirteen glyph spans read as thirteen lines, one span as one,
7411/// which on one proxy page carries a marginal run to heading level and
7412/// re-decides the page's tables around it. Lone and tight stays as drawn,
7413/// everywhere, including a run held inside a table region's rectangle that
7414/// is margin furniture and not table ink.
7415/// A lone run inside a table region is the accepted residual: the grid slices
7416/// only rows of two or more, so a single turned line beside upright text is
7417/// thin and refused, as it is without this merge; no tuning page shows
7418/// otherwise.
7419fn merge_rotated_runs(spans: Vec<TextSpan>) -> Vec<TextSpan> {
7420 if !spans.iter().any(span_is_quarter_turned) {
7421 return spans;
7422 }
7423 /// A baseline of quarter-turned text taking shape: the direction its
7424 /// members share, the across-coordinate of its first span, and its members
7425 /// in draw order.
7426 struct Baseline {
7427 direction: Direction,
7428 across: f64,
7429 members: Vec<usize>,
7430 }
7431 let mut baselines: Vec<Baseline> = Vec::new();
7432 'spans: for (index, span) in spans.iter().enumerate() {
7433 if !span_is_quarter_turned(span) {
7434 continue;
7435 }
7436 let direction = span_direction(span);
7437 let across = baseline_coordinates(span, direction).1;
7438 // The band tolerance #435 groups a run's own lines by, asked of the
7439 // span joining: sub-point drift in the matrices still joins, and the
7440 // next column's head never does.
7441 let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
7442 for baseline in &mut baselines {
7443 if !directions_agree(baseline.direction, direction)
7444 || (baseline.across - across).abs() > tolerance
7445 {
7446 continue;
7447 }
7448 let first = &spans[baseline.members[0]];
7449 if first.font_name != span.font_name
7450 || first.is_bold != span.is_bold
7451 || first.is_italic != span.is_italic
7452 || first.color != span.color
7453 {
7454 continue;
7455 }
7456 baseline.members.push(index);
7457 continue 'spans;
7458 }
7459 baselines.push(Baseline {
7460 direction,
7461 across,
7462 members: vec![index],
7463 });
7464 }
7465
7466 // A baseline in pieces where another quarter-turned run stands inside
7467 // it: the members of one run are consecutive along it, and a gap another
7468 // run's glyph starts in is not a word gap of this one. A chart's axis label
7469 // reads `W`, `1`, `0`, `Omega`, `Delta` down one baseline with the two
7470 // small digits set in another face between the capitals; joining the
7471 // capitals across the digits parts the label where nothing parts it (#574:
7472 // the label comes back `01` in one block and `Delta Omega W` in another).
7473 // The label's own tight pairs stay whole -- intervention only ever splits.
7474 let mut split: Vec<Baseline> = Vec::with_capacity(baselines.len());
7475 for baseline in baselines {
7476 let mut members = baseline.members;
7477 members.sort_by(|a, b| {
7478 baseline_coordinates(&spans[*a], baseline.direction)
7479 .0
7480 .partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
7481 .unwrap_or(Ordering::Equal)
7482 });
7483 let mut piece = vec![members[0]];
7484 for pair in members.windows(2) {
7485 let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
7486 let end = baseline_end(prev, baseline.direction);
7487 let start = baseline_coordinates(curr, baseline.direction).0;
7488 let intervened = (start > end)
7489 && spans.iter().enumerate().any(|(index, span)| {
7490 !members.contains(&index)
7491 && span_is_quarter_turned(span)
7492 && directions_agree(span_direction(span), baseline.direction)
7493 && {
7494 let (along, across) = baseline_coordinates(span, baseline.direction);
7495 let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
7496 along > end
7497 && along < start
7498 && (baseline.across - across).abs() <= tolerance
7499 }
7500 });
7501 let size = prev.font_size.max(f64::MIN_POSITIVE);
7502 if start - end > ROTATED_RUN_MAX_GAP_EM * size || intervened {
7503 split.push(Baseline {
7504 direction: baseline.direction,
7505 across: baseline.across,
7506 members: std::mem::take(&mut piece),
7507 });
7508 }
7509 piece.push(pair[1]);
7510 }
7511 split.push(Baseline {
7512 direction: baseline.direction,
7513 across: baseline.across,
7514 members: piece,
7515 });
7516 }
7517 let mut baselines = split;
7518
7519 // Keep the groups the layout would break and let the rest through as
7520 // drawn.
7521 {
7522 // Each group's reading-first baseline and size, for the sibling test
7523 // below. Reading-first is where the grid would row the run: the
7524 // bottom of a run that climbs the page, the top of one that falls.
7525 struct Seat {
7526 baseline: f64,
7527 size: f64,
7528 }
7529 let seats: Vec<Seat> = baselines
7530 .iter()
7531 .map(|baseline| {
7532 let first = baseline
7533 .members
7534 .iter()
7535 .min_by(|a, b| {
7536 baseline_coordinates(&spans[**a], baseline.direction)
7537 .0
7538 .partial_cmp(&baseline_coordinates(&spans[**b], baseline.direction).0)
7539 .unwrap_or(Ordering::Equal)
7540 })
7541 .copied()
7542 .unwrap_or(0);
7543 Seat {
7544 baseline: spans[first].y,
7545 size: baseline
7546 .members
7547 .iter()
7548 .map(|i| spans[*i].font_size)
7549 .fold(0.0, f64::max),
7550 }
7551 })
7552 .collect();
7553 let mut keep = vec![false; baselines.len()];
7554 for (ordinal, baseline) in baselines.iter().enumerate() {
7555 // A gap on the paragraph-break scale parts the run at the cut,
7556 // and the pieces come back in page order, backwards for a run
7557 // that climbs the page. Past two ems the gap splits the baseline
7558 // into runs below instead, so it is not this trigger: stacked
7559 // labels part where they should.
7560 let mut members = baseline.members.clone();
7561 members.sort_by(|a, b| {
7562 baseline_coordinates(&spans[*a], baseline.direction)
7563 .0
7564 .partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
7565 .unwrap_or(Ordering::Equal)
7566 });
7567 let mut wide = false;
7568 for pair in members.windows(2) {
7569 let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
7570 let gap = baseline_coordinates(curr, baseline.direction).0
7571 - baseline_end(prev, baseline.direction);
7572 let size = prev.font_size.max(f64::MIN_POSITIVE);
7573 if gap > LONE_RUN_MAX_GAP_EM * size && gap <= ROTATED_RUN_MAX_GAP_EM * size {
7574 wide = true;
7575 break;
7576 }
7577 }
7578 if wide {
7579 keep[ordinal] = true;
7580 continue;
7581 }
7582 // A sibling quarter-turned run on the same row is the row the grid
7583 // would read them as, one-glyph cells across: side-by-side heads
7584 // share a reading-first baseline to the point. Parts of one run split
7585 // by a style change never do -- consecutive parts start at least
7586 // an advance apart, past the half em below -- nor do stacked
7587 // labels or runs that read opposite ways, so the lone and the
7588 // stacked stay as drawn.
7589 keep[ordinal] = seats.iter().enumerate().any(|(other, seat)| {
7590 other != ordinal
7591 && (seats[ordinal].baseline - seat.baseline).abs()
7592 <= 0.5 * seats[ordinal].size.max(seat.size)
7593 });
7594 }
7595 let mut kept: Vec<Baseline> = Vec::with_capacity(baselines.len());
7596 for (baseline, keep) in baselines.into_iter().zip(keep) {
7597 if keep {
7598 kept.push(baseline);
7599 }
7600 }
7601 baselines = kept;
7602 if baselines.is_empty() {
7603 return spans;
7604 }
7605 }
7606
7607 // Each baseline along itself, in reading order, split into runs where the
7608 // white is wider than any word gap.
7609 let mut runs: Vec<Vec<usize>> = Vec::new();
7610 for baseline in &baselines {
7611 let mut members = baseline.members.clone();
7612 members.sort_by(|a, b| {
7613 baseline_coordinates(&spans[*a], baseline.direction)
7614 .0
7615 .partial_cmp(&baseline_coordinates(&spans[*b], baseline.direction).0)
7616 .unwrap_or(Ordering::Equal)
7617 });
7618 let mut run = vec![members[0]];
7619 for pair in members.windows(2) {
7620 let (prev, curr) = (&spans[pair[0]], &spans[pair[1]]);
7621 let gap = baseline_coordinates(curr, baseline.direction).0
7622 - baseline_end(prev, baseline.direction);
7623 if gap > ROTATED_RUN_MAX_GAP_EM * prev.font_size.max(f64::MIN_POSITIVE) {
7624 runs.push(std::mem::take(&mut run));
7625 }
7626 run.push(pair[1]);
7627 }
7628 runs.push(run);
7629 }
7630
7631 // Each run of two or more as one span, emitted where its first-drawn
7632 // member stood; a quarter-turned span with no partner keeps its own place.
7633 let mut replacement: Vec<Option<TextSpan>> = (0..spans.len()).map(|_| None).collect();
7634 let mut folded = vec![false; spans.len()];
7635 for run in runs.iter().filter(|run| run.len() >= 2) {
7636 let members: Vec<TextSpan> = run.iter().map(|i| spans[*i].clone()).collect();
7637 // The run as #435 would assemble it: its spans in reading order
7638 // through the line assembly, so the string is the one the page holds.
7639 // The bounds below follow the same order the text was joined in.
7640 let text = TextBlock {
7641 spans: members.clone(),
7642 }
7643 .visual_text();
7644 let reading_first = &members[0];
7645 let x = members
7646 .iter()
7647 .map(|span| span.x)
7648 .fold(f64::INFINITY, f64::min);
7649 let y = members
7650 .iter()
7651 .map(|span| span.y)
7652 .fold(f64::INFINITY, f64::min);
7653 let right = members
7654 .iter()
7655 .map(|span| span.measured_right())
7656 .fold(f64::NEG_INFINITY, f64::max);
7657 // Glyph-scale, and deliberately not the envelope: every reader of
7658 // `height` -- the band tolerance, the row pitch -- expects one line's
7659 // worth, and the envelope lives in the bounds, where the boxes are
7660 // actually read from.
7661 let height = members.iter().map(|span| span.height).fold(0.0, f64::max);
7662 let font_size = members
7663 .iter()
7664 .map(|span| span.font_size)
7665 .fold(0.0, f64::max);
7666 let mut char_bounds = Vec::new();
7667 let mut tight_char_bounds = Vec::new();
7668 let mut glyph_advances = Vec::new();
7669 let mut glyph_bounds_sources = Vec::new();
7670 for span in &members {
7671 char_bounds.extend(span.char_bounds.iter().copied());
7672 tight_char_bounds.extend(span.tight_char_bounds.iter().copied());
7673 glyph_advances.extend(span.glyph_advances.iter().copied());
7674 glyph_bounds_sources.extend(span.glyph_bounds_sources.iter().copied());
7675 }
7676 let merged = TextSpan {
7677 text,
7678 x,
7679 y,
7680 width: (right - x).max(0.0),
7681 height,
7682 font_size,
7683 font_name: reading_first.font_name.clone(),
7684 is_bold: reading_first.is_bold,
7685 is_italic: reading_first.is_italic,
7686 color: reading_first.color,
7687 // An estimate anywhere in the run, like the device's own merge.
7688 width_source: if members
7689 .iter()
7690 .any(|span| span.width_source == WidthSource::Estimate)
7691 {
7692 WidthSource::Estimate
7693 } else {
7694 WidthSource::Metric
7695 },
7696 char_bounds,
7697 geometry_mode: reading_first.geometry_mode,
7698 bounds_source: aggregate_bounds_source(&glyph_bounds_sources),
7699 tight_char_bounds,
7700 glyph_advances,
7701 glyph_bounds_sources,
7702 transform: reading_first.transform,
7703 font_weight: reading_first.font_weight,
7704 is_serif: reading_first.is_serif,
7705 is_ocr_layer: members.iter().any(|span| span.is_ocr_layer),
7706 is_monospace: reading_first.is_monospace,
7707 render_mode: reading_first.render_mode,
7708 font_metrics: reading_first.font_metrics,
7709 // The merged span ends where its last-drawn member does (#643).
7710 complete_run: members.last().is_some_and(|span| span.complete_run),
7711 };
7712 let first_drawn = run.iter().copied().min().unwrap_or(0);
7713 for index in run {
7714 folded[*index] = *index != first_drawn;
7715 }
7716 replacement[first_drawn] = Some(merged);
7717 }
7718
7719 let mut out: Vec<TextSpan> = Vec::with_capacity(spans.len());
7720 for (index, span) in spans.into_iter().enumerate() {
7721 if folded[index] {
7722 continue;
7723 }
7724 match replacement[index].take() {
7725 Some(merged) => out.push(merged),
7726 None => out.push(span),
7727 }
7728 }
7729 out
7730}
7731
7732fn xy_cut_recursive(
7733 spans: Vec<TextSpan>,
7734 depth: usize,
7735 stats: &PageStats,
7736 rules: &[HorizontalRule],
7737) -> Vec<Vec<TextBlock>> {
7738 if spans.is_empty() {
7739 return Vec::new();
7740 }
7741 if depth >= XY_CUT_MAX_DEPTH {
7742 return vec![band_based_blocks(spans, stats, rules)];
7743 }
7744
7745 // ANN[r17/TEX2] Pick whichever direction has the largest qualifying
7746 // gap. Always cutting vertically first breaks layouts where a
7747 // footer sits in the mid-x range — it would attach to the left
7748 // column instead of being recognized as a page-level zone. The
7749 // "largest gap wins" rule is the standard XY-Cut tie-breaker used
7750 // by academic OCR literature and matches pdf_oxide.
7751 let vcut = try_vertical_cut(&spans, stats, rules);
7752 let hcut = try_horizontal_cut(&spans, stats);
7753
7754 let (chosen, _) = match (vcut, hcut) {
7755 (Some((v_groups, v_gap)), Some((h_groups, h_gap))) => {
7756 if v_gap >= h_gap {
7757 (Some(v_groups), v_gap)
7758 } else {
7759 (Some(h_groups), h_gap)
7760 }
7761 }
7762 (Some((v_groups, v_gap)), None) => (Some(v_groups), v_gap),
7763 (None, Some((h_groups, h_gap))) => (Some(h_groups), h_gap),
7764 (None, None) => (None, 0.0),
7765 };
7766
7767 if let Some(groups) = chosen {
7768 let mut out = Vec::new();
7769 for group in groups {
7770 out.extend(xy_cut_recursive(group, depth + 1, stats, rules));
7771 }
7772 return out;
7773 }
7774
7775 vec![band_based_blocks(spans, stats, rules)]
7776}
7777
7778/// Emit per-band row blocks without any column detection. Used as the
7779/// leaf of XY-Cut recursion — at this point the region either has no
7780/// further cuts or the density guard refused them.
7781fn band_based_blocks(
7782 spans: Vec<TextSpan>,
7783 stats: &PageStats,
7784 rules: &[HorizontalRule],
7785) -> Vec<TextBlock> {
7786 // XY-Cut can miss recurring gutters when a small number of bands span the
7787 // full page width (e.g. a running header above a 3-column body). In that
7788 // case, fall back to the older band/gutter detector inside the leaf region
7789 // instead of flattening everything row-major.
7790 group_spans_into_blocks_legacy_with_stats(spans, stats, rules)
7791}
7792
7793/// Median font-size helper. Currently unreferenced after `PageStats` took over
7794/// the typography baseline computation; kept available for future tuning paths.
7795#[allow(dead_code)]
7796fn median_font_size(spans: &[TextSpan]) -> f64 {
7797 if spans.is_empty() {
7798 return 12.0;
7799 }
7800 let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
7801 sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
7802 sizes[sizes.len() / 2]
7803}
7804
7805/// Whether span `at` of a band stands in a gutter without being its ink: a
7806/// marker hung into it (one glyph set apart, `hung`), or a word space drawn as
7807/// a run of its own (#574).
7808///
7809/// NYSE_MSA outdents its `t` bullets 10 pt past the left column's measure and
7810/// draws the space after each as a run of its own, so no x on the page is free
7811/// of every span and the two columns were read across, line by line. A run of
7812/// spaces wider than an em is not looked past: a table that fills its row with
7813/// spaces from cell to cell has said the row is one.
7814fn is_marker_or_space(hung: &HashSet<usize>, at: usize, span: &TextSpan) -> bool {
7815 hung.contains(&at)
7816 || (!span.text.chars().any(|c| !c.is_whitespace())
7817 && span.measured_right() - span.x <= span.font_size.max(1.0))
7818}
7819
7820/// Whether both sides of `cut_x` are running text: the median line on each
7821/// side holds more than `RUNNING_LETTERS` letters.
7822///
7823/// Asked of a gutter found only by looking past markers and spaces, because
7824/// the cell column of a table is also a strip of white with a few spans in it
7825/// -- NYSE_JWN's exhibit index, `Filed herewith electronically` down the right
7826/// -- and a cell is short where a column's line runs on.
7827fn both_sides_run_on(bands: &[TextBand], cut_x: f64) -> bool {
7828 let mut left: Vec<usize> = Vec::new();
7829 let mut right: Vec<usize> = Vec::new();
7830 for band in bands {
7831 // A band with ink on a side counts on that side even when its ink
7832 // is all figures: a row of amounts is a row with no letters, and
7833 // left out it would let one long header stand for the whole side
7834 // (codex, #2045).
7835 let (mut l, mut r) = (0usize, 0usize);
7836 let (mut l_ink, mut r_ink) = (false, false);
7837 for span in &band.spans {
7838 let inked = span.text.chars().any(|c| !c.is_whitespace());
7839 // Letters, because a row of amounts is as long as a line of text
7840 // and says nothing a reader would call running on.
7841 let chars = span.text.chars().filter(|c| c.is_alphabetic()).count();
7842 if span.x + (span.measured_right() - span.x) * 0.5 < cut_x {
7843 l += chars;
7844 l_ink |= inked;
7845 } else {
7846 r += chars;
7847 r_ink |= inked;
7848 }
7849 }
7850 if l_ink {
7851 left.push(l);
7852 }
7853 if r_ink {
7854 right.push(r);
7855 }
7856 }
7857 let median = |v: &mut Vec<usize>| {
7858 v.sort_unstable();
7859 v.get(v.len() / 2).copied().unwrap_or(0)
7860 };
7861 median(&mut left) > RUNNING_LETTERS && median(&mut right) > RUNNING_LETTERS
7862}
7863
7864/// Attempt a vertical (column) cut. Returns the span groups plus the
7865/// gap size (in pt) if a suitable gutter is found AND the density +
7866/// alignment guards accept.
7867///
7868/// ANN[r17/TEX2] Three guards together avoid false-positive columns:
7869/// 1. `min_gap` is the MAX of (median_font, 10pt) — deliberately
7870/// lower than `median_font * 2` so narrow-gutter academic papers
7871/// (12pt gutters, common in print) are still detected. It does not
7872/// scale with the region: see the note on the constants above.
7873/// 2. `columns_are_dense` rejects column splits where either side
7874/// has <2 spans or <8 chars/band — catches table cells.
7875/// 3. `columns_are_band_aligned` rejects cuts where any band would
7876/// end up on only one side of the cut while being wider than
7877/// ~70% of that side's column width — catches full-width
7878/// paragraphs (Intro / Outro) that accidentally sit in the
7879/// left-column x-range.
7880fn try_vertical_cut(
7881 spans: &[TextSpan],
7882 stats: &PageStats,
7883 rules: &[HorizontalRule],
7884) -> Option<(Vec<Vec<TextSpan>>, f64)> {
7885 if spans.len() < 2 * XY_CUT_MIN_SPANS_PER_COLUMN {
7886 return None;
7887 }
7888 // A LINE NUMBER DRAWN INTO ITS LINE IS PEELED OFF FIRST (#1973), so that
7889 // the margin scan below can see it for what it is.
7890 let peeled = peel_line_numbers(spans);
7891 let spans: &[TextSpan] = &peeled;
7892
7893 let region_left = spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
7894 let region_right = spans
7895 .iter()
7896 .map(TextSpan::right)
7897 .fold(f64::NEG_INFINITY, f64::max);
7898 let region_width = region_right - region_left;
7899 if region_width <= 0.0 {
7900 return None;
7901 }
7902
7903 // ANN[r17/TEX2][r17/TEX4] Threshold uses the ADAPTIVE median-word-gap
7904 // from the bands rather than a flat font-size multiple. Narrow-gutter
7905 // academic layouts have 12pt gutters next to 4pt word spaces — the
7906 // adaptive threshold scales with the actual typography used on this
7907 // page. Clamped to `XY_CUT_VERTICAL_GAP_FLOOR` to avoid firing on
7908 // ordinary inter-word spaces when character advance data is noisy.
7909 // median_font and the width fraction act only as safety rails for
7910 // pathological inputs.
7911 let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
7912 // THE PAGE'S OWN RULING LINES COME FIRST. A region the document rules into
7913 // rows is a table, and a table read down its columns is the defect this
7914 // guard exists for (#545). Asked here as well as in the leaf below because
7915 // a table with a wide gutter is taken by this cut and never reaches the
7916 // leaf at all.
7917 let all_bands: Vec<usize> = (0..bands.len()).collect();
7918 if region_is_ruled(&bands, &all_bands, rules) {
7919 return None;
7920 }
7921 let adaptive = compute_adaptive_column_gap(&bands);
7922 let floor = stats.median_font_size.max(XY_CUT_VERTICAL_GAP_FLOOR);
7923 let min_gap = adaptive.min(floor).max(XY_CUT_VERTICAL_GAP_FLOOR);
7924
7925 // Intervals [x_left, x_right] of every span; we look for an x value
7926 // that is free of ALL intervals (full-height gap).
7927 // MEASURED EDGES ONLY. `right()` returns x + width.max(estimated_width()),
7928 // and the estimate is a character count times a nominal advance -- on
7929 // tracemonkey's abstract it overshoots the measured edge by 61pt, which is
7930 // wider than the gutter it then covers. A gap scan that asks "is any x free
7931 // of every span" cannot use an edge that is deliberately generous.
7932 // Unmeasured spans (width 0) keep the estimate, since a point-wide interval
7933 // would open a gap where there is text.
7934 //
7935 // AND THE MARGIN COLUMN IS NOT INK FOR THIS PURPOSE (#582). A statute and a
7936 // US patent print their line numbers down the middle of the page, INSIDE
7937 // the gutter, so no x is free of every span and the cut is unfindable at
7938 // any depth -- the page comes back read line by line across both columns.
7939 // `a_margin_column` is what those figures are and a word is not. The
7940 // intervals are taken from the bands rather than from the region's own
7941 // slice, because the set that names the spans to look past is indexed on
7942 // each band's spans and two numberings of one set of spans is how an index
7943 // goes wrong.
7944 let word_space = (adaptive / COLUMN_GAP_MEDIAN_MULTIPLIER).max(0.5);
7945 let (mut skipped, figures) = a_margin_column(&bands, region_width, min_gap, word_space);
7946 // A BULLET HUNG INTO THE GUTTER IS NOT THE GUTTER'S INK (#574). A column
7947 // of items outdents its markers to the left of its text, and on a page
7948 // set with a narrow gutter they stand in it: NYSE_MSA's `t` bullets sit
7949 // 10 pt from the left column's measure where the columns' text is 18 pt
7950 // apart, and the page was read across both columns line by line. A glyph
7951 // set apart from the rest of its band -- one character, then more white
7952 // than a word space -- is looked past here and still goes with the column
7953 // its midpoint falls in.
7954 let hung: Vec<HashSet<usize>> = bands
7955 .iter()
7956 .map(|row| {
7957 (0..row.spans.len())
7958 .filter(|&at| {
7959 let span = &row.spans[at];
7960 // LEADING, with white before it as well as after: the
7961 // last glyph of a left-column line, `)` in a run of its
7962 // own, has its line's text right against it (codex,
7963 // #2041).
7964 let apart_before = row.spans[..at]
7965 .iter()
7966 .rev()
7967 .find(|prev| prev.text.chars().any(|c| !c.is_whitespace()))
7968 .is_none_or(|prev| {
7969 span.x - prev.measured_right()
7970 >= HUNG_MARKER_GAP_EM * span.font_size.max(1.0)
7971 });
7972 span.text.chars().filter(|c| !c.is_whitespace()).count() == 1
7973 && apart_before
7974 && row.spans[at + 1..]
7975 .iter()
7976 .find(|next| next.text.chars().any(|c| !c.is_whitespace()))
7977 .is_some_and(|next| {
7978 next.x - span.measured_right()
7979 >= HUNG_MARKER_GAP_EM * span.font_size.max(1.0)
7980 })
7981 })
7982 .collect()
7983 })
7984 .collect();
7985 let scan =
7986 |skipped: &[HashSet<usize>], from: usize, past_markers: bool| -> Option<(f64, f64)> {
7987 let mut intervals: Vec<(f64, f64)> = bands
7988 .iter()
7989 .enumerate()
7990 .skip(from)
7991 .flat_map(|(band, row)| {
7992 let skipped = &skipped[band];
7993 let hung = &hung[band];
7994 row.spans
7995 .iter()
7996 .enumerate()
7997 .filter(move |(at, s)| {
7998 !skipped.contains(at)
7999 && (!past_markers || !is_marker_or_space(hung, *at, s))
8000 })
8001 .map(|(_, s)| {
8002 let r = if s.width > 0.0 {
8003 s.measured_right()
8004 } else {
8005 s.right()
8006 };
8007 (s.x, r.max(s.x + 0.001))
8008 })
8009 })
8010 .collect();
8011 intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
8012 let mut cursor = intervals.first()?.1;
8013 let mut best: Option<(f64, f64)> = None; // (gap_size, cut_x)
8014 for (left, right) in intervals.iter().skip(1) {
8015 if *left > cursor {
8016 let gap = *left - cursor;
8017 if gap >= min_gap && !matches!(best, Some((b, _)) if b >= gap) {
8018 best = Some((gap, (cursor + *left) * 0.5));
8019 }
8020 }
8021 cursor = cursor.max(*right);
8022 }
8023 best
8024 };
8025 // A RUNNING HEAD ACROSS THE GUTTER IS NOT THE PAGE (#1973). A US patent
8026 // centres `US 7,268,176 B2` over the gutter and sets its column numbers a
8027 // line under it, close enough to the body that no horizontal cut takes them
8028 // off first -- so the head alone leaves no x free, and the page was read
8029 // across both columns. Where the gutter is found once the top band or two
8030 // are left out, and those bands are a line of a few runs each, they are a
8031 // group of their own and read first.
8032 //
8033 // ONLY ON A PAGE THAT NUMBERS ITS LINES IN THE GUTTER, which is the page
8034 // this is for. Anywhere else a centred title over a table is the same
8035 // shape -- `SCHEDULE 3` over the two columns of a statute's schedule, a
8036 // heading over a reconciliation table -- and setting it aside handed the
8037 // table to the column cut and read it down its columns (validation,
8038 // uksi_20200121 0.939 -> 0.627, OTC_SDXHF 0.774 -> 0.455).
8039 let mut head = 0;
8040 let mut best_gap = scan(&skipped, 0, false);
8041 // Numbered BY A COLUMN OF FIGURES, as the margin scan found it: every
8042 // member it confirmed on its own band is a figure. The scan also looks past
8043 // a column of tab runs down a financial table (a 10-K, 0.641 -> 0.535) and
8044 // a list's markers, and one digit among `i`, `ii` is not a numbered page
8045 // (codex, #1991).
8046 let numbered = figures && skipped.iter().any(|band| !band.is_empty());
8047 if best_gap.is_none() && numbered {
8048 for drop in 1..=HEAD_BANDS_MAX.min(bands.len().saturating_sub(2)) {
8049 if bands[drop - 1].spans.len() > HEAD_BAND_MAX_SPANS {
8050 break;
8051 }
8052 if let Some(found) = scan(&skipped, drop, false) {
8053 best_gap = Some(found);
8054 head = drop;
8055 break;
8056 }
8057 }
8058 }
8059
8060 // ONLY WHERE NOTHING ELSE OPENS A GUTTER is it looked for past the
8061 // markers and word spaces standing in it, and what it then separates has
8062 // to be running text on both sides -- see `is_marker_or_space`.
8063 let mut past_markers = false;
8064 if best_gap.is_none() {
8065 best_gap = scan(&skipped, 0, true);
8066 past_markers = best_gap.is_some();
8067 }
8068 let (gap_size, cut_x) = best_gap?;
8069 // A marker looked past belongs to the column it hangs before, so the cut
8070 // goes just left of the first one standing in the gap.
8071 let cut_x = if past_markers {
8072 bands
8073 .iter()
8074 .zip(&hung)
8075 .flat_map(|(row, hung)| {
8076 row.spans
8077 .iter()
8078 .enumerate()
8079 .filter(|(at, s)| is_marker_or_space(hung, *at, s))
8080 .map(|(_, s)| s.x)
8081 .collect::<Vec<_>>()
8082 })
8083 .filter(|x| (x - cut_x).abs() < gap_size * 0.5)
8084 .fold(cut_x, f64::min)
8085 - 0.01
8086 } else {
8087 cut_x
8088 };
8089
8090 // AND THE SKIP HAS TO HAVE BEEN WHAT WAS IN THE WAY. A thin column
8091 // somewhere ELSE on the page -- the hanging figures of a bibliography at
8092 // the left edge of each column, which are narrow, short and at one x on
8093 // every band -- is not a reason to believe in a gutter it does not stand
8094 // in. Where the column the scan looked past is not inside the gap that was
8095 // found, the scan is run again with nothing skipped and THAT cut is the
8096 // answer: refusing instead would take the columns off a page that has a
8097 // perfectly good gutter, which cost `tracemonkey.pdf` page 14 its reading
8098 // order, 100.0 to 74.5, where both foreign readers agree at 100.
8099 let margin_of = |sets: &[HashSet<usize>]| -> Vec<TextSpan> {
8100 bands
8101 .iter()
8102 .enumerate()
8103 .flat_map(|(band, row)| {
8104 let set = &sets[band];
8105 row.spans
8106 .iter()
8107 .enumerate()
8108 .filter(move |(at, _)| set.contains(at))
8109 .map(|(_, s)| s.clone())
8110 })
8111 .collect()
8112 };
8113 let mut margin = margin_of(&skipped);
8114 let inside = |spans: &[TextSpan], cut_x: f64, gap: f64| {
8115 spans
8116 .iter()
8117 .all(|s| s.x >= cut_x - gap && s.measured_right() <= cut_x + gap)
8118 };
8119 let (gap_size, cut_x) = if margin.is_empty() || inside(&margin, cut_x, gap_size) {
8120 (gap_size, cut_x)
8121 } else {
8122 skipped = vec![HashSet::new(); bands.len()];
8123 margin.clear();
8124 scan(&skipped, head, past_markers)?
8125 };
8126 let head_spans: Vec<TextSpan> = bands[..head].iter().flat_map(|b| b.spans.clone()).collect();
8127 let in_margin: HashSet<(u64, u64)> = margin
8128 .iter()
8129 .chain(&head_spans)
8130 .map(|s| (s.x.to_bits(), s.y.to_bits()))
8131 .collect();
8132
8133 // Split spans around the cut. A span whose midpoint is < cut_x belongs to
8134 // the left group; the margin column is a group of its OWN, so the line
8135 // numbers come out as their own block instead of inside the running text
8136 // of whichever column they happened to fall on.
8137 let mut left_group = Vec::new();
8138 let mut right_group = Vec::new();
8139 for span in spans {
8140 if in_margin.contains(&(span.x.to_bits(), span.y.to_bits())) {
8141 continue;
8142 }
8143 let midpoint = span.x + (span.right() - span.x) * 0.5;
8144 if midpoint < cut_x {
8145 left_group.push(span.clone());
8146 } else {
8147 right_group.push(span.clone());
8148 }
8149 }
8150
8151 if !columns_are_dense(&left_group, &right_group, stats) {
8152 return None;
8153 }
8154 // WITHOUT THE MARGIN COLUMN, for the reason the gap scan is run without it:
8155 // a band that carries a line number would otherwise reach across the cut
8156 // by the width of two figures and be read as a band sitting on one side.
8157 let aligned: Vec<TextSpan> = if in_margin.is_empty() {
8158 spans.to_vec()
8159 } else {
8160 spans
8161 .iter()
8162 .filter(|s| !in_margin.contains(&(s.x.to_bits(), s.y.to_bits())))
8163 .cloned()
8164 .collect()
8165 };
8166 let aligned: Vec<TextSpan> = if past_markers {
8167 aligned
8168 .into_iter()
8169 .filter(|s| s.text.chars().filter(|c| !c.is_whitespace()).count() > 1)
8170 .collect()
8171 } else {
8172 aligned
8173 };
8174 if past_markers && !both_sides_run_on(&bands[head..], cut_x) {
8175 return None;
8176 }
8177 if !columns_are_band_aligned(&aligned, cut_x, region_left, region_right, stats) {
8178 return None;
8179 }
8180 if a_column_of_labels(&bands[head..], cut_x, region_left, region_right, min_gap) {
8181 return None;
8182 }
8183 // THE SPANS THE SCAN SKIPPED REACH THE GUARD, and this is the caller the
8184 // parameter was added for. Counted as ink, a column of line numbers beside
8185 // the gutter puts a wide strip of white between itself and the rest of its
8186 // side on nearly every band, which reads as a bank's own cell boundary --
8187 // and the guard would refuse exactly the cut that skipping them was meant
8188 // to allow (#582, on the parameter #580 landed).
8189 if the_cut_is_a_column_of_a_table(&bands[head..], cut_x, gap_size, &skipped[head..]) {
8190 return None;
8191 }
8192
8193 let mut groups = Vec::new();
8194 if !head_spans.is_empty() {
8195 groups.push(head_spans);
8196 }
8197 groups.push(left_group);
8198 groups.push(right_group);
8199 if !margin.is_empty() {
8200 groups.push(margin);
8201 }
8202 Some((groups, gap_size))
8203}
8204
8205/// A line number drawn into the line beside it, split off as a span of its own
8206/// (#1973).
8207///
8208/// `a_margin_column` looks past a span that IS a line number. An OCR text layer
8209/// -- every US patent of DocLayNet's validation half set in two columns, 45 of
8210/// their 65 pages -- draws each right-column line as one object that opens in
8211/// the gutter with the line's number, so `10 example, aluminum and steel` is
8212/// one span from the gutter to the margin: a number and a line, which that
8213/// scan cannot look past, and the page was read across both columns. On the
8214/// rig that was one document, 9840656_AdobeOCR, and the whole of the text gap
8215/// to the reference: 0.340 against 0.886, 0.050 of the mean over eleven.
8216///
8217/// THE GEOMETRY DECIDES, as it does for the margin column: the head of the
8218/// span is one to three figures, then white, and the rest of the span starts
8219/// on an edge at least [`MARGIN_COLUMN_MIN_BANDS`] other spans start on --
8220/// the column's own left edge -- while the figures stand left of it. A line
8221/// that opens with a number at the column's edge (`10 people attended`) does
8222/// not stick out and is left whole; a figure that sticks out of a column
8223/// nothing else starts on is not peeled either. Whether the peeled figure is
8224/// then a margin column is `a_margin_column`'s question, asked as before.
8225fn peel_line_numbers(spans: &[TextSpan]) -> Vec<TextSpan> {
8226 let mut out = Vec::with_capacity(spans.len());
8227 for (at, span) in spans.iter().enumerate() {
8228 match peel_one(span, at, spans) {
8229 Some((figure, rest)) => {
8230 out.push(figure);
8231 out.push(rest);
8232 }
8233 None => match peel_trailing(span, at, spans) {
8234 Some((rest, figure)) => {
8235 out.push(rest);
8236 out.push(figure);
8237 }
8238 None => out.push(span.clone()),
8239 },
8240 }
8241 }
8242 out
8243}
8244
8245fn peel_one(span: &TextSpan, at: usize, spans: &[TextSpan]) -> Option<(TextSpan, TextSpan)> {
8246 let digits = span.text.chars().take_while(char::is_ascii_digit).count();
8247 if !(1..=3).contains(&digits) || !span.text[digits..].starts_with(' ') {
8248 return None;
8249 }
8250 let bounds = &span.char_bounds;
8251 if bounds.len() <= digits {
8252 return None;
8253 }
8254 let size = span.font_size.max(1.0);
8255 let figure_right = bounds[digits - 1][2];
8256 let clear = size * MARGIN_COLUMN_MIN_CLEARANCE_EM;
8257 // The rest opens at the first glyph clear of the figures: a drawn space
8258 // between them is a glyph of its own and belongs to neither.
8259 let first = (digits..bounds.len()).find(|&k| bounds[k][0] >= figure_right + clear)?;
8260 let rest_x = bounds[first][0];
8261 let tolerance = size * PEEL_EDGE_EM;
8262 if span.x >= rest_x - tolerance {
8263 return None;
8264 }
8265 let on_edge = spans
8266 .iter()
8267 .enumerate()
8268 .filter(|(other, s)| *other != at && (s.x - rest_x).abs() <= tolerance)
8269 .count();
8270 if on_edge < MARGIN_COLUMN_MIN_BANDS {
8271 return None;
8272 }
8273 let rest_text = span.text[digits..].trim_start();
8274 if rest_text.is_empty() {
8275 return None;
8276 }
8277 let cut = |v: &[[f64; 4]], lo: usize, hi: usize| {
8278 if v.len() == bounds.len() {
8279 v[lo..hi].to_vec()
8280 } else {
8281 Vec::new()
8282 }
8283 };
8284 let right = span.measured_right();
8285 let mut figure = span.clone();
8286 figure.text = span.text[..digits].to_string();
8287 figure.width = figure_right - span.x;
8288 figure.char_bounds = bounds[..digits].to_vec();
8289 figure.tight_char_bounds = cut(&span.tight_char_bounds, 0, digits);
8290 let mut rest = span.clone();
8291 rest.text = rest_text.to_string();
8292 rest.x = rest_x;
8293 rest.width = (right - rest_x).max(0.0);
8294 rest.char_bounds = bounds[first..].to_vec();
8295 // THE ORIGIN MOVES WITH THE SPAN (codex, #1991): `transform` locates the
8296 // span's first glyph, and the rest's first glyph is `first`, not the
8297 // figure it was drawn after.
8298 if let Some(t) = rest.transform.as_mut() {
8299 t[4] += bounds[first][0] - bounds[0][0];
8300 t[5] += bounds[first][1] - bounds[0][1];
8301 }
8302 rest.tight_char_bounds = cut(&span.tight_char_bounds, first, bounds.len());
8303 // The rich-geometry vectors run glyph for glyph beside the tight boxes;
8304 // split where they line up, dropped where they do not.
8305 if span.glyph_advances.len() == bounds.len() {
8306 figure.glyph_advances = span.glyph_advances[..digits].to_vec();
8307 rest.glyph_advances = span.glyph_advances[first..].to_vec();
8308 } else {
8309 figure.glyph_advances.clear();
8310 rest.glyph_advances.clear();
8311 }
8312 if span.glyph_bounds_sources.len() == bounds.len() {
8313 figure.glyph_bounds_sources = span.glyph_bounds_sources[..digits].to_vec();
8314 rest.glyph_bounds_sources = span.glyph_bounds_sources[first..].to_vec();
8315 } else {
8316 figure.glyph_bounds_sources.clear();
8317 rest.glyph_bounds_sources.clear();
8318 }
8319 Some((figure, rest))
8320}
8321
8322/// A line number drawn at the END of the line before it, the mirror of
8323/// [`peel_one`] (#574). A US patent prints its line numbers in the gutter, and
8324/// where a left-column line runs close to them the OCR layer's words join the
8325/// figure into the line's run: `market, citric acid and/or 35` (8287919)
8326/// ended nine points into a gutter of fourteen, and the page was read across
8327/// both columns line by line. Peeled the same way: one to three figures at the
8328/// end, white before them, and the rest ending on an edge at least
8329/// [`MARGIN_COLUMN_MIN_BANDS`] other spans end on -- the column's own right
8330/// edge -- with the figures standing right of it.
8331///
8332/// Counted from the END, because that is where the glyphs and the text agree:
8333/// a space the device inserts between two glyphs has no box, so the boxes and
8334/// the characters are out of step from the first inserted space on, but a
8335/// space is only ever inserted BEFORE a glyph, so whatever ends the text was
8336/// drawn.
8337fn peel_trailing(span: &TextSpan, at: usize, spans: &[TextSpan]) -> Option<(TextSpan, TextSpan)> {
8338 let body = span.text.trim_end();
8339 let tail_ws = span.text[body.len()..].chars().count();
8340 let digits = body.chars().rev().take_while(char::is_ascii_digit).count();
8341 if !(1..=3).contains(&digits) || !body[..body.len() - digits].ends_with(' ') {
8342 return None;
8343 }
8344 let rest_text = body[..body.len() - digits].trim_end();
8345 if rest_text.is_empty() {
8346 return None;
8347 }
8348 let bounds = &span.char_bounds;
8349 if bounds.len() <= tail_ws + digits {
8350 return None;
8351 }
8352 let figure_end = bounds.len() - tail_ws;
8353 let figure_start = figure_end - digits;
8354 let size = span.font_size.max(1.0);
8355 let figure_left = bounds[figure_start][0];
8356 let clear = size * MARGIN_COLUMN_MIN_CLEARANCE_EM;
8357 // The rest ends at the last glyph clear of the figures: a drawn space
8358 // between them reaches up to the figure and belongs to neither.
8359 let last = (0..figure_start)
8360 .rev()
8361 .find(|&k| bounds[k][2] <= figure_left - clear)?;
8362 let rest_right = bounds[last][2];
8363 let tolerance = size * PEEL_EDGE_EM;
8364 let figure_right = bounds[figure_end - 1][2];
8365 if figure_right <= rest_right + tolerance {
8366 return None;
8367 }
8368 let on_edge = spans
8369 .iter()
8370 .enumerate()
8371 .filter(|(other, s)| *other != at && (s.measured_right() - rest_right).abs() <= tolerance)
8372 .count();
8373 if on_edge < MARGIN_COLUMN_MIN_BANDS {
8374 return None;
8375 }
8376 let cut = |v: &[[f64; 4]], lo: usize, hi: usize| {
8377 if v.len() == bounds.len() {
8378 v[lo..hi].to_vec()
8379 } else {
8380 Vec::new()
8381 }
8382 };
8383 let mut rest = span.clone();
8384 rest.text = rest_text.to_string();
8385 rest.width = (rest_right - span.x).max(0.0);
8386 rest.char_bounds = bounds[..=last].to_vec();
8387 rest.tight_char_bounds = cut(&span.tight_char_bounds, 0, last + 1);
8388 let mut figure = span.clone();
8389 figure.text = body[body.len() - digits..].to_string();
8390 figure.x = figure_left;
8391 figure.width = (figure_right - figure_left).max(0.0);
8392 figure.char_bounds = bounds[figure_start..figure_end].to_vec();
8393 figure.tight_char_bounds = cut(&span.tight_char_bounds, figure_start, figure_end);
8394 // The origin moves with the figure, as it does with a peeled rest.
8395 if let Some(t) = figure.transform.as_mut() {
8396 t[4] += bounds[figure_start][0] - bounds[0][0];
8397 t[5] += bounds[figure_start][1] - bounds[0][1];
8398 }
8399 if span.glyph_advances.len() == bounds.len() {
8400 rest.glyph_advances = span.glyph_advances[..=last].to_vec();
8401 figure.glyph_advances = span.glyph_advances[figure_start..figure_end].to_vec();
8402 } else {
8403 rest.glyph_advances.clear();
8404 figure.glyph_advances.clear();
8405 }
8406 if span.glyph_bounds_sources.len() == bounds.len() {
8407 rest.glyph_bounds_sources = span.glyph_bounds_sources[..=last].to_vec();
8408 figure.glyph_bounds_sources = span.glyph_bounds_sources[figure_start..figure_end].to_vec();
8409 } else {
8410 rest.glyph_bounds_sources.clear();
8411 figure.glyph_bounds_sources.clear();
8412 }
8413 Some((rest, figure))
8414}
8415
8416/// Whether a column of candidates is a count of the page's lines: every member
8417/// a whole number, rising strictly from the top band down, all of them multiples
8418/// of one step of at least two -- `5`, `10`, `15`, with the members the scan did
8419/// not confirm simply missing (#574).
8420fn counts_the_lines(column: &[(usize, usize, f64)], bands: &[TextBand]) -> bool {
8421 let mut members: Vec<(usize, u32)> = Vec::with_capacity(column.len());
8422 for &(band, at, _) in column {
8423 // One to three figures, as a printed line number is. Nothing earlier
8424 // limits a candidate's length, and a column of years rises by a common
8425 // step as well: `2018`, `2020`, `2022` (codex, #2056).
8426 let text = bands[band].spans[at].text.trim();
8427 if text.chars().count() > MARGIN_FIGURE_MAX_CHARS {
8428 return false;
8429 }
8430 match text.parse::<u32>() {
8431 Ok(value) => members.push((band, value)),
8432 Err(_) => return false,
8433 }
8434 }
8435 if members.len() < MARGIN_COLUMN_MIN_BANDS {
8436 return false;
8437 }
8438 members.sort_unstable();
8439 let rising = members
8440 .windows(2)
8441 .all(|pair| pair[0].0 < pair[1].0 && pair[0].1 < pair[1].1);
8442 let step = members.iter().fold(0, |g, &(_, v)| gcd(g, v));
8443 rising && step >= 2
8444}
8445
8446fn gcd(a: u32, b: u32) -> u32 {
8447 if b == 0 {
8448 a
8449 } else {
8450 gcd(b, a % b)
8451 }
8452}
8453
8454/// How many bands at the top of a region may be set aside as its running head
8455/// when they are what fills the gutter (#1973): the head and the line of
8456/// column numbers a patent sets under it.
8457const HEAD_BANDS_MAX: usize = 2;
8458
8459/// The most runs a band set aside as a head may hold. A running head is a line
8460/// or two of a few runs; a band of more is a line of the page's own text, and
8461/// a region whose own first line crosses the gutter is not two columns.
8462const HEAD_BAND_MAX_SPANS: usize = 3;
8463
8464/// How near an edge a span has to start to be on it, in ems of the peeled
8465/// span's type: half an em, well inside a gutter and well over the jitter of
8466/// an OCR layer's left edges (a fifth of a point on the pages it was built for).
8467const PEEL_EDGE_EM: f64 = 0.5;
8468
8469/// Whether a run is a figure and nothing else, as a printed line number is.
8470fn only_figures(text: &str) -> bool {
8471 let t = text.trim();
8472 !t.is_empty() && t.chars().all(|c| c.is_ascii_digit())
8473}
8474
8475/// The most characters a member of a margin column may carry when it is taken
8476/// in by the column rather than by its own band (#1973): a line number is
8477/// three figures at most.
8478const MARGIN_FIGURE_MAX_CHARS: usize = 3;
8479
8480/// How wide a margin column may be, as a fraction of the region it stands in.
8481///
8482/// A tenth. A line number is two or three figures against a measure of sixty
8483/// characters; a column of a table is a third of its row, and this is what
8484/// refuses it before any of the other conditions is asked.
8485const MARGIN_COLUMN_MAX_WIDTH_FRACTION: f64 = 0.10;
8486
8487/// How much of its own band's text a margin column may carry.
8488///
8489/// COUNTED IN CHARACTERS AND NOT IN WIDTH, so that a proportional face does
8490/// not decide it: `10` beside a line of prose is two characters of sixty.
8491const MARGIN_COLUMN_MAX_CHAR_SHARE: f64 = 0.15;
8492
8493/// How much white a margin column needs on each side, in ems of its own type.
8494///
8495/// A fifth: under a word space in any face and over the fit between letters.
8496const MARGIN_COLUMN_MIN_CLEARANCE_EM: f64 = 0.2;
8497
8498/// How many bands a margin column has to stand on before it is one.
8499///
8500/// Three, for the reason the running-head rule of #571 asks for three pages: a
8501/// pair of short runs at one x is a coincidence a page cannot rule out.
8502const MARGIN_COLUMN_MIN_BANDS: usize = 3;
8503
8504/// The spans the gap scan may look past, per band (#582).
8505///
8506/// A statute and a US patent print their line numbers down the middle of the
8507/// page, INSIDE the gutter. The scan asks for an x free of every span, so such
8508/// a page has no free x at any depth and comes back read line by line across
8509/// both columns -- the whole of what the three patent pages of run
8510/// `structured-doclaynet-20260922T081244Z` lost, 1.227 of the 4.888 the
8511/// interleave class cost.
8512///
8513/// FOUR CONDITIONS, AND NONE OF THEM ABOUT WHAT THE SPAN SAYS. A rule that
8514/// read the text would fire on a verse number and on a marginal note, and
8515/// would have to be taught every language's figures:
8516///
8517/// 1. WHITE ON BOTH SIDES within its own band, measured against its OWN type
8518/// rather than against the gutter: a margin column stands inside a gutter
8519/// of twelve points and has three or four either side, so a threshold taken
8520/// from the gutter's width would refuse every real one.
8521/// 2. NARROW -- at most [`MARGIN_COLUMN_MAX_WIDTH_FRACTION`] of the region.
8522/// 3. A NEGLIGIBLE SHARE OF ITS BAND'S CHARACTERS.
8523/// 4. A COLUMN, AND EXACTLY ONE: the candidates stand at nearly the same x on
8524/// at least [`MARGIN_COLUMN_MIN_BANDS`] bands, and where two such columns
8525/// are found NOTHING is skipped.
8526///
8527/// The second and the fourth are what keep a table of figures out, and they
8528/// were asked for rather than assumed (T2, #577/#580): every cell of a bank of
8529/// figures has white on both sides, so the first condition alone would make
8530/// the boundary INSIDE a table findable and undo what #577 took out. A cell is
8531/// a third of its row, which the second refuses; and a table has several such
8532/// columns, which the fourth refuses.
8533/// Returns the spans to look past, one set per band, and whether the column
8534/// they form is FIGURES in every member its own bands found -- the evidence a
8535/// caller needs before it treats the page as one that numbers its lines
8536/// (codex, #1991): a column of `i`, `1`, `ii` is a list's markers.
8537fn a_margin_column(
8538 bands: &[TextBand],
8539 region_width: f64,
8540 min_gap: f64,
8541 word_space: f64,
8542) -> (Vec<HashSet<usize>>, bool) {
8543 let mut out: Vec<HashSet<usize>> = vec![HashSet::new(); bands.len()];
8544 if bands.len() < MARGIN_COLUMN_MIN_BANDS || region_width <= 0.0 {
8545 return (out, false);
8546 }
8547 let mut candidates: Vec<(usize, usize, f64)> = Vec::new();
8548 for (band, row) in bands.iter().enumerate() {
8549 let letters: usize = row.spans.iter().map(|s| s.text.chars().count()).sum();
8550 if letters == 0 {
8551 continue;
8552 }
8553 for (at, span) in row.spans.iter().enumerate() {
8554 let right = span.measured_right().max(span.x);
8555 if right - span.x > MARGIN_COLUMN_MAX_WIDTH_FRACTION * region_width {
8556 continue;
8557 }
8558 if span.text.chars().count() as f64 > MARGIN_COLUMN_MAX_CHAR_SHARE * letters as f64 {
8559 continue;
8560 }
8561 let before = row
8562 .spans
8563 .iter()
8564 .filter(|other| other.measured_right() <= span.x)
8565 .map(TextSpan::measured_right)
8566 .fold(f64::NEG_INFINITY, f64::max);
8567 let after = row
8568 .spans
8569 .iter()
8570 .filter(|other| other.x >= right)
8571 .map(|other| other.x)
8572 .fold(f64::INFINITY, f64::min);
8573 if !before.is_finite() || !after.is_finite() {
8574 continue;
8575 }
8576 let clear = (span.font_size * MARGIN_COLUMN_MIN_CLEARANCE_EM).max(word_space.min(1.0));
8577 if span.x - before <= clear || after - right <= clear {
8578 continue;
8579 }
8580 candidates.push((band, at, span.x + (right - span.x) * 0.5));
8581 }
8582 }
8583 if candidates.len() < MARGIN_COLUMN_MIN_BANDS {
8584 return (out, false);
8585 }
8586 candidates.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(Ordering::Equal));
8587 let mut columns: Vec<Vec<(usize, usize, f64)>> = Vec::new();
8588 for candidate in candidates {
8589 match columns.last_mut() {
8590 Some(column) if candidate.2 - column[column.len() - 1].2 <= min_gap => {
8591 column.push(candidate)
8592 }
8593 _ => columns.push(vec![candidate]),
8594 }
8595 }
8596 let mut found: Vec<&Vec<(usize, usize, f64)>> = columns
8597 .iter()
8598 .filter(|column| {
8599 let mut on: Vec<usize> = column.iter().map(|c| c.0).collect();
8600 on.sort_unstable();
8601 on.dedup();
8602 on.len() >= MARGIN_COLUMN_MIN_BANDS
8603 })
8604 .collect();
8605 // EXACTLY ONE. Two of them are the banks of a table, not a margin.
8606 //
8607 // UNLESS ONE OF THEM COUNTS THE PAGE'S LINES (#574). A US patent with a
8608 // table on it has the table's banks AND its gutter's line numbers, and the
8609 // rule above refused them all, so the page was read across both columns:
8610 // `6420562_AdobeOCR` p. 53, a bank of `0.0`, `4.0`, `64.9` beside the
8611 // gutter's `5`, `10` ... `65`. What a line-number column has and a bank of
8612 // figures does not is a count: whole numbers rising down the page by a
8613 // common step. A table's row numbers rise by one and are refused by the
8614 // step; a column of years has four figures and is refused by its width.
8615 if found.len() > 1 {
8616 found.retain(|column| counts_the_lines(column, bands));
8617 }
8618 if found.len() != 1 {
8619 return (out, false);
8620 }
8621 let column = found.pop().expect("one column");
8622 for (band, at, _) in column.iter() {
8623 out[*band].insert(*at);
8624 }
8625 // AND ITS MEMBERS THAT STAND BESIDE NO LINE (#1973). A line number beside
8626 // the first line of a paragraph in the right column, where the left
8627 // column's paragraph has just ended, has white after it and nothing before
8628 // it -- the first clause asks for both, and the figure stayed in the
8629 // gutter as ink. Once the column is found by the members that do have both,
8630 // a figure at the same x with white after it is one of them.
8631 //
8632 // A COLUMN OF FIGURES ONLY, where every member found is one. The same shape
8633 // with nothing before it is also the marker of a list hung out of its
8634 // column -- `i`, `ii`, `iii` down the left of a reconciliation table --
8635 // and taken into the column those markers let the cut through the table
8636 // (validation, OTC_SDXHF 0.774 -> 0.455). A column of line numbers says
8637 // what it is in every member it has.
8638 if !column
8639 .iter()
8640 .all(|&(band, at, _)| only_figures(&bands[band].spans[at].text))
8641 {
8642 return (out, false);
8643 }
8644 let centre = column.iter().map(|c| c.2).sum::<f64>() / column.len() as f64;
8645 for (band, row) in bands.iter().enumerate() {
8646 let letters: usize = row.spans.iter().map(|s| s.text.chars().count()).sum();
8647 for (at, span) in row.spans.iter().enumerate() {
8648 // Three characters at most, and not necessarily figures: the OCR
8649 // layer that draws these reads a `5` as an `s` often enough, and a
8650 // column that is figures in every member it was found by has said
8651 // what it holds (9017715_AdobeOCR, `hormone- s dependent`).
8652 if out[band].contains(&at) || span.text.trim().chars().count() > MARGIN_FIGURE_MAX_CHARS
8653 {
8654 continue;
8655 }
8656 let right = span.measured_right().max(span.x);
8657 if (span.x + (right - span.x) * 0.5 - centre).abs() > min_gap
8658 || right - span.x > MARGIN_COLUMN_MAX_WIDTH_FRACTION * region_width
8659 || span.text.chars().count() as f64 > MARGIN_COLUMN_MAX_CHAR_SHARE * letters as f64
8660 {
8661 continue;
8662 }
8663 let after = row
8664 .spans
8665 .iter()
8666 .filter(|other| other.x >= right)
8667 .map(|other| other.x)
8668 .fold(f64::INFINITY, f64::min);
8669 let clear = (span.font_size * MARGIN_COLUMN_MIN_CLEARANCE_EM).max(word_space.min(1.0));
8670 // AND WITH NOTHING AFTER IT (#574): where the right column has no
8671 // line -- a paragraph ends, a table stands in the other column --
8672 // the figure has the left column's line before it and white to the
8673 // page's edge. White before it is then what says it stands apart.
8674 let before = row
8675 .spans
8676 .iter()
8677 .filter(|other| other.measured_right() <= span.x)
8678 .map(TextSpan::measured_right)
8679 .fold(f64::NEG_INFINITY, f64::max);
8680 let apart = if after.is_finite() {
8681 after - right > clear
8682 } else {
8683 !before.is_finite() || span.x - before > clear
8684 };
8685 if apart {
8686 out[band].insert(at);
8687 }
8688 }
8689 }
8690 (out, true)
8691}
8692
8693/// Whether the gap being cut at is a boundary INSIDE a table rather than THE
8694/// gutter of a page set in columns.
8695///
8696/// `a_column_of_labels` above reads the table whose thin side is a column of
8697/// names. This is the other half of the same defect and the one shape none of
8698/// the guards here could see (#577): a table of figures whose two banks of
8699/// columns are both dense on every band. `region_is_ruled` cannot refuse it --
8700/// the document rules the head off and nothing else, which is one gap of eight
8701/// -- and `columns_are_dense` was written to catch a thin side, so a bank of
8702/// numbers passes it. Cut down the strip between the banks, the table comes out
8703/// as two tables side by side: every row of it is halved, and the figures of
8704/// row four stand under the figures of row one.
8705///
8706/// WHAT SEPARATES THE TWO CASES IS WHETHER THE CUT IS SPECIAL. A page set in
8707/// columns puts its widest strip of white between the columns, and a line of
8708/// prose inside one has nothing but word spaces in it -- so a side of that cut
8709/// holds bands with no gap anywhere near the gutter's width. A bank of a table
8710/// is columns of cells, so its own bands break at gaps as wide as the strip
8711/// between the banks or wider: the gutter that was cut at is not the widest
8712/// thing on the page, it is one of several the same size, and a table is what
8713/// that means.
8714///
8715/// BOTH SIDES, and that is the guard against the page this must not refuse. A
8716/// paper set in two columns with a table in ONE of them breaks the lines of
8717/// that column and not of the other, and its column cut is right; a run of
8718/// bands broken on both sides of the cut, most of the way down, is one grid.
8719///
8720/// AND A SPAN THE GAP SCAN SKIPPED IS NOT A CELL BOUNDARY. `ignored` names, per
8721/// band, the spans the caller left out of the scan that found this gap -- the
8722/// case it exists for is a thin column standing INSIDE a gutter, printed line
8723/// numbers down the middle of a statute or a patent, which makes every x in the
8724/// gutter occupied and the cut unfindable at any depth (#582). Those spans have
8725/// to be left out here too. Counted as ink, a column of numbers beside the
8726/// gutter puts a wide strip of white between itself and the rest of its side on
8727/// nearly every band -- which reads as a bank's own cell boundary, and this
8728/// guard would refuse exactly the cut that skipping them was meant to allow.
8729///
8730/// ONE SET PER BAND, parallel to `bands`, because a band's spans are numbered
8731/// within that band: the band that carries a line number holds three runs where
8732/// the band beside it holds two, so one set of indices for the whole region
8733/// would strike the number on one band and a column of text on the next (#582).
8734/// An empty slice ignores nothing, which is what `try_vertical_cut` passes; a
8735/// caller that skips spans builds its sets from the same banding the guard is
8736/// given.
8737fn the_cut_is_a_column_of_a_table(
8738 bands: &[TextBand],
8739 cut_x: f64,
8740 gap: f64,
8741 ignored: &[HashSet<usize>],
8742) -> bool {
8743 if gap <= 0.0 {
8744 return false;
8745 }
8746 // THE SAME EDGE ON BOTH SIDES OF THE DIVISION. `region_left` and
8747 // `region_right` are taken from `TextSpan::right()`, which is the greater of
8748 // the measured width and an estimate from the character count -- and on
8749 // prose that estimate overshoots the glyphs by tens of points. The extent
8750 // below is measured ink. Dividing one by the other inflates the denominator
8751 // and understates the fill, which is how a page of prose could be read as a
8752 // bank of cells and a legitimate column cut refused (codex, #577). So the
8753 // measure is the measured extent of the bands themselves.
8754 //
8755 // THE EXTENT IS OF THE BANDS AS THE GUARD SEES THEM, so `ignored` reaches it
8756 // too: a line-number column left out of the scan must not set the edge that
8757 // the fill is divided by, or the side it stands on reads as emptier than it is.
8758 let (ink_left, ink_right) = measured_extent(bands, ignored);
8759 let left = one_side_of_a_cut(
8760 bands,
8761 cut_x,
8762 true,
8763 (cut_x - ink_left).max(0.0),
8764 gap,
8765 ignored,
8766 );
8767 let right = one_side_of_a_cut(
8768 bands,
8769 cut_x,
8770 false,
8771 (ink_right - cut_x).max(0.0),
8772 gap,
8773 ignored,
8774 );
8775 let (left, right) = match (left, right) {
8776 (Some(left), Some(right)) => (left, right),
8777 _ => return false,
8778 };
8779 let banks = |side: &SideOfACut| (side.broken as f64) > BANK_BREAK_SHARE * side.inked as f64;
8780 if banks(&left) && banks(&right) {
8781 return true;
8782 }
8783 // ONE SIDE OF CELLS IS ENOUGH WHERE THE OTHER SIDE IS CELLS TOO. The other
8784 // half of this shape is a table whose first column is one cell a row --
8785 // names, dates, a label -- against a bank of figures. That side breaks
8786 // nowhere, because one cell cannot have a gap in it, so the test above sees
8787 // one bank and lets the cut through; `a_column_of_labels` does not refuse it
8788 // either, because it asks the thin side to have ink on far fewer bands than
8789 // the wide one and here both sides have ink on nearly all of them (#577).
8790 //
8791 // What decides is the measurement `region_is_a_grid` is built on: a line of
8792 // prose fills its measure and a cell does not. So a side that breaks
8793 // nowhere is still a column OF the table while its typical band leaves
8794 // white beside it -- and the page this must not refuse, a paper set in two
8795 // columns with a table in one of them, has prose on the other side, filling
8796 // it.
8797 (banks(&left) && right.fill <= GRID_SLICE_FILL)
8798 || (banks(&right) && left.fill <= GRID_SLICE_FILL)
8799}
8800
8801/// The measured ink of a set of bands, left and right.
8802///
8803/// `measured_right` rather than `right`, because the fill this feeds is computed
8804/// from measured ink and a measure taken from the estimate would not be the same
8805/// question (codex, #577).
8806///
8807/// `ignored` is the same per-band set the guard is given: a span left out of the
8808/// gap scan is left out of the extent too, or the edge it sets would be divided
8809/// into a fill it does not appear in (#582). An empty slice ignores nothing.
8810fn measured_extent(bands: &[TextBand], ignored: &[HashSet<usize>]) -> (f64, f64) {
8811 let mut left = f64::INFINITY;
8812 let mut right = f64::NEG_INFINITY;
8813 for (b, band) in bands.iter().enumerate() {
8814 let skip = ignored.get(b);
8815 for (i, span) in band.spans.iter().enumerate() {
8816 if skip.is_some_and(|set| set.contains(&i)) {
8817 continue;
8818 }
8819 left = left.min(span.x);
8820 right = right.max(span.measured_right());
8821 }
8822 }
8823 if left.is_finite() && right > left {
8824 (left, right)
8825 } else {
8826 (0.0, 0.0)
8827 }
8828}
8829
8830/// One side of a proposed cut: how many bands have ink on it, how many of them
8831/// break at a gap as wide as the cut's, and how much of the side its typical
8832/// band covers.
8833struct SideOfACut {
8834 inked: usize,
8835 broken: usize,
8836 fill: f64,
8837}
8838
8839/// `None` where the side has ink on too few bands to say anything.
8840fn one_side_of_a_cut(
8841 bands: &[TextBand],
8842 cut_x: f64,
8843 is_left: bool,
8844 measure: f64,
8845 gap: f64,
8846 ignored: &[HashSet<usize>],
8847) -> Option<SideOfACut> {
8848 if measure <= 0.0 {
8849 return None;
8850 }
8851 let mut inked = 0usize;
8852 let mut broken = 0usize;
8853 let mut fills: Vec<f64> = Vec::new();
8854 for (at, band) in bands.iter().enumerate() {
8855 let skipped = ignored.get(at);
8856 let mut ink: Vec<(f64, f64)> = band
8857 .spans
8858 .iter()
8859 .enumerate()
8860 .filter(|(span, _)| skipped.is_none_or(|set| !set.contains(span)))
8861 .map(|(_, span)| span)
8862 .filter(|span| {
8863 let middle = span.x + (span.measured_right() - span.x) * 0.5;
8864 (middle < cut_x) == is_left
8865 })
8866 .map(|span| (span.x, span.measured_right()))
8867 .collect();
8868 if ink.is_empty() {
8869 continue;
8870 }
8871 inked += 1;
8872 ink.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
8873 // The widest strip of white INSIDE this side of the band. A cell
8874 // boundary of a bank, where the side is a bank; a word space, where it
8875 // is a line of prose.
8876 let widest = ink
8877 .windows(2)
8878 .map(|pair| pair[1].0 - pair[0].1)
8879 .fold(0.0_f64, f64::max);
8880 if widest >= gap {
8881 broken += 1;
8882 }
8883 let from = ink.iter().map(|pair| pair.0).fold(f64::INFINITY, f64::min);
8884 let to = ink
8885 .iter()
8886 .map(|pair| pair.1)
8887 .fold(f64::NEG_INFINITY, f64::max);
8888 if to > from {
8889 fills.push(((to - from) / measure).min(1.0));
8890 }
8891 }
8892 if inked < BANK_MIN_ROWS || fills.is_empty() {
8893 return None;
8894 }
8895 fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
8896 Some(SideOfACut {
8897 inked,
8898 broken,
8899 fill: fills[fills.len() / 2],
8900 })
8901}
8902
8903/// Whether the thin side of the cut is a column of LABELS for the other side:
8904/// a table whose cells hold sentences (#576).
8905///
8906/// `region_is_ruled` above says the same thing about a table the document drew
8907/// lines round. This is the commoner table -- no lines, a narrow first column
8908/// of names and a wide one of descriptions -- and cut down its gutter every
8909/// row of it is lost: the names come out as one paragraph, the descriptions as
8910/// another, and nothing says which name went with which description.
8911///
8912/// THE MEASUREMENT IS WHERE THE THIN SIDE'S INK SITS. A label stands against
8913/// the START of what it labels, so every band the thin side has ink on is a
8914/// band where the wide side BEGINS something: the wide side's step into that
8915/// band is wider than the leading it sets its own lines at. A note in the
8916/// margin -- the case this must not refuse -- falls where it falls, in the
8917/// middle of the running text beside it, and its bands are ordinary lines of
8918/// that text.
8919///
8920/// Every band, not most: the thin side is a handful of bands, and a share over
8921/// a handful is a coin toss. One label in the middle of a paragraph and this
8922/// is not a table.
8923fn a_column_of_labels(
8924 bands: &[TextBand],
8925 cut_x: f64,
8926 region_left: f64,
8927 region_right: f64,
8928 min_gap: f64,
8929) -> bool {
8930 fn on(band: &TextBand, left: bool, cut_x: f64) -> Vec<&TextSpan> {
8931 band.spans
8932 .iter()
8933 .filter(|span| {
8934 let middle = span.x + (span.measured_right() - span.x) * 0.5;
8935 (middle < cut_x) == left
8936 })
8937 .collect()
8938 }
8939 let inked = |left: bool| -> Vec<usize> {
8940 (0..bands.len())
8941 .filter(|at| !on(&bands[*at], left, cut_x).is_empty())
8942 .collect()
8943 };
8944 let (thin, wide, thin_is_left) = {
8945 let (l, r) = (inked(true), inked(false));
8946 if l.len() <= r.len() {
8947 (l, r, true)
8948 } else {
8949 (r, l, false)
8950 }
8951 };
8952 if thin.len() < LABEL_MIN_ROWS || thin.len() * 4 > wide.len() * 3 {
8953 return false;
8954 }
8955
8956 // AND A LABEL IS SHORT. The other way a side has ink on few bands is that
8957 // it is the shorter column of a page set in columns -- one that ends
8958 // higher than its neighbour -- and its lines fill their measure to the
8959 // last word, which is what a line of prose does and a cell does not. So
8960 // the typical line of the thin side has to leave white beside it before
8961 // any of this is read as a label at all.
8962 let measure = if thin_is_left {
8963 cut_x - region_left
8964 } else {
8965 region_right - cut_x
8966 };
8967 if measure <= 0.0 {
8968 return false;
8969 }
8970 // AND A LABEL IS ONE CELL: a thin side whose own bands break into several
8971 // runs of ink is not a column at all but more columns, which is what a
8972 // page of glyph samples laid out in a grid looks like from here.
8973 let mut fills: Vec<f64> = Vec::with_capacity(thin.len());
8974 for at in &thin {
8975 let slice = on(&bands[*at], thin_is_left, cut_x);
8976 let broken = slice
8977 .windows(2)
8978 .any(|pair| pair[1].x - pair[0].measured_right() >= min_gap);
8979 if broken {
8980 return false;
8981 }
8982 let from = slice.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
8983 let to = slice
8984 .iter()
8985 .map(|s| s.measured_right())
8986 .fold(f64::NEG_INFINITY, f64::max);
8987 if to > from {
8988 fills.push(((to - from) / measure).min(1.0));
8989 }
8990 }
8991 if fills.is_empty() {
8992 return false;
8993 }
8994 fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
8995 if fills[fills.len() / 2] > GRID_SLICE_FILL {
8996 return false;
8997 }
8998
8999 // The wide side's own leading: the middle step between the bands it has
9000 // ink on. A step wider than `LABEL_PITCH_JUMP` of it is where it begins
9001 // something rather than carrying on.
9002 let mut steps: Vec<f64> = wide
9003 .windows(2)
9004 .map(|pair| bands[pair[0]].y - bands[pair[1]].y)
9005 .filter(|step| *step > 0.0)
9006 .collect();
9007 if steps.len() < 2 {
9008 return false;
9009 }
9010 steps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9011 let leading = steps[steps.len() / 2];
9012 if leading <= 0.0 {
9013 return false;
9014 }
9015
9016 // A LABEL MAY WRAP. `Community resources` set over two lines is one label
9017 // and its second line stands against nothing, so a band the thin side
9018 // carries on into -- one leading under a thin band of its own -- is as
9019 // much part of a label as the band that opened it.
9020 let opens_the_wide_side = |at: usize| match wide.iter().position(|w| *w >= at) {
9021 Some(0) => true,
9022 Some(where_) if wide[where_] == at => {
9023 bands[wide[where_ - 1]].y - bands[at].y > leading * LABEL_PITCH_JUMP
9024 }
9025 _ => false,
9026 };
9027 thin.iter().all(|at| {
9028 *at == 0
9029 || opens_the_wide_side(*at)
9030 || (thin.binary_search(&(at - 1)).is_ok() && bands[at - 1].y - bands[*at].y <= leading)
9031 })
9032}
9033
9034/// ANN[r17/TEX2] Reject a vertical cut when any band sits on only one
9035/// side of the cut AND occupies more than ~70% of that side's column
9036/// width. Such bands are almost certainly full-width paragraphs that
9037/// happened to align with the left margin of one column, and forcing
9038/// them into that column re-orders them relative to text that follows.
9039fn columns_are_band_aligned(
9040 spans: &[TextSpan],
9041 cut_x: f64,
9042 region_left: f64,
9043 region_right: f64,
9044 stats: &PageStats,
9045) -> bool {
9046 let left_width = (cut_x - region_left).max(1.0);
9047 let right_width = (region_right - cut_x).max(1.0);
9048
9049 // Threshold chosen empirically: paragraph bodies in columnar
9050 // layouts usually fill ~60-70% of their column; anything wider
9051 // than 0.7× is a page-level element masquerading as column
9052 // content.
9053 const MAX_SINGLE_SIDE_FRACTION: f64 = 0.70;
9054
9055 let _ = (left_width, right_width, MAX_SINGLE_SIDE_FRACTION);
9056 // ONE SPAN'S INK REACHING ACROSS THE CUT is what "a full-width element
9057 // masquerading as column content" actually looks like. The guard used to
9058 // ask a different question -- is a band that sits on one side wider than
9059 // 0.70 of that side -- and on a real two-column page the answer is yes for
9060 // any ordinary line of body text, because a line fills about 0.9 of its
9061 // column. Every such page has single-sided bands (a heading, a column that
9062 // ends short, a figure), so the old test refused the canonical academic
9063 // page every time (#429).
9064 //
9065 // Width was a proxy for crossing, and it is a proxy that cannot tell a
9066 // banner from a column line. Its own fixture shows why: the banner runs
9067 // x 40..320 against a cut at 200, which is ink on both sides. It counted as
9068 // "one side" only because sides were decided on each span's midpoint.
9069 for span in spans {
9070 if span.x < cut_x && span.measured_right() > cut_x {
9071 return false;
9072 }
9073 }
9074
9075 // A LINE CANNOT BE HALF AGAIN AS WIDE AS THE TYPICAL LINE IN ITS OWN COLUMN
9076 // and still be a line of that column. That is the second way a full-width
9077 // element hides: `Intro paragraph` and `Outro paragraph` in
9078 // mixed_single_and_multi_column_regions_preserve_shared_bands are wider
9079 // than the column they sit above but still stop short of the gutter, so no
9080 // ink of theirs crosses the cut. Swallowed into the left column they would
9081 // read Intro, L1..L3, Outro, R1..R3 -- the outro before the right column it
9082 // precedes.
9083 //
9084 // Measured against the MEDIAN LINE OF THAT SIDE, not against the width of
9085 // the side. The old test used cut_x - region_left, which is a column plus
9086 // half a gutter, so an ordinary body line at ~0.9 of its column tripped it
9087 // and the canonical two-column page was refused every time (#429). The
9088 // median calibrates itself: on a real column most lines run full width, so
9089 // the median IS the column width and nothing legitimate exceeds it.
9090 const OUTLIER: f64 = 1.3;
9091 // EVERY band's per-side extent, not only the single-sided ones. Measuring
9092 // the typical line from the single-sided bands alone compares the outliers
9093 // with themselves: in the Intro/Outro fixture those two ARE the only
9094 // single-sided bands, so their median is their own width and nothing ever
9095 // stands out.
9096 let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
9097 let mut widths: (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
9098 let mut single: Vec<(bool, f64)> = Vec::new();
9099 for band in &bands {
9100 let mut left_lo = f64::INFINITY;
9101 let mut left_hi = f64::NEG_INFINITY;
9102 let mut right_lo = f64::INFINITY;
9103 let mut right_hi = f64::NEG_INFINITY;
9104 for span in &band.spans {
9105 let r = span.measured_right();
9106 if span.x + (r - span.x) * 0.5 < cut_x {
9107 left_lo = left_lo.min(span.x);
9108 left_hi = left_hi.max(r);
9109 } else {
9110 right_lo = right_lo.min(span.x);
9111 right_hi = right_hi.max(r);
9112 }
9113 }
9114 let has_left = left_hi > left_lo || left_lo.is_finite();
9115 let has_right = right_hi > right_lo || right_lo.is_finite();
9116 if has_left {
9117 widths.0.push((left_hi - left_lo).max(0.0));
9118 }
9119 if has_right {
9120 widths.1.push((right_hi - right_lo).max(0.0));
9121 }
9122 match (has_left, has_right) {
9123 (true, false) => single.push((true, (left_hi - left_lo).max(0.0))),
9124 (false, true) => single.push((false, (right_hi - right_lo).max(0.0))),
9125 _ => {}
9126 }
9127 }
9128 // A PATENT'S FRONT PAGE: THE COLUMN'S WIDTH IS ITS LONG LINES. Its
9129 // bibliographic column is mostly short lines -- `(22) Filed: Nov. 8,
9130 // 2002` over `(58) Field of Search ..... 252/500; 528/422` -- so the
9131 // median stood at 152 pt where the column runs 199, the column's own
9132 // longest line read as a banner over both columns, and every line joined
9133 // its neighbour across the gutter (#574, the rig-like proxy's patents).
9134 // Where the page numbers its fields with INID codes -- three or more
9135 // lines opening `(21)`, `(75)`, `(58)` at the region's left edge -- the
9136 // column is measured by its
9137 // ninetieth percentile. Only there: taken for every page, the same
9138 // measure cut financial tables and scientific pages that the median
9139 // keeps whole (tables -0.010, geometric order -0.012 on the proxy).
9140 let inid = spans
9141 .iter()
9142 .filter(|span| {
9143 let t = span.text.trim_start();
9144 let b = t.as_bytes();
9145 // At the column's LEFT EDGE: an equation's number `(23)` stands
9146 // at the right of a scientific page and is not a field.
9147 span.x <= region_left + 2.0 * span.font_size.max(1.0)
9148 && b.len() >= 4
9149 && b[0] == b'('
9150 && b[1].is_ascii_digit()
9151 && b[2].is_ascii_digit()
9152 && b[3] == b')'
9153 })
9154 .count()
9155 >= 3;
9156 let median = |v: &mut Vec<f64>| -> Option<f64> {
9157 if v.is_empty() {
9158 return None;
9159 }
9160 v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9161 Some(if inid {
9162 v[(v.len() * 9 / 10).min(v.len() - 1)]
9163 } else {
9164 v[v.len() / 2]
9165 })
9166 };
9167 let left_median = median(&mut widths.0);
9168 let right_median = median(&mut widths.1);
9169 for (is_left, w) in single {
9170 let m = if is_left { left_median } else { right_median };
9171 if let Some(m) = m {
9172 if m > 0.0 && w > m * OUTLIER {
9173 return false;
9174 }
9175 }
9176 }
9177 true
9178}
9179
9180/// Density guard — reject column splits that look like tables (few,
9181/// short spans per column). A column is "dense" when it has at least
9182/// MIN_SPANS_PER_COLUMN spans and the average character count per band
9183/// exceeds MIN_CHARS_PER_BAND.
9184fn columns_are_dense(left: &[TextSpan], right: &[TextSpan], stats: &PageStats) -> bool {
9185 for col in [left, right] {
9186 if col.len() < XY_CUT_MIN_SPANS_PER_COLUMN {
9187 return false;
9188 }
9189 let bands = group_spans_into_bands_with_stats(col.to_vec(), stats);
9190 if bands.is_empty() {
9191 return false;
9192 }
9193 let total_chars: usize = col.iter().map(|s| s.text.chars().count()).sum();
9194 let chars_per_band = total_chars as f64 / bands.len() as f64;
9195 if chars_per_band < XY_CUT_MIN_CHARS_PER_BAND {
9196 return false;
9197 }
9198 }
9199 true
9200}
9201
9202/// Attempt a horizontal (zone / paragraph) cut. Unlike vertical cuts
9203/// this does NOT need a density guard — splitting top-from-bottom
9204/// cannot re-order content.
9205fn try_horizontal_cut(spans: &[TextSpan], stats: &PageStats) -> Option<(Vec<Vec<TextSpan>>, f64)> {
9206 if spans.len() < 2 {
9207 return None;
9208 }
9209 // Sort by descending y (PDF y grows upward).
9210 let mut sorted = spans.to_vec();
9211 sorted.sort_by(|a, b| {
9212 b.y.partial_cmp(&a.y)
9213 .unwrap_or(Ordering::Equal)
9214 .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
9215 });
9216
9217 // ANN[r17/TEX4] Paragraph / zone cuts scale with MEDIAN LINE
9218 // SPACING when available — this is the typographically correct
9219 // baseline (paragraph break ≈ 1.8 × line-spacing). When the page
9220 // has only one band, or stats haven't observed spacing yet, fall
9221 // back to the font-size multiple the legacy path used.
9222 let min_gap = if stats.median_line_spacing > 0.0 {
9223 stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
9224 } else {
9225 stats.median_font_size * XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER
9226 };
9227 // AND NEVER BELOW THE LEADING THE PAGE IS SET ON (#580). The threshold
9228 // above scales with `median_line_spacing`, which is the FIRST QUARTILE of
9229 // the band steps -- so a page carrying anything tighter than its body (a
9230 // table, a stack of figure labels, a run of sub- and superscripts) puts the
9231 // quartile on that tighter thing and the threshold lands BELOW the body's
9232 // own leading. Every line break on the page then clears it.
9233 //
9234 // WHAT THAT COSTS IS THE WHOLE PAGE, not one cut, and the reason is in
9235 // `blocks::paragraphs`: its stretches are bounded PER REGION, so lines that
9236 // the cut put in two regions can never be merged back. On
9237 // `PMC6019521-p0007` the threshold sat at 10.82 against a leading of 12.00
9238 // and the page came out as 83 blocks, 72 of them a single line of prose --
9239 // one block per line, unrecoverable downstream.
9240 //
9241 // A FLOOR AND NOT A DIFFERENT STATISTIC. Replacing the quartile with the
9242 // leading was measured too and it is wrong: the threshold then rises above
9243 // the paragraph break as well, no horizontal cut fires, the vertical cut is
9244 // refused on the full-width band that is left, and a two-column page reads
9245 // letter-perfect nonsense -- left and right column lines glued alternately.
9246 // The horizontal cut firing at paragraph-break scale is what makes the
9247 // vertical cut findable at all. So the window the threshold has to sit in is
9248 // (the leading, the paragraph break], and the floor is the only end of it
9249 // that was missing.
9250 let leading = leading_of_a_region(spans, stats);
9251 let min_gap = if leading > 0.0 {
9252 min_gap.max(leading * HORIZONTAL_CUT_LEADING_FLOOR)
9253 } else {
9254 min_gap
9255 };
9256
9257 // Look for the largest gap between consecutive span y-values.
9258 // Once, before the scan: every candidate is asked the same question about the
9259 // same bands, and building them per candidate would make the guard's cost
9260 // quadratic in the gaps a page has.
9261 let bands = group_spans_into_bands(sorted.clone());
9262 let mut best: Option<(f64, f64)> = None; // (gap_size, cut_y)
9263 let tolerance = stats.median_font_size * BAND_Y_FRACTION;
9264 let mut band_bottom = sorted[0].y;
9265
9266 for span in sorted.iter().skip(1) {
9267 if (band_bottom - span.y).abs() <= tolerance {
9268 band_bottom = band_bottom.min(span.y);
9269 continue;
9270 }
9271 let gap = band_bottom - span.y;
9272 if gap >= min_gap {
9273 let cut_y = (band_bottom + span.y) * 0.5;
9274 match best {
9275 Some((best_gap, _)) if best_gap >= gap => {}
9276 _ => best = Some((gap, cut_y)),
9277 }
9278 }
9279 band_bottom = span.y;
9280 }
9281
9282 let (gap_size, cut_y) = best?;
9283
9284 // AND NOT INSIDE ONE GRID (#596): a table whose rows are grouped under labels
9285 // leaves more white between the groups than between its rows, and the
9286 // threshold above is a multiple of the ROW pitch.
9287 //
9288 // THE WHOLE REGION IS REFUSED, and the review asked for the other shape:
9289 // skip the grid-internal candidates while scanning and take the largest gap
9290 // left, so that a SMALLER qualifying gap which separates the table from its
9291 // caption is not lost with them. The hazard is real -- a table left merged
9292 // with prose above it is a region `tables::find` refuses outright -- but the
9293 // cure measured worse and the numbers are why it is not taken: over the 43
9294 // annotated pages this machine may hold, refusing the region leaves a summed
9295 // distance of 25 from the annotated table counts, and scanning past the
9296 // grid-internal gaps leaves 32 against 50 unguarded. `PMC3037919-p0007` goes
9297 // back from 1 table to 4 and `PMC2268700-p0008` from 1 to 3.
9298 //
9299 // The reason is this guard's own blind spots, which are counted on #596: where
9300 // the region has fewer than two bands on a side, or the widest band shows
9301 // fewer than two separations, a gap INSIDE the grid is not recognised as one.
9302 // Scanning on therefore does not find the caption's gap, it finds the next
9303 // unrecognised gap inside the table and cuts there. Refusing the region keeps
9304 // the table whole; scanning past cuts it somewhere else.
9305 if the_cut_is_inside_a_grid(&bands, cut_y, gap_size) {
9306 return None;
9307 }
9308
9309 let mut top_group = Vec::new();
9310 let mut bottom_group = Vec::new();
9311 for span in spans {
9312 if span.y > cut_y {
9313 top_group.push(span.clone());
9314 } else {
9315 bottom_group.push(span.clone());
9316 }
9317 }
9318 if top_group.is_empty() || bottom_group.is_empty() {
9319 return None;
9320 }
9321 Some((vec![top_group, bottom_group], gap_size))
9322}
9323
9324/// Legacy band+column-detection path, kept for reference and as the
9325/// fallback inside `band_based_blocks` test coverage. Not currently
9326/// used — XY-Cut supersedes it.
9327#[allow(dead_code)]
9328fn group_spans_into_blocks_legacy(spans: Vec<TextSpan>) -> Vec<TextBlock> {
9329 let bands = group_spans_into_bands(spans);
9330 group_spans_into_blocks_legacy_from_bands(bands, &[])
9331}
9332
9333fn group_spans_into_blocks_legacy_with_stats(
9334 spans: Vec<TextSpan>,
9335 stats: &PageStats,
9336 rules: &[HorizontalRule],
9337) -> Vec<TextBlock> {
9338 let bands = group_spans_into_bands_with_stats(spans, stats);
9339 group_spans_into_blocks_legacy_from_bands(bands, rules)
9340}
9341
9342fn group_spans_into_blocks_legacy_from_bands(
9343 bands: Vec<TextBand>,
9344 rules: &[HorizontalRule],
9345) -> Vec<TextBlock> {
9346 if bands.is_empty() {
9347 return Vec::new();
9348 }
9349
9350 let column_gap_threshold = compute_adaptive_column_gap(&bands);
9351
9352 let mut blocks = Vec::new();
9353 let mut idx = 0;
9354
9355 while idx < bands.len() {
9356 let gap_midpoints = bands[idx].gap_midpoints(column_gap_threshold);
9357 if gap_midpoints.is_empty() {
9358 idx = push_row(&bands, idx, &mut blocks);
9359 continue;
9360 }
9361
9362 let mut boundaries = gap_midpoints.clone();
9363 let mut band_indices = vec![idx];
9364 let mut gapped_band_count = 1usize;
9365 let mut region_left = bands[idx].left();
9366 let mut region_right = bands[idx].right();
9367 let mut next_idx = idx + 1;
9368
9369 while next_idx < bands.len() {
9370 let next_band = &bands[next_idx];
9371 let next_gap_midpoints = next_band.gap_midpoints(column_gap_threshold);
9372 if next_gap_midpoints.is_empty() {
9373 if next_band
9374 .fits_single_column(&boundaries, region_left, region_right)
9375 .is_some()
9376 {
9377 band_indices.push(next_idx);
9378 next_idx += 1;
9379 continue;
9380 }
9381 break;
9382 }
9383
9384 if !boundaries_match(&boundaries, &next_gap_midpoints, column_gap_threshold) {
9385 break;
9386 }
9387
9388 update_boundaries(&mut boundaries, &next_gap_midpoints, gapped_band_count);
9389 gapped_band_count += 1;
9390 band_indices.push(next_idx);
9391 region_left = region_left.min(next_band.left());
9392 region_right = region_right.max(next_band.right());
9393 next_idx += 1;
9394 }
9395
9396 if region_is_columnar(&bands, &band_indices, &boundaries, gapped_band_count)
9397 && !region_is_ruled(&bands, &band_indices, rules)
9398 && !region_is_a_grid(&bands, &band_indices, &boundaries)
9399 {
9400 append_column_region_blocks(&bands, &band_indices, &boundaries, &mut blocks);
9401 idx = next_idx;
9402 } else {
9403 idx = push_row(&bands, idx, &mut blocks);
9404 }
9405 }
9406
9407 blocks
9408}
9409
9410/// Emit the band at `idx` and return the index of the first band after what was
9411/// emitted.
9412///
9413/// A band is normally one line of the page and becomes one block. The exception
9414/// is the row of a table one of whose cells wraps: the wrapped cell's second
9415/// line is a band of its own, and emitting the bands in turn puts it after the
9416/// row's remaining cells instead of after the text it continues (#434).
9417fn push_row(bands: &[TextBand], idx: usize, blocks: &mut Vec<TextBlock>) -> usize {
9418 if let Some(next) = append_wrapped_table_row(bands, idx, blocks) {
9419 return next;
9420 }
9421 blocks.push(bands[idx].row_block());
9422 idx + 1
9423}
9424
9425/// The left edges of the cells the band at `idx` draws, as the row itself
9426/// states them: one edge per span start, and two starts within
9427/// `CELL_EDGE_TOLERANCE` are one edge.
9428///
9429/// Cell edges are taken from starts rather than from the gaps between spans
9430/// because a cell's measured extent is not a column boundary: `8.2.5.26-t05`
9431/// draws four of its five columns less than 16 pt apart, which no gutter
9432/// threshold that leaves ordinary word spacing alone would ever see.
9433fn cell_edges(band: &TextBand) -> Vec<f64> {
9434 let mut edges: Vec<f64> = band.spans.iter().map(|span| span.x).collect();
9435 edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9436 edges.dedup_by(|a, b| (*a - *b).abs() <= CELL_EDGE_TOLERANCE);
9437 edges
9438}
9439
9440/// The cell a span belongs to: the edge it starts on, when it also stops before
9441/// the next edge. A span that starts between two edges, or that reaches into the
9442/// next cell, belongs to no cell of this grid and refuses the whole row.
9443///
9444/// The extent asked for is the measured one. A span's nominal width is the sum
9445/// of the advances the font asks for, which regularly overshoots the ink and so
9446/// the cell: `t05`'s wrapped cell advances 78 pt past its own text and into the
9447/// column after it.
9448fn cell_of(span: &TextSpan, edges: &[f64]) -> Option<usize> {
9449 let idx = edges
9450 .iter()
9451 .rposition(|edge| span.x >= edge - CELL_EDGE_TOLERANCE)?;
9452 if (span.x - edges[idx]).abs() > CELL_EDGE_TOLERANCE {
9453 return None;
9454 }
9455 if let Some(next_edge) = edges.get(idx + 1) {
9456 if span.measured_right() > next_edge - CELL_EDGE_TOLERANCE {
9457 return None;
9458 }
9459 }
9460 Some(idx)
9461}
9462
9463/// The tallest glyph of the band, which is the unit both vertical distances
9464/// below are measured in.
9465fn band_line_height(band: &TextBand) -> f64 {
9466 band.spans
9467 .iter()
9468 .map(|span| span.height.max(span.font_size))
9469 .fold(0.0_f64, f64::max)
9470}
9471
9472/// Whether `band` is another row of the same grid: every one of its spans sits
9473/// in a cell of `edges`, and it carries at least `TABLE_GRID_MIN_COLUMNS` of
9474/// them.
9475///
9476/// This is what separates a table row from two lines of prose that happen to
9477/// break in the right place. One line's spans always align with themselves; a
9478/// grid is only a grid when a second row draws the same edges.
9479/// The single cell of the grid a band fills, when it fills exactly one.
9480///
9481/// This is what a wrapped line looks like from above, and what the next row of
9482/// a table does not look like: a row with an empty cell still fills two of
9483/// them, so it is not mistaken for the line of a cell that ran on.
9484fn band_fills_one_cell(band: &TextBand, edges: &[f64]) -> Option<usize> {
9485 let mut filled: Option<usize> = None;
9486 for span in &band.spans {
9487 let cell = cell_of(span, edges)?;
9488 match filled {
9489 Some(seen) if seen != cell => return None,
9490 _ => filled = Some(cell),
9491 }
9492 }
9493 filled
9494}
9495
9496fn band_matches_grid(band: &TextBand, edges: &[f64]) -> bool {
9497 let mut occupied = vec![false; edges.len()];
9498 for span in &band.spans {
9499 match cell_of(span, edges) {
9500 Some(cell) => occupied[cell] = true,
9501 None => return false,
9502 }
9503 }
9504 occupied.iter().filter(|seen| **seen).count() >= TABLE_GRID_MIN_COLUMNS
9505}
9506
9507/// Emit the row at `idx` cell by cell when one of its cells wraps, and return
9508/// the index of the first band after the row. `None` when the band is not such
9509/// a row, which is the ordinary case.
9510///
9511/// The row is emitted slice-major: every cell in turn, and within a cell its
9512/// own lines in the order they are drawn. That is what both readers do, and it
9513/// is the only order in which a wrapped cell reads as one piece of text.
9514///
9515/// A wrapped line and the next row of the table look alike from above -- both
9516/// are a band under the row with some of its cells filled -- and telling them
9517/// apart is the whole of the work here. Two things say "line" rather than
9518/// "row": it fills a single cell of the grid, and it sits closer to the row
9519/// than the rows of the grid sit to each other.
9520///
9521/// What it does not do, deliberately:
9522/// * a row two of whose cells wrap onto the same line is a line that fills
9523/// two cells, which is what the next row of the table looks like, so it
9524/// stays row-major;
9525/// * so does a row every one of whose cells has a line under it: nothing
9526/// there says which line continues the row and which one begins the next,
9527/// and it is also the shape a two-column region takes when one column runs
9528/// on alone -- reordering that would be the column cut of #389 class O-B
9529/// done badly;
9530/// * a row of two cells is left alone, because that is also what a page set
9531/// in columns looks like -- see `TABLE_ROW_MIN_CELLS`;
9532/// * the first row of a table is left alone, because a single line's cell
9533/// edges are also the word boundaries of a sentence and no row above it
9534/// corroborates them;
9535/// * a continuation that is indented, centred or right-aligned inside its
9536/// cell does not start on the cell's edge and is not recognised.
9537fn append_wrapped_table_row(
9538 bands: &[TextBand],
9539 idx: usize,
9540 blocks: &mut Vec<TextBlock>,
9541) -> Option<usize> {
9542 let head = &bands[idx];
9543 if head.spans.len() < TABLE_ROW_MIN_CELLS {
9544 return None;
9545 }
9546 let edges = cell_edges(head);
9547 if edges.len() < TABLE_ROW_MIN_CELLS {
9548 return None;
9549 }
9550 let line_height = band_line_height(head);
9551 if line_height <= 0.0 {
9552 return None;
9553 }
9554
9555 let head_cells: Vec<usize> = head
9556 .spans
9557 .iter()
9558 .map(|span| cell_of(span, &edges))
9559 .collect::<Option<Vec<usize>>>()?;
9560
9561 // The pitch of the grid, from the band directly above: one line plus
9562 // whatever the cells are padded by. Nothing below may sit that far from
9563 // what it continues, or it is the next row rather than a wrapped line.
9564 let above = idx.checked_sub(1)?;
9565 let row_pitch = bands[above].y - head.y;
9566 if row_pitch <= 0.0 {
9567 return None;
9568 }
9569 let max_line_gap = (line_height * WRAPPED_CELL_LINE_GAP_FACTOR)
9570 .min(row_pitch * WRAPPED_CELL_MAX_PITCH_FRACTION);
9571
9572 // The nearest row above that draws the same cell edges. It is what says
9573 // this is a grid at all: one line's spans always align with themselves, so
9574 // only a second row makes the edges evidence of anything. The bands stepped
9575 // over on the way are the wrapped lines of that row.
9576 let mut corroborator = above;
9577 loop {
9578 if band_matches_grid(&bands[corroborator], &edges) {
9579 break;
9580 }
9581 if band_fills_one_cell(&bands[corroborator], &edges).is_none()
9582 || idx - corroborator >= TABLE_GRID_MAX_LOOKBACK
9583 || corroborator == 0
9584 {
9585 return None;
9586 }
9587 corroborator -= 1;
9588 }
9589
9590 // The continuation lines: each fills one cell of the grid that the row
9591 // itself fills, and sits less than the grid's pitch below the last line of
9592 // *that cell*.
9593 //
9594 // Per cell, and not below the line before it whichever cell that was: a
9595 // cell's lines are consecutive, so its third line is two gaps below the row
9596 // while another cell's second line is one. Measuring against the last line
9597 // accepted would let the row after a wrapped one -- if it happens to fill a
9598 // single cell -- sit one gap below that wrapped line and be read as a third
9599 // line of the row it follows.
9600 let mut continuations: Vec<(&TextBand, usize)> = Vec::new();
9601 let mut last_line_in_cell = vec![head.y; edges.len()];
9602 let mut next_idx = idx + 1;
9603 while next_idx < bands.len() {
9604 let band = &bands[next_idx];
9605 // One cell's line cannot carry more runs than the whole row it
9606 // continues; a band that does is a row of its own.
9607 if band.spans.len() >= head.spans.len() {
9608 break;
9609 }
9610 let Some(cell) = band_fills_one_cell(band, &edges) else {
9611 break;
9612 };
9613 if !head_cells.contains(&cell) {
9614 break;
9615 }
9616 let line_gap = last_line_in_cell[cell] - band.y;
9617 if line_gap <= 0.0 || line_gap > max_line_gap {
9618 break;
9619 }
9620 last_line_in_cell[cell] = band.y;
9621 continuations.push((band, cell));
9622 next_idx += 1;
9623 }
9624 if continuations.is_empty() {
9625 return None;
9626 }
9627
9628 let wrapped_cells: Vec<usize> = continuations.iter().map(|(_, cell)| *cell).collect();
9629 if head_cells.iter().all(|cell| wrapped_cells.contains(cell)) {
9630 return None;
9631 }
9632
9633 for cell in 0..edges.len() {
9634 let row_spans: Vec<TextSpan> = head
9635 .spans
9636 .iter()
9637 .zip(&head_cells)
9638 .filter(|(_, span_cell)| **span_cell == cell)
9639 .map(|(span, _)| span.clone())
9640 .collect();
9641 if !row_spans.is_empty() {
9642 blocks.push(TextBlock { spans: row_spans });
9643 }
9644 for (band, wrapped_cell) in &continuations {
9645 if *wrapped_cell == cell {
9646 blocks.push(band.row_block());
9647 }
9648 }
9649 }
9650
9651 Some(next_idx)
9652}
9653
9654/// Legacy wrapper used by call sites that haven't been handed PageStats.
9655/// It derives stats locally. Prefer `group_spans_into_bands_with_stats`
9656/// inside the XY-Cut pipeline to avoid recomputing the stats per call.
9657fn group_spans_into_bands(spans: Vec<TextSpan>) -> Vec<TextBand> {
9658 let stats = PageStats::from_spans(&spans);
9659 group_spans_into_bands_with_stats(spans, &stats)
9660}
9661
9662fn group_spans_into_bands_with_stats(mut spans: Vec<TextSpan>, stats: &PageStats) -> Vec<TextBand> {
9663 if spans.is_empty() {
9664 return Vec::new();
9665 }
9666
9667 spans.sort_by(|a, b| {
9668 b.y.partial_cmp(&a.y)
9669 .unwrap_or(Ordering::Equal)
9670 .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
9671 });
9672
9673 // ANN[r17/TEX4] Band tolerance scales with this page's median font
9674 // size rather than a fixed 5pt floor. Single-page spreads with
9675 // huge display fonts (24pt+) previously merged unrelated lines; a
9676 // fractional threshold keeps that from happening without hurting
9677 // body-text pages.
9678 let page_tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
9679
9680 let mut bands: Vec<TextBand> = Vec::new();
9681
9682 for span in spans {
9683 let tolerance = (span.height * BAND_Y_FRACTION)
9684 .max(page_tolerance)
9685 .max(BAND_Y_TOLERANCE);
9686 if let Some(band) = bands
9687 .iter_mut()
9688 .find(|band| (band.y - span.y).abs() <= tolerance)
9689 {
9690 let span_count = band.spans.len() as f64;
9691 band.y = (band.y * span_count + span.y) / (span_count + 1.0);
9692 band.spans.push(span);
9693 } else {
9694 bands.push(TextBand::new(span));
9695 }
9696 }
9697
9698 for band in &mut bands {
9699 band.sort_spans();
9700 }
9701
9702 bands.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
9703 bands
9704}
9705
9706fn boundaries_match(boundaries: &[f64], gap_midpoints: &[f64], column_gap_threshold: f64) -> bool {
9707 let tolerance = (column_gap_threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
9708 boundaries.len() == gap_midpoints.len()
9709 && boundaries
9710 .iter()
9711 .zip(gap_midpoints)
9712 .all(|(lhs, rhs)| (lhs - rhs).abs() <= tolerance)
9713}
9714
9715fn update_boundaries(boundaries: &mut [f64], gap_midpoints: &[f64], seen_gapped_bands: usize) {
9716 for (boundary, midpoint) in boundaries.iter_mut().zip(gap_midpoints) {
9717 *boundary =
9718 (*boundary * seen_gapped_bands as f64 + midpoint) / (seen_gapped_bands as f64 + 1.0);
9719 }
9720}
9721
9722fn region_is_columnar(
9723 bands: &[TextBand],
9724 band_indices: &[usize],
9725 boundaries: &[f64],
9726 gapped_band_count: usize,
9727) -> bool {
9728 if boundaries.is_empty()
9729 || gapped_band_count < MIN_COLUMN_GAPPED_BANDS
9730 || band_indices.is_empty()
9731 || (gapped_band_count as f64 / band_indices.len() as f64) < MIN_COLUMN_GAP_SUPPORT
9732 {
9733 return false;
9734 }
9735
9736 let mut non_empty_slices = 0usize;
9737 let mut dense_slices = 0usize;
9738 let mut slices_per_column = vec![0usize; boundaries.len() + 1];
9739
9740 for &band_idx in band_indices {
9741 let slices = bands[band_idx].split_by_boundaries(boundaries);
9742 for (column_idx, slice) in slices.iter().enumerate() {
9743 if slice.is_empty() {
9744 continue;
9745 }
9746
9747 non_empty_slices += 1;
9748 slices_per_column[column_idx] += 1;
9749
9750 let char_count = slice
9751 .iter()
9752 .map(|span| span.text.chars().count())
9753 .sum::<usize>();
9754 if slice.len() >= 2 || char_count >= 8 {
9755 dense_slices += 1;
9756 }
9757 }
9758 }
9759
9760 if non_empty_slices < boundaries.len() + 2 {
9761 return false;
9762 }
9763
9764 if slices_per_column.contains(&0) {
9765 return false;
9766 }
9767
9768 // A COLUMN OF MARKERS IS A LIST (#574). A bullet set well apart from its
9769 // item -- ASX_MRM draws a 19 pt `•` 13 pt before each item's text -- is a
9770 // gap on every band at one x, and read as a column the page came back as
9771 // four bullets and then four items, which no list pass can put together
9772 // again. Where every cell of the first column is a list marker, the bands
9773 // are lines.
9774 //
9775 // AN ITEM OPENS WITH A WORD: a `–` for nil down the first column of a
9776 // block of amounts is a column of cells, and the band beside it holds
9777 // figures (DocLayNet validation, a statement of receivables).
9778 let mut marked = 0usize;
9779 let markers_first = band_indices.iter().all(|&band_idx| {
9780 let slices = bands[band_idx].split_by_boundaries(boundaries);
9781 let joined = |slice: &Vec<TextSpan>| -> String {
9782 slice
9783 .iter()
9784 .map(|span| span.text.as_str())
9785 .collect::<String>()
9786 };
9787 let Some(first) = slices.first() else {
9788 return true;
9789 };
9790 let text = joined(first);
9791 let text = text.trim();
9792 if text.is_empty() {
9793 return true;
9794 }
9795 let item_follows = slices
9796 .iter()
9797 .skip(1)
9798 .map(joined)
9799 .find(|t| !t.trim().is_empty())
9800 // Words, wherever they start: `“Revenue increased”`, `2024
9801 // results` are items; a cell of figures is not (codex, #2045).
9802 .is_some_and(|t| t.chars().any(char::is_alphabetic));
9803 marked += 1;
9804 is_bullet_or_list_marker(text) && item_follows
9805 });
9806 if markers_first && marked >= 2 {
9807 return false;
9808 }
9809
9810 (dense_slices as f64 / non_empty_slices as f64) >= MIN_DENSE_SLICE_RATIO
9811}
9812
9813/// Whether a cell's whole text is a list's marker: a bullet glyph, a pointer
9814/// or picture the font had no letter for, or a short ordinal like `1.`, `a)`.
9815fn is_bullet_or_list_marker(text: &str) -> bool {
9816 let mut chars = text.chars();
9817 match (chars.next(), chars.next()) {
9818 (Some(c), None) => {
9819 crate::lists::BULLET_CHARS.contains(&c) || crate::lists::is_a_pointer_or_a_picture(c)
9820 }
9821 _ => is_list_marker(text),
9822 }
9823}
9824
9825/// Whether this region is a TABLE rather than a page set in columns (#570).
9826///
9827/// `region_is_ruled` answers the same question from the document's own
9828/// graphics and says nothing at all about a table drawn without ruling lines --
9829/// which is the commonest kind there is, and which `region_is_columnar` reads
9830/// down its columns: the rows of it come back as one paragraph per column, and
9831/// every row of the table is lost. So there is a second answer, and it is made
9832/// of the one measurement that separates the two cases without a ruling line.
9833///
9834/// A LINE OF PROSE FILLS ITS COLUMN AND A CELL DOES NOT. That is the whole of
9835/// it. A paper set in two columns sets each of them to a measure and fills it:
9836/// the slice a band leaves in a column is as wide as the column, to the last
9837/// word of the last line. A table's cells are words and figures, ragged and
9838/// short, and the white to the right of them is what makes the thing look like
9839/// a table to a reader. So: a region whose columns are all filled in most of
9840/// its rows, and whose typical cell covers well under its column, is a grid.
9841///
9842/// Deliberately narrow. Three rows at least, every column present in three
9843/// quarters of them, and the MEDIAN fill under `GRID_SLICE_FILL` -- so one
9844/// short line in a column of prose, or one long cell in a table, decides
9845/// nothing.
9846fn region_is_a_grid(bands: &[TextBand], band_indices: &[usize], boundaries: &[f64]) -> bool {
9847 if band_indices.len() < GRID_MIN_ROWS || boundaries.is_empty() {
9848 return false;
9849 }
9850
9851 let mut left = f64::INFINITY;
9852 let mut right = f64::NEG_INFINITY;
9853 for &band_idx in band_indices {
9854 for span in &bands[band_idx].spans {
9855 left = left.min(span.x);
9856 right = right.max(span.measured_right());
9857 }
9858 }
9859 if !(left.is_finite() && right > left) {
9860 return false;
9861 }
9862 let edges: Vec<f64> = std::iter::once(left)
9863 .chain(boundaries.iter().copied())
9864 .chain(std::iter::once(right))
9865 .collect();
9866
9867 let mut present = vec![0usize; boundaries.len() + 1];
9868 let mut fills: Vec<f64> = Vec::new();
9869 for &band_idx in band_indices {
9870 for (column, slice) in bands[band_idx]
9871 .split_by_boundaries(boundaries)
9872 .iter()
9873 .enumerate()
9874 {
9875 if slice.is_empty() {
9876 continue;
9877 }
9878 present[column] += 1;
9879 let slice_left = slice
9880 .iter()
9881 .map(|span| span.x)
9882 .fold(f64::INFINITY, f64::min);
9883 let slice_right = slice
9884 .iter()
9885 .map(TextSpan::measured_right)
9886 .fold(f64::NEG_INFINITY, f64::max);
9887 let width = edges[column + 1] - edges[column];
9888 if width > 0.0 && slice_right > slice_left {
9889 fills.push(((slice_right - slice_left) / width).min(1.0));
9890 }
9891 }
9892 }
9893
9894 let needed = GRID_COLUMN_SUPPORT * band_indices.len() as f64;
9895 if present.iter().any(|seen| (*seen as f64) < needed) {
9896 return false;
9897 }
9898 if fills.is_empty() {
9899 return false;
9900 }
9901 fills.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
9902 fills[fills.len() / 2] <= GRID_SLICE_FILL
9903}
9904
9905/// Whether the page rules this region into rows (#545).
9906///
9907/// A rule counts when it crosses the region -- `RULE_SPAN_FRACTION` of the
9908/// region's own width, so a line under a single cell does not speak for the
9909/// table -- and lies between the baselines of two bands that follow each other
9910/// in the region. Enough of the region's band gaps carrying such a rule is the
9911/// document saying, in its own graphics, that these bands are rows; and rows
9912/// are read across, never down.
9913///
9914/// What it does not claim: a table drawn without ruling lines is invisible
9915/// here, and this is deliberately the whole of the claim. The alternative
9916/// offered for that case was a width heuristic, and a width heuristic cannot
9917/// tell a table from a paper set in two columns -- it fixed the Isartor
9918/// appendix and cost `tracemonkey` p2 and p13, which is the class O-B danger
9919/// the #389 report names for exactly this defect.
9920fn region_is_ruled(bands: &[TextBand], band_indices: &[usize], rules: &[HorizontalRule]) -> bool {
9921 if rules.is_empty() || band_indices.len() < MIN_RULED_ROW_SEPARATORS + 1 {
9922 return false;
9923 }
9924
9925 let mut left = f64::INFINITY;
9926 let mut right = f64::NEG_INFINITY;
9927 for &band_idx in band_indices {
9928 for span in &bands[band_idx].spans {
9929 left = left.min(span.x);
9930 right = right.max(span.measured_right());
9931 }
9932 }
9933 let width = right - left;
9934 if width <= 0.0 {
9935 return false;
9936 }
9937
9938 let mut gaps = 0usize;
9939 let mut ruled = 0usize;
9940 for pair in band_indices.windows(2) {
9941 let upper = bands[pair[0]].y;
9942 let lower = bands[pair[1]].y;
9943 if upper <= lower {
9944 continue;
9945 }
9946 gaps += 1;
9947 let crossed = rules.iter().any(|rule| {
9948 rule.y > lower
9949 && rule.y < upper
9950 && (rule.right.min(right) - rule.left.max(left)) >= width * RULE_SPAN_FRACTION
9951 });
9952 if crossed {
9953 ruled += 1;
9954 }
9955 }
9956
9957 gaps > 0
9958 && ruled >= MIN_RULED_ROW_SEPARATORS
9959 && (ruled as f64 / gaps as f64) >= MIN_RULED_GAP_SUPPORT
9960}
9961
9962fn append_column_region_blocks(
9963 bands: &[TextBand],
9964 band_indices: &[usize],
9965 boundaries: &[f64],
9966 blocks: &mut Vec<TextBlock>,
9967) {
9968 let column_count = boundaries.len() + 1;
9969 let mut column_bands = vec![Vec::<TextSpan>::new(); column_count];
9970
9971 for &band_idx in band_indices {
9972 let slices = bands[band_idx].split_by_boundaries(boundaries);
9973 for (column_idx, slice) in slices.into_iter().enumerate() {
9974 if slice.is_empty() {
9975 continue;
9976 }
9977 column_bands[column_idx].push(TextSpan::default());
9978 let marker_idx = column_bands[column_idx].len() - 1;
9979 column_bands[column_idx][marker_idx] = TextSpan {
9980 x: f64::NEG_INFINITY,
9981 y: bands[band_idx].y,
9982 ..TextSpan::default()
9983 };
9984 column_bands[column_idx].extend(slice);
9985 }
9986 }
9987
9988 for spans in column_bands {
9989 let mut current: Vec<TextSpan> = Vec::new();
9990 for span in spans {
9991 if span.x == f64::NEG_INFINITY {
9992 if !current.is_empty() {
9993 current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
9994 blocks.push(TextBlock {
9995 spans: std::mem::take(&mut current),
9996 });
9997 }
9998 continue;
9999 }
10000 current.push(span);
10001 }
10002 if !current.is_empty() {
10003 current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
10004 blocks.push(TextBlock { spans: current });
10005 }
10006 }
10007}
10008
10009/// Join per-block lines, stitching end-of-line hyphenated word-wraps the
10010/// way pdftotext / MuPDF / PDFBox do.
10011///
10012/// Trigger conditions (all must hold):
10013/// 1. Previous line ends with `-` preceded by an alphabetic character.
10014/// 2. The alphabetic suffix before the `-` has >= 3 characters.
10015/// 3. The next line (trimmed) starts with an ASCII lowercase letter.
10016/// 4. The lowercase prefix of the next line has >= 3 characters.
10017///
10018/// When triggered, the trailing `-` is removed and the two halves are
10019/// concatenated without a space or newline.
10020///
10021/// This avoids false positives on compound words ("real-time"), bullet
10022/// lists, numeric ranges ("42-"), and short fragments.
10023fn stitch_hyphenated_lines(lines: &[String]) -> String {
10024 let mut out = String::new();
10025 for (idx, line) in lines.iter().enumerate() {
10026 if idx == 0 {
10027 out.push_str(line);
10028 continue;
10029 }
10030
10031 let next_trimmed = line.trim_start();
10032
10033 // Check the accumulated output for end-of-line hyphen pattern
10034 let should_merge = is_hyphen_wrap_candidate(&out, next_trimmed);
10035
10036 if should_merge {
10037 out.pop(); // drop the trailing '-'
10038 out.push_str(next_trimmed);
10039 } else {
10040 out.push('\n');
10041 out.push_str(line);
10042 }
10043 }
10044 out
10045}
10046
10047/// Check if the accumulated text ends with a hyphen-wrap pattern and the
10048/// continuation is a valid merge target.
10049pub(crate) fn is_hyphen_wrap_candidate(accumulated: &str, next_trimmed: &str) -> bool {
10050 // Must end with '-'
10051 if !accumulated.ends_with('-') {
10052 return false;
10053 }
10054
10055 // Character before '-' must be alphabetic
10056 let before_hyphen = accumulated.chars().rev().nth(1);
10057 if !before_hyphen.is_some_and(|c| c.is_alphabetic()) {
10058 return false;
10059 }
10060
10061 // Count consecutive alphabetic chars before the '-' (the word fragment)
10062 let alpha_prefix_len = accumulated
10063 .chars()
10064 .rev()
10065 .skip(1) // skip the '-'
10066 .take_while(|c| c.is_alphabetic())
10067 .count();
10068 if alpha_prefix_len < 3 {
10069 return false;
10070 }
10071
10072 // Next line must start with lowercase ASCII
10073 let first_next = next_trimmed.chars().next();
10074 if !first_next.is_some_and(|c| c.is_ascii_lowercase()) {
10075 return false;
10076 }
10077
10078 // Count consecutive lowercase chars at start of next line
10079 let next_alpha_len = next_trimmed
10080 .chars()
10081 .take_while(|c| c.is_ascii_lowercase())
10082 .count();
10083 if next_alpha_len < 3 {
10084 return false;
10085 }
10086
10087 true
10088}
10089
10090/// Normalize extracted text to match pdftotext conventions.
10091///
10092/// 1. Trim trailing whitespace from each line.
10093/// 2. Collapse runs of more than two consecutive newlines into exactly two.
10094/// 3. Preserve form-feed characters (`\x0C`) as page separators.
10095/// 4. End with a single trailing newline (or empty for empty input).
10096pub(crate) fn normalize_text_output(text: &str) -> String {
10097 if text.is_empty() {
10098 return String::new();
10099 }
10100
10101 let mut lines: Vec<&str> = Vec::new();
10102 for line in text.split('\n') {
10103 lines.push(line.trim_end());
10104 }
10105
10106 // Remove trailing empty lines (we'll add exactly one \n at the end)
10107 while lines.last() == Some(&"") {
10108 lines.pop();
10109 }
10110
10111 if lines.is_empty() {
10112 return String::new();
10113 }
10114
10115 let mut result = String::with_capacity(text.len());
10116 let mut consecutive_empty = 0u32;
10117
10118 for (i, line) in lines.iter().enumerate() {
10119 if line.is_empty() || *line == "\x0C" {
10120 if line.is_empty() {
10121 consecutive_empty += 1;
10122 // Collapse >2 consecutive blank lines to 2
10123 if consecutive_empty <= 2 {
10124 result.push('\n');
10125 }
10126 } else {
10127 // Bare form-feed line
10128 consecutive_empty = 0;
10129 result.push_str(line);
10130 if i + 1 < lines.len() {
10131 result.push('\n');
10132 }
10133 }
10134 } else {
10135 // Both form-feed-prefixed and regular lines are emitted as-is.
10136 consecutive_empty = 0;
10137 result.push_str(line);
10138 if i + 1 < lines.len() {
10139 result.push('\n');
10140 }
10141 }
10142 }
10143
10144 // Ensure single trailing newline
10145 if !result.is_empty() && !result.ends_with('\n') {
10146 result.push('\n');
10147 }
10148
10149 result
10150}
10151
10152#[cfg(test)]
10153mod tests {
10154 use super::*;
10155
10156 fn span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
10157 TextSpan {
10158 text: text.into(),
10159 x,
10160 y,
10161 width,
10162 height: 12.0,
10163 font_size: 12.0,
10164 ..TextSpan::default()
10165 }
10166 }
10167
10168 /// `span` with one 6 pt glyph box per character from `x` on.
10169 fn boxed(text: &str, x: f64) -> TextSpan {
10170 let n = text.chars().count();
10171 let mut s = span(text, x, 100.0, 6.0 * n as f64);
10172 s.char_bounds = (0..n)
10173 .map(|i| {
10174 let left = x + 6.0 * i as f64;
10175 [left, 100.0, left + 6.0, 112.0]
10176 })
10177 .collect();
10178 s
10179 }
10180
10181 /// A U+20DD that reaches the line assembly is never moved or left out:
10182 /// placing a row-derived mark is the interpreter's (C4 rule 6), so a
10183 /// circle from `ToUnicode` keeps its x order here.
10184 #[test]
10185 fn a_circle_from_another_source_keeps_its_place() {
10186 let mut mark = boxed("\u{20DD}", 98.0);
10187 mark.char_bounds = vec![[98.0, 100.0, 110.0, 112.0]];
10188 mark.width = 12.0;
10189 let enclosing = TextBlock {
10190 spans: vec![mark.clone(), boxed("c", 100.0)],
10191 };
10192 assert!(
10193 enclosing.text().starts_with('\u{20DD}'),
10194 "{:?}",
10195 enclosing.text()
10196 );
10197 let lone = TextBlock { spans: vec![mark] };
10198 assert_eq!(lone.text(), "\u{20DD}");
10199 }
10200
10201 /// A row as a letter-spaced line draws it: one span per glyph, letters
10202 /// `tracking` apart, a word space drawn as a span of its own where
10203 /// `words` part (#1973). `gap` puts that much before the word instead.
10204 fn tracked_row(words: &[&str], tracking: f64, gap: Option<(usize, f64)>) -> TextBlock {
10205 let mut spans = Vec::new();
10206 let mut x = 0.0;
10207 for (w, word) in words.iter().enumerate() {
10208 if w > 0 {
10209 match gap {
10210 Some((at, wide)) if at == w => x += wide,
10211 _ => {
10212 spans.push(span(" ", x, 0.0, 3.0));
10213 x += 3.0 + tracking;
10214 }
10215 }
10216 }
10217 for c in word.chars() {
10218 spans.push(span(&c.to_string(), x, 0.0, 8.0));
10219 x += 8.0 + tracking;
10220 }
10221 }
10222 TextBlock { spans }
10223 }
10224
10225 #[test]
10226 fn a_row_tracked_by_drawn_spaces_reads_as_its_words() {
10227 // `P O W E L L I N D U S T R I E S`: five points of tracking on
10228 // twelve-point capitals is over the word-space threshold, so each
10229 // letter read as a word (#1973). The drawn space says where the words
10230 // part.
10231 let row = tracked_row(&["POWELL", "INDUSTRIES,", "INC."], 5.0, None);
10232 assert_eq!(row.text(), "POWELL INDUSTRIES, INC.");
10233 }
10234
10235 #[test]
10236 fn a_row_of_letters_with_no_drawn_space_is_left_alone() {
10237 // No mark says where words part: letters set apart on purpose.
10238 let row = tracked_row(&["ABCDE"], 5.0, None);
10239 assert_eq!(row.text(), "A B C D E");
10240 // Nor lower case, a formula or a listing, whatever is drawn between.
10241 let row = tracked_row(&["xyzw", "abcd"], 5.0, None);
10242 assert_eq!(row.text(), "x y z w a b c d");
10243 }
10244
10245 #[test]
10246 fn lower_case_between_drawn_spaces_does_not_make_the_capitals_tracked() {
10247 // Capitals, lower-case letters one apart, capitals again, a space
10248 // drawn between each -- a formula's variables between two labels.
10249 // Only capitals make a tracked row, so neither space is the boundary
10250 // of one.
10251 let row = tracked_row(&["ABCD", "xyzw", "EFGH"], 5.0, None);
10252 assert_eq!(row.text(), "A B C D x y z w E F G H");
10253 }
10254
10255 #[test]
10256 fn a_single_letter_after_the_drawn_space_is_not_a_tracked_word() {
10257 // A figure's label `F C F C`, a space, and a one-letter word: nothing
10258 // here is a tracked line of words.
10259 let row = tracked_row(&["FCFC", "S"], 5.0, None);
10260 assert_eq!(row.text(), "F C F C S");
10261 }
10262
10263 #[test]
10264 fn a_gap_far_wider_than_the_tracking_keeps_its_space() {
10265 // `NEW YORK` and then, with no drawn space but a wide gap, `DESK`: the
10266 // gap is a word boundary the page did not draw.
10267 let row = tracked_row(&["NEW", "YORK", "DESK"], 5.0, Some((2, 40.0)));
10268 assert_eq!(row.text(), "NEW YORK DESK");
10269 }
10270
10271 #[test]
10272 fn letter_spacing_by_space_characters_reads_as_its_words() {
10273 // The same line when the page draws it in one span, a space after
10274 // every capital and two where the words part (#1973).
10275 let mut tracked = span(
10276 "P O W E L L I N D U S T R I E S , I N C .",
10277 0.0,
10278 0.0,
10279 200.0,
10280 );
10281 untrack_spaced_letters(&mut tracked);
10282 assert_eq!(tracked.text, "POWELL INDUSTRIES, INC.");
10283 // A single space everywhere says nothing about the words: left alone.
10284 let mut apart = span("A B C D E F G", 0.0, 0.0, 100.0);
10285 untrack_spaced_letters(&mut apart);
10286 assert_eq!(apart.text, "A B C D E F G");
10287 // Lower case is a formula or a listing.
10288 let mut formula = span("x y z w a b c d", 0.0, 0.0, 100.0);
10289 untrack_spaced_letters(&mut formula);
10290 assert_eq!(formula.text, "x y z w a b c d");
10291 // A label beside one letter is not two tracked words (codex, #1974).
10292 let mut label = span("F C F C S", 0.0, 0.0, 100.0);
10293 untrack_spaced_letters(&mut label);
10294 assert_eq!(label.text, "F C F C S");
10295 }
10296
10297 /// A span as the consensus leaves a tracked word: every letter a glyph
10298 /// `advance` wide and `gaps[k]` before letter `k + 1`, and a space in the
10299 /// text between each of them (#1973).
10300 fn consensus_spaced(letters: &str, advance: f64, gaps: &[f64], size: f64) -> TextSpan {
10301 let mut x = 0.0;
10302 let mut bounds = Vec::new();
10303 for (k, _) in letters.chars().enumerate() {
10304 bounds.push([x, 0.0, x + advance, size]);
10305 x += advance + gaps.get(k).copied().unwrap_or(0.0);
10306 }
10307 let text = letters
10308 .chars()
10309 .map(String::from)
10310 .collect::<Vec<_>>()
10311 .join(" ");
10312 TextSpan {
10313 char_bounds: bounds,
10314 font_size: size,
10315 ..span(&text, 0.0, 0.0, x)
10316 }
10317 }
10318
10319 #[test]
10320 fn a_short_tracked_word_reads_as_one() {
10321 // `P E N N` at 6.2 points, 0.9 apart: three gaps, never the four #582
10322 // waits for, and a new span at the word space (#1973).
10323 let mut penn = consensus_spaced("PENN", 3.0, &[0.9, 0.9, 0.9], 6.2);
10324 untrack_spaced_letters(&mut penn);
10325 assert_eq!(penn.text, "PENN");
10326 // Kerned pairs sit tighter than the tracking, and are inside the word.
10327 let mut kerned = consensus_spaced(
10328 "OUTSTANDING",
10329 30.0,
10330 &[3.0, 3.0, 3.0, 3.0, 0.6, 3.0, 3.0, 3.0, 1.5, 3.0],
10331 60.0,
10332 );
10333 untrack_spaced_letters(&mut kerned);
10334 assert_eq!(kerned.text, "OUTSTANDING");
10335 }
10336
10337 #[test]
10338 fn a_gap_wider_than_the_tracking_is_a_word_boundary() {
10339 let mut two = consensus_spaced("ABCDEF", 8.0, &[1.0, 1.0, 3.0, 1.0, 1.0], 12.0);
10340 untrack_spaced_letters(&mut two);
10341 assert_eq!(two.text, "ABC DEF");
10342 // As many word gaps as letter gaps (codex, #1980).
10343 let mut short = consensus_spaced("IAMOK", 8.0, &[3.0, 1.0, 3.0, 1.0], 12.0);
10344 untrack_spaced_letters(&mut short);
10345 assert_eq!(short.text, "I AM OK");
10346 }
10347
10348 #[test]
10349 fn a_finished_span_is_left_alone_unless_it_is_tracked_capitals() {
10350 // Set solid: the boxes touch, and the consensus's spaces are words.
10351 let mut solid = consensus_spaced("ABCDE", 8.0, &[0.0, 0.0, 0.0, 0.0], 12.0);
10352 untrack_spaced_letters(&mut solid);
10353 assert_eq!(solid.text, "A B C D E");
10354 // Lower case: a formula's variables.
10355 let mut lower = consensus_spaced("xyzwv", 8.0, &[1.0, 1.0, 1.0, 1.0], 12.0);
10356 untrack_spaced_letters(&mut lower);
10357 assert_eq!(lower.text, "x y z w v");
10358 // Nor capitals beside lower case one apart, a label and a formula.
10359 let mut mixed = consensus_spaced("ABCDxyz", 8.0, &[1.0; 6], 12.0);
10360 untrack_spaced_letters(&mut mixed);
10361 assert_eq!(mixed.text, "A B C D x y z");
10362 // A glyph more than letters: a drawn space or a ligature, whose place
10363 // in the text this cannot know.
10364 let mut drawn = consensus_spaced("ABCDE", 8.0, &[1.0, 1.0, 1.0, 1.0], 12.0);
10365 drawn.char_bounds.push([100.0, 0.0, 104.0, 12.0]);
10366 untrack_spaced_letters(&mut drawn);
10367 assert_eq!(drawn.text, "A B C D E");
10368 }
10369
10370 fn sized(text: &str, x: f64, y: f64, width: f64, size: f64) -> TextSpan {
10371 TextSpan {
10372 font_size: size,
10373 height: size,
10374 ..span(text, x, y, width)
10375 }
10376 }
10377
10378 #[test]
10379 fn a_label_drawn_again_as_its_shadow_is_one_label() {
10380 // FAA-H-8083-6 (DocLayNet train), a moving map: `KFTC` drawn twice,
10381 // the second copy 0.94 right and 0.85 lower at 4.9 points, and read
10382 // `KFTCKFTC` (#1973).
10383 let first = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
10384 let shadow = sized("KFTC", 264.13, 656.97, 12.88, 4.9);
10385 assert!(spans_are_overprint_duplicates(&first, &shadow));
10386 // A size larger, as a halo is: overlapping 77 per cent.
10387 let small = sized("NRST", 385.24, 476.85, 10.93, 4.90);
10388 let large = sized("NRST", 387.79, 477.16, 12.33, 5.52);
10389 assert!(spans_are_overprint_duplicates(&small, &large));
10390 }
10391
10392 #[test]
10393 fn a_column_head_set_twice_half_over_itself_is_one_head() {
10394 // NASDAQ_SHEN_2003 (DocLayNet): `2003` and its copy 7.99 right, 0.86
10395 // higher, overlapping 60 per cent -- read `20032003` (#623).
10396 let first = sized("2003", 346.22, 549.77, 20.16, 10.08);
10397 let copy = sized("2003", 354.21, 550.63, 20.16, 10.08);
10398 assert!(spans_are_overprint_duplicates(&first, ©));
10399 // Less than half over each other is two words, even of one text.
10400 let left = sized("2003", 346.22, 549.77, 20.16, 10.08);
10401 let right = sized("2003", 358.0, 549.77, 20.16, 10.08);
10402 assert!(!spans_are_overprint_duplicates(&left, &right));
10403 // Two characters keep the stricter share.
10404 let a = sized("12", 100.0, 100.0, 10.0, 10.0);
10405 let b = sized("12", 104.0, 100.3, 10.0, 10.0);
10406 assert!(!spans_are_overprint_duplicates(&a, &b));
10407 }
10408
10409 #[test]
10410 fn a_shadow_needs_the_same_text_of_two_characters_close_by() {
10411 // One character: the two `1`s of a matrix, the `ll` of a word.
10412 let one = sized("1", 100.0, 100.0, 2.5, 5.0);
10413 let other = sized("1", 100.5, 100.9, 2.5, 5.0);
10414 assert!(!spans_are_overprint_duplicates(&one, &other));
10415 // Other text.
10416 let a = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
10417 let b = sized("KCLN", 264.13, 656.97, 12.88, 4.9);
10418 assert!(!spans_are_overprint_duplicates(&a, &b));
10419 // Further off than a quarter em: the next line, not a shadow.
10420 let line = sized("KFTC", 263.19, 656.12, 12.88, 4.9);
10421 let below = sized("KFTC", 263.19, 654.50, 12.88, 4.9);
10422 assert!(!spans_are_overprint_duplicates(&line, &below));
10423 }
10424
10425 #[test]
10426 fn a_tracked_span_that_loses_its_spaces_says_what_its_letters_are() {
10427 // Rich geometry marks a span holding a drawn space an estimate: a space
10428 // has no outline. With the spaces taken out the span is as tight as
10429 // its letters (codex, #1974).
10430 let text = "A B C D E F G H";
10431 let mut tracked = span(text, 0.0, 0.0, 100.0);
10432 tracked.glyph_bounds_sources = text
10433 .chars()
10434 .map(|c| {
10435 if c == ' ' {
10436 BoundsSource::Estimate
10437 } else {
10438 BoundsSource::Tight
10439 }
10440 })
10441 .collect();
10442 tracked.bounds_source = BoundsSource::Estimate;
10443 untrack_spaced_letters(&mut tracked);
10444 assert_eq!(tracked.text, "ABCD EFGH");
10445 assert_eq!(tracked.glyph_bounds_sources.len(), 9);
10446 assert_eq!(
10447 tracked.bounds_source,
10448 BoundsSource::Estimate,
10449 "the one space kept is still a space"
10450 );
10451 let mut all_tight = span("A B C D E F G H", 0.0, 0.0, 100.0);
10452 all_tight.glyph_bounds_sources = vec![BoundsSource::Tight; 16];
10453 all_tight.bounds_source = BoundsSource::Estimate;
10454 untrack_spaced_letters(&mut all_tight);
10455 assert_eq!(all_tight.bounds_source, BoundsSource::Tight);
10456 }
10457
10458 /// A two-column academic page, the shape #429 is about: a full-width title,
10459 /// then two columns of body text whose right column ends one line short of
10460 /// the left. Every line is a realistic fraction of its column width.
10461 ///
10462 /// Returned in reading order — title, all of column one, then all of column
10463 /// two — so the test can say what the right answer is instead of guessing.
10464 ///
10465 /// The lines are prose-length on purpose. `estimated_width()` is
10466 /// `font_size * 0.5 * chars`, so a realistic line of about fifty characters
10467 /// estimates wider than it measures, and `right()` — which takes the larger
10468 /// of the two — then reaches across the gutter. Short labels would make a
10469 /// tidier fixture that never reproduces the defect.
10470 fn two_column_page() -> Vec<TextSpan> {
10471 const LEFT_X: f64 = 40.0;
10472 const RIGHT_X: f64 = 320.0;
10473 const COLUMN_WIDTH: f64 = 250.0;
10474 const TOP: f64 = 700.0;
10475 const LEADING: f64 = 14.0;
10476 const LINES: usize = 20;
10477
10478 let mut out = vec![span(
10479 "A Trace-based Just-in-Time Compiler",
10480 40.0,
10481 740.0,
10482 530.0,
10483 )];
10484 for i in 0..LINES {
10485 out.push(span(
10486 &format!("left line {i:02} of the two-column academic body text"),
10487 LEFT_X,
10488 TOP - (i as f64) * LEADING,
10489 COLUMN_WIDTH,
10490 ));
10491 }
10492 // One line short: the right column ends above the left, which leaves the
10493 // left column's last band alone on its side of the gutter. Every real
10494 // two-column page has such bands -- a heading, a column that ends early,
10495 // a figure -- and the guard this test is about used to refuse the cut
10496 // for exactly that reason.
10497 for i in 0..(LINES - 1) {
10498 out.push(span(
10499 &format!("right line {i:02} of the two-column academic body text"),
10500 RIGHT_X,
10501 TOP - (i as f64) * LEADING,
10502 COLUMN_WIDTH,
10503 ));
10504 }
10505 out
10506 }
10507
10508 /// #429: a two-column body is read column by column.
10509 ///
10510 /// The whole page in one assertion, because reading order is a property of
10511 /// the whole page and a per-block check would pass while the columns were
10512 /// interleaved. Revert either half of the fix and this goes red: with the
10513 /// gap scan on `right()` no gutter is found at all, and with the old
10514 /// band-aligned guard the cut is refused once it is.
10515 #[test]
10516 fn a_two_column_body_is_read_column_by_column() {
10517 let spans = two_column_page();
10518 let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
10519
10520 let produced: Vec<String> = group_spans_into_blocks(spans, &[])
10521 .into_iter()
10522 .flat_map(|b| b.spans.into_iter().map(|s| s.text))
10523 .collect();
10524
10525 assert_eq!(produced, expected);
10526 }
10527
10528 /// A JOURNAL GUTTER IS NARROWER THAN FOUR PERCENT OF THE PAGE (#389).
10529 ///
10530 /// The shape that the region-width rail refused: 9 pt type, a 493 pt text
10531 /// block, and the 13.2 pt gutter tracemonkey is actually set with. Four
10532 /// percent of that block is 19.7 pt, which is wider than the gutter of any
10533 /// journal, and the cut was refused on pages 2 and 13 of tracemonkey,
10534 /// highlights and tracemonkey_with_editable_annotations -- six of the eleven
10535 /// pages left under 95 % order agreement on the gate set.
10536 ///
10537 /// Returned in reading order, so the test says what the right answer is.
10538 fn narrow_gutter_page() -> Vec<TextSpan> {
10539 narrow_gutter_page_with_gutter(13.2)
10540 }
10541
10542 /// The same page with the gutter set by the caller, so a test can ask for a
10543 /// gap that is not a gutter.
10544 fn narrow_gutter_page_with_gutter(gutter: f64) -> Vec<TextSpan> {
10545 const LEFT_X: f64 = 54.0;
10546 const COLUMN_WIDTH: f64 = 240.0;
10547 const FONT_SIZE: f64 = 9.0;
10548 const TOP: f64 = 700.0;
10549 const LEADING: f64 = 11.0;
10550 const LINES: usize = 20;
10551
10552 let right_x = LEFT_X + COLUMN_WIDTH + gutter;
10553 let line = |text: &str, x: f64, y: f64| TextSpan {
10554 text: text.into(),
10555 x,
10556 y,
10557 width: COLUMN_WIDTH,
10558 height: FONT_SIZE,
10559 font_size: FONT_SIZE,
10560 ..TextSpan::default()
10561 };
10562
10563 let mut out = vec![TextSpan {
10564 text: "Trace-based Just-in-Time Type Specialization".into(),
10565 x: LEFT_X,
10566 y: 740.0,
10567 width: 490.0,
10568 height: 14.0,
10569 font_size: 14.0,
10570 ..TextSpan::default()
10571 }];
10572 // PROSE-LENGTH LINES, for the same reason `two_column_page` gives them:
10573 // `estimated_width()` is font_size * 0.5 * chars, so a real line of
10574 // sixty characters overshoots its measured width and the overshoot
10575 // swallows the gutter -- which is how a page ends up with no measurable
10576 // word gaps at all and the adaptive threshold falls back to 20 pt.
10577 for i in 0..LINES {
10578 out.push(line(
10579 &format!("left line {i:02} of the two-column academic body of this paper"),
10580 LEFT_X,
10581 TOP - (i as f64) * LEADING,
10582 ));
10583 }
10584 for i in 0..(LINES - 1) {
10585 out.push(line(
10586 &format!("right line {i:02} of the two-column academic body of this paper"),
10587 right_x,
10588 TOP - (i as f64) * LEADING,
10589 ));
10590 }
10591 out
10592 }
10593
10594 /// Red the moment a gutter has to be a fraction of the region again.
10595 ///
10596 /// Asked of `try_vertical_cut` itself, so that it names the cut and not the
10597 /// page: the reading order below is the consequence and this is the cause.
10598 #[test]
10599 fn a_thirteen_point_gutter_in_nine_point_type_is_a_gutter() {
10600 let spans = narrow_gutter_page();
10601 let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
10602 let stats = PageStats::from_spans(&body);
10603
10604 let (groups, gap) = try_vertical_cut(&body, &stats, &[])
10605 .expect("no column cut found on a 13.2 pt gutter in 9 pt type");
10606
10607 assert_eq!(groups.len(), 2);
10608 assert!(
10609 (gap - 13.2).abs() < 0.5,
10610 "the cut was taken on a gap of {gap}, not on the gutter"
10611 );
10612 assert!(
10613 groups[0].iter().all(|s| s.text.starts_with("left")),
10614 "the left group holds text from the right column"
10615 );
10616 assert!(
10617 groups[1].iter().all(|s| s.text.starts_with("right")),
10618 "the right group holds text from the left column"
10619 );
10620 }
10621
10622 /// And the whole page, in reading order, through the path a document takes.
10623 /// Red with the rail back as well: the leaf's own band-by-band gutter
10624 /// detector does not rescue a page whose lines overshoot their measured
10625 /// width, which is every page set in a proportional face.
10626 #[test]
10627 fn the_narrow_gutter_page_reads_column_by_column() {
10628 let spans = narrow_gutter_page();
10629 let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
10630
10631 let produced: Vec<String> = group_spans_into_blocks(spans, &[])
10632 .into_iter()
10633 .flat_map(|b| b.spans.into_iter().map(|s| s.text))
10634 .collect();
10635
10636 assert_eq!(produced, expected);
10637 }
10638
10639 /// And the floor still does its work: the same page whose columns are six
10640 /// points apart is not two columns, because six points is a word space in
10641 /// nine point type. Red if the floor is dropped along with the rail.
10642 #[test]
10643 fn a_gap_narrower_than_the_type_is_not_a_gutter() {
10644 let spans = narrow_gutter_page_with_gutter(6.0);
10645 let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
10646 let stats = PageStats::from_spans(&body);
10647
10648 assert!(
10649 try_vertical_cut(&body, &stats, &[]).is_none(),
10650 "a six point gap in nine point type was taken for a column gutter"
10651 );
10652 }
10653
10654 fn nine(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
10655 TextSpan {
10656 height: 9.0,
10657 font_size: 9.0,
10658 ..span(text, x, y, width)
10659 }
10660 }
10661
10662 /// Two columns whose text is 23 pt apart, with the right column's bullets
10663 /// hung 8 pt from the left column's measure and the space after each
10664 /// drawn as a run of its own (NYSE_MSA, #574). Counted as ink, they leave
10665 /// no x free of every span.
10666 fn columns_with_hung_markers() -> Vec<TextSpan> {
10667 let mut out = Vec::new();
10668 for i in 0..12 {
10669 let y = 700.0 - 12.0 * i as f64;
10670 out.push(nine(
10671 &format!("left line {i:02} of the running text of this annual report"),
10672 72.0,
10673 y,
10674 225.0,
10675 ));
10676 if i % 4 == 0 {
10677 out.push(nine("t", 305.0, y, 3.3));
10678 out.push(nine(" ", 308.3, y, 5.0));
10679 }
10680 out.push(nine(
10681 &format!("right line {i:02} of the running text of this annual report"),
10682 320.0,
10683 y,
10684 225.0,
10685 ));
10686 }
10687 out
10688 }
10689
10690 #[test]
10691 fn a_gutter_is_found_past_the_markers_hung_into_it() {
10692 let body = columns_with_hung_markers();
10693 let stats = PageStats::from_spans(&body);
10694 let (groups, _) = try_vertical_cut(&body, &stats, &[])
10695 .expect("no cut past the hung markers and their space runs");
10696 assert_eq!(groups.len(), 2);
10697 assert!(
10698 groups[0].iter().all(|s| s.text.starts_with("left")),
10699 "a hung marker went to the left column"
10700 );
10701 }
10702
10703 /// The looking-past is for columns of text. NYSE_JWN's exhibit index has a
10704 /// right column of `Filed herewith electronically` on every row and one
10705 /// space drawn in the strip on its head row; looked past, the strip is
10706 /// white, and cutting there read every filing method after every exhibit.
10707 #[test]
10708 fn a_column_of_cells_found_past_a_space_is_not_cut_off() {
10709 let mut body = vec![
10710 nine("Exhibit", 192.0, 728.0, 28.0),
10711 nine(" ", 326.0, 728.0, 2.0),
10712 nine("Method of Filing", 413.0, 728.0, 63.0),
10713 ];
10714 for i in 0..10 {
10715 let y = 713.0 - 18.0 * i as f64;
10716 if i % 3 == 2 {
10717 // `101.SCH XBRL Taxonomy ...`: number and description one run,
10718 // across the white that would otherwise part them.
10719 body.push(nine(
10720 "101.SCH XBRL Taxonomy Extension Schema Document",
10721 52.0,
10722 y,
10723 213.0,
10724 ));
10725 } else {
10726 body.push(nine(&format!("{i}.1"), 52.0, y, 16.0));
10727 body.push(nine(
10728 "Certification of Chief Financial Officer required",
10729 92.0,
10730 y,
10731 226.0,
10732 ));
10733 }
10734 body.push(nine("Filed herewith electronically", 330.0, y, 100.0));
10735 }
10736 let stats = PageStats::from_spans(&body);
10737 assert!(
10738 try_vertical_cut(&body, &stats, &[]).is_none(),
10739 "the cell column of a table was cut off as a column of text"
10740 );
10741 }
10742
10743 /// The last glyph of a line is not a marker hung into the gutter (codex,
10744 /// #2041). A left column whose lines close on `)` in a run of their own,
10745 /// 9 pt from the right column: looked past, the `)` would open a 15 pt
10746 /// strip and take a gutter the page does not have.
10747 #[test]
10748 fn a_closing_glyph_is_not_a_hung_marker() {
10749 let mut body = Vec::new();
10750 for i in 0..10 {
10751 let y = 700.0 - 12.0 * i as f64;
10752 // Ragged, so the closing glyphs stand at no one x.
10753 let width = [221.0, 204.0, 213.0, 196.0, 218.0][i % 5];
10754 body.push(nine(
10755 &format!("left line {i:02} of the running text of this annual report (see"),
10756 72.0,
10757 y,
10758 width,
10759 ));
10760 body.push(nine(")", 72.0 + width + 1.0, y, 3.0));
10761 body.push(nine(
10762 &format!("right line {i:02} of the running text of this annual report"),
10763 305.0,
10764 y,
10765 225.0,
10766 ));
10767 }
10768 let stats = PageStats::from_spans(&body);
10769 assert!(
10770 try_vertical_cut(&body, &stats, &[]).is_none(),
10771 "a line's closing glyph was looked past as a hung marker"
10772 );
10773 }
10774
10775 /// A statement whose cells are parted by tab runs is not two columns of
10776 /// text (DocLayNet validation, #574). Every gap between its amounts holds
10777 /// a `\t` drawn as a run of its own; looked past as word spaces they left
10778 /// every column gap clear, and rows of figures as long as a line of prose
10779 /// passed for running text -- the statement was cut down its middle. A
10780 /// column of text is counted in letters.
10781 #[test]
10782 fn a_statement_parted_by_tab_runs_is_not_cut() {
10783 let mut body = Vec::new();
10784 for i in 0..10 {
10785 let y = 700.0 - 12.0 * i as f64;
10786 body.push(nine(
10787 &format!("Corporate, other and eliminations ({i:02})"),
10788 15.0,
10789 y,
10790 106.0,
10791 ));
10792 let mut x = 125.0;
10793 for cell in [
10794 "$ 438,082",
10795 "$ 9,836,950",
10796 "$ 3,304,152",
10797 "$ 5,919,819",
10798 "$ 429,405",
10799 "$ 3,692,551",
10800 "$ 840,333",
10801 "$ 1,049,328",
10802 "$ 6,086,009",
10803 ] {
10804 body.push(nine(cell, x, y, 38.0));
10805 body.push(nine("\t", x + 44.0, y, 2.4));
10806 x += 51.0;
10807 }
10808 }
10809 let stats = PageStats::from_spans(&body);
10810 assert!(
10811 try_vertical_cut(&body, &stats, &[]).is_none(),
10812 "a statement parted by tab runs was cut as two columns of text"
10813 );
10814 }
10815
10816 /// A `–` for nil down a column of amounts is a cell, not a list's marker
10817 /// (DocLayNet validation, #574): read as a list the statement's figures
10818 /// lost their columns.
10819 #[test]
10820 fn a_column_of_nil_dashes_is_not_a_list() {
10821 let mut spans = Vec::new();
10822 for i in 0..5 {
10823 let y = 700.0 - 14.0 * i as f64;
10824 spans.push(nine("\u{2013}", 300.0, y, 5.0));
10825 spans.push(nine(&format!("22,795,60{i}"), 360.0, y, 40.0));
10826 spans.push(nine(&format!("22,788,10{i}"), 430.0, y, 40.0));
10827 }
10828 assert!(
10829 !is_bullet_or_list_marker("22,795,604"),
10830 "the fixture's figures read as markers"
10831 );
10832 let bands = group_spans_into_bands(spans);
10833 let indices: Vec<usize> = (0..bands.len()).collect();
10834 let boundaries = vec![330.0, 415.0];
10835 let gaps = indices.len();
10836 // A column of dashes beside figures is still columnar.
10837 assert!(
10838 region_is_columnar(&bands, &indices, &boundaries, gaps),
10839 "a column of nil dashes beside amounts was taken for a list"
10840 );
10841 }
10842
10843 /// Rows of amounts count as rows with no letters (codex, #2045): left out
10844 /// of the sample, one long header on each side would stand for a whole
10845 /// statement of figures and pass it off as two columns of text.
10846 #[test]
10847 fn rows_of_amounts_keep_a_statement_from_reading_as_text() {
10848 let mut body = vec![
10849 nine(
10850 "Consolidated statement of financial position for the year",
10851 15.0,
10852 712.0,
10853 240.0,
10854 ),
10855 nine(
10856 "Consolidated statement of comprehensive income for the year",
10857 300.0,
10858 712.0,
10859 240.0,
10860 ),
10861 ];
10862 for i in 0..9 {
10863 let y = 700.0 - 12.0 * i as f64;
10864 let mut x = 15.0;
10865 for cell in [
10866 "438,082",
10867 "9,836,950",
10868 "3,304,152",
10869 "5,919,819",
10870 "429,405",
10871 "3,692,551",
10872 "840,333",
10873 "1,049,328",
10874 "6,086,009",
10875 "1,360,471",
10876 ] {
10877 body.push(nine(cell, x, y, 44.0));
10878 body.push(nine("\t", x + 48.0, y, 2.4));
10879 x += 54.0;
10880 }
10881 }
10882 let stats = PageStats::from_spans(&body);
10883 assert!(
10884 try_vertical_cut(&body, &stats, &[]).is_none(),
10885 "a statement of figures under two long headers was cut as two columns of text"
10886 );
10887 }
10888
10889 /// An item may open with a quotation mark or a figure (codex, #2045):
10890 /// `“Revenue increased”` beside a column of bullets is still an item, and
10891 /// the bullets are still a list's.
10892 #[test]
10893 fn an_item_that_opens_with_punctuation_keeps_its_bullet() {
10894 let sized = |text: &str, x: f64, y: f64, width: f64, size: f64| TextSpan {
10895 height: size,
10896 font_size: size,
10897 ..span(text, x, y, width)
10898 };
10899 let mut spans = vec![sized(
10900 "The following items were reported during the course of the Financial Year:",
10901 86.8,
10902 720.0,
10903 313.3,
10904 10.2,
10905 )];
10906 for (k, (item, width)) in [
10907 ("\u{201c}Revenue increased\u{201d} across the group;", 190.0),
10908 ("(Formerly the Northern Division) was sold;", 180.0),
10909 ("2024 results were restated for the change.", 175.0),
10910 (
10911 "\u{201c}Margins improved\u{201d} in the second half.",
10912 170.0,
10913 ),
10914 ]
10915 .iter()
10916 .enumerate()
10917 {
10918 let y = 685.6 - 34.4 * k as f64;
10919 spans.push(sized("\u{2022}", 104.9, y - 4.5, 13.0, 19.0));
10920 spans.push(sized(item, 131.5, y, *width, 10.2));
10921 }
10922 for k in 0..4 {
10923 spans.push(sized(
10924 "Other than detailed in the Chairman's Report set out at pages 1 and 2 of this report",
10925 86.8,
10926 548.0 - 17.2 * k as f64,
10927 394.8,
10928 10.2,
10929 ));
10930 }
10931 let blocks = group_spans_into_blocks(spans, &[]);
10932 let bare = blocks
10933 .iter()
10934 .filter(|b| b.text().trim() == "\u{2022}")
10935 .count();
10936 assert_eq!(
10937 bare,
10938 0,
10939 "items opening with punctuation lost their bullets: {:?}",
10940 blocks.iter().map(|b| b.text()).collect::<Vec<_>>()
10941 );
10942 }
10943
10944 /// A column of bullets is a list, not a column (#574).
10945 ///
10946 /// ASX_MRM's spans as the engine reads them: a 19 pt bullet 13 pt before
10947 /// each 10 pt item, its baseline 4.5 pt under the item's, and body lines
10948 /// round it. Every band of the list has a gap at one x, and read as two
10949 /// columns the list came back as four bullets and then four items.
10950 #[test]
10951 fn a_column_of_bullets_is_read_with_its_items() {
10952 let sized = |text: &str, x: f64, y: f64, width: f64, size: f64| TextSpan {
10953 height: size,
10954 font_size: size,
10955 ..span(text, x, y, width)
10956 };
10957 let mut spans = vec![
10958 sized(
10959 "Mermaid's principal activities during the course of the Financial Year were:",
10960 86.8,
10961 720.0,
10962 313.3,
10963 10.2,
10964 ),
10965 sized("PRINCIPAL ACTIVITIES", 416.8, 718.6, 123.3, 9.1),
10966 ];
10967 for (k, (item, width)) in [
10968 ("Operating crewed vessel charters;", 138.4),
10969 ("Vessel manning, management and logistics;", 179.8),
10970 ("Operating supply base facilities; and", 149.1),
10971 ("Equipment hire.", 67.1),
10972 ]
10973 .iter()
10974 .enumerate()
10975 {
10976 let y = 685.6 - 34.4 * k as f64;
10977 spans.push(sized("\u{2022}", 104.9, y - 4.5, 13.0, 19.0));
10978 spans.push(sized(item, 131.5, y, *width, 10.2));
10979 }
10980 for k in 0..4 {
10981 spans.push(sized(
10982 "Other than detailed in the Chairman's Report set out at pages 1 and 2 of this report",
10983 86.8,
10984 548.0 - 17.2 * k as f64,
10985 394.8,
10986 10.2,
10987 ));
10988 }
10989 let blocks = group_spans_into_blocks(spans, &[]);
10990 let bare = blocks
10991 .iter()
10992 .filter(|b| b.text().trim() == "\u{2022}")
10993 .count();
10994 assert_eq!(
10995 bare,
10996 0,
10997 "the bullets were read as a column of their own: {:?}",
10998 blocks.iter().map(|b| b.text()).collect::<Vec<_>>()
10999 );
11000 }
11001
11002 /// A line number peeled off the line it was drawn into leaves a line whose
11003 /// transform still locates its own first glyph (codex, #1991).
11004 #[test]
11005 fn a_peeled_line_keeps_its_origin_on_its_first_glyph() {
11006 let glued = TextSpan {
11007 text: "10 example, aluminum and steel".into(),
11008 x: 298.8,
11009 y: 100.0,
11010 width: 229.4,
11011 height: 8.5,
11012 font_size: 8.5,
11013 transform: Some([8.5, 0.0, 0.0, 8.5, 298.8, 100.0]),
11014 char_bounds: std::iter::once([298.8, 100.0, 302.0, 108.5])
11015 .chain(std::iter::once([302.0, 100.0, 305.2, 108.5]))
11016 .chain((0..27).map(|k| {
11017 let x = 310.9 + k as f64 * 4.0;
11018 [x, 100.0, x + 4.0, 108.5]
11019 }))
11020 .collect(),
11021 ..TextSpan::default()
11022 };
11023 let mut spans = vec![glued];
11024 for i in 0..3 {
11025 spans.push(TextSpan {
11026 text: "a line of the right column".into(),
11027 x: 310.9,
11028 y: 90.0 - i as f64 * 10.0,
11029 width: 200.0,
11030 height: 8.5,
11031 font_size: 8.5,
11032 ..TextSpan::default()
11033 });
11034 }
11035 let (figure, rest) = peel_one(&spans[0], 0, &spans).expect("the figure is peeled");
11036 assert_eq!(figure.text, "10");
11037 assert_eq!(rest.text, "example, aluminum and steel");
11038 assert!((rest.x - 310.9).abs() < 1e-9);
11039 let t = rest.transform.expect("a transform");
11040 assert!(
11041 (t[4] - rest.x).abs() < 1e-9 && (t[5] - 100.0).abs() < 1e-9,
11042 "the rest's transform still points at the figure: {t:?}"
11043 );
11044 }
11045
11046 /// A column the margin scan finds is a column of line numbers only when
11047 /// every member it found is a figure (codex, #1991): `i`, `1`, `ii` down a
11048 /// list is a list's markers, and one digit among them numbers no page.
11049 #[test]
11050 fn a_margin_column_of_mixed_labels_is_not_figures() {
11051 let band = |y: f64, label: &str| {
11052 let mut b = TextBand::new(TextSpan {
11053 text: "a line of the left column running its measure".into(),
11054 x: 77.0,
11055 y,
11056 width: 200.0,
11057 height: 9.0,
11058 font_size: 9.0,
11059 ..TextSpan::default()
11060 });
11061 b.spans.push(TextSpan {
11062 text: label.into(),
11063 x: 288.0,
11064 y,
11065 width: 6.0,
11066 height: 9.0,
11067 font_size: 9.0,
11068 ..TextSpan::default()
11069 });
11070 b.spans.push(TextSpan {
11071 text: "a line of the right column running its measure".into(),
11072 x: 300.0,
11073 y,
11074 width: 200.0,
11075 height: 9.0,
11076 font_size: 9.0,
11077 ..TextSpan::default()
11078 });
11079 b
11080 };
11081 let column = |labels: &[&str]| {
11082 let bands: Vec<TextBand> = labels
11083 .iter()
11084 .enumerate()
11085 .map(|(i, l)| band(700.0 - i as f64 * 11.0, l))
11086 .collect();
11087 a_margin_column(&bands, 423.0, 10.0, 2.5)
11088 };
11089 let (skipped, figures) = column(&["5", "10", "15", "20"]);
11090 assert!(figures && skipped.iter().all(|b| b.len() == 1));
11091 let (_, figures) = column(&["i", "1", "ii", "iii"]);
11092 assert!(!figures, "a mixed column was read as a column of figures");
11093 }
11094
11095 /// Beside a bank of figures, the gutter's column is still the margin when it
11096 /// COUNTS the lines, and not when it is a table's row numbers (#574).
11097 #[test]
11098 fn a_counting_column_beside_a_bank_of_figures_is_the_margin() {
11099 let span = |text: &str, x: f64, width: f64, y: f64| TextSpan {
11100 text: text.into(),
11101 x,
11102 y,
11103 width,
11104 height: 9.0,
11105 font_size: 9.0,
11106 ..TextSpan::default()
11107 };
11108 let band = |y: f64, figure: &str, label: &str| {
11109 let mut b = TextBand::new(span("a line of the", 77.0, 60.0, y));
11110 b.spans.push(span(figure, 150.0, 12.0, y));
11111 b.spans
11112 .push(span("left column running its measure", 170.0, 107.0, y));
11113 b.spans.push(span(label, 288.0, 6.0, y));
11114 b.spans.push(span(
11115 "a line of the right column running its measure",
11116 300.0,
11117 200.0,
11118 y,
11119 ));
11120 b
11121 };
11122 let column = |labels: &[&str]| {
11123 let bands: Vec<TextBand> = ["0.0", "4.0", "64.9", "3.8"]
11124 .iter()
11125 .zip(labels)
11126 .enumerate()
11127 .map(|(i, (f, l))| band(700.0 - i as f64 * 11.0, f, l))
11128 .collect();
11129 a_margin_column(&bands, 423.0, 10.0, 2.5)
11130 };
11131 let (skipped, figures) = column(&["5", "10", "15", "20"]);
11132 assert!(
11133 figures && skipped.iter().all(|b| b.len() == 1 && b.contains(&3)),
11134 "the line numbers beside a bank were not the margin: {skipped:?}"
11135 );
11136 let (skipped, _) = column(&["1", "2", "3", "4"]);
11137 assert!(
11138 skipped.iter().all(HashSet::is_empty),
11139 "a table's row numbers were taken for line numbers: {skipped:?}"
11140 );
11141 let (skipped, _) = column(&["2018", "2020", "2022", "2024"]);
11142 assert!(
11143 skipped.iter().all(HashSet::is_empty),
11144 "a column of years was taken for line numbers: {skipped:?}"
11145 );
11146 }
11147
11148 /// A span the gap scan skipped is not a cell boundary of a bank (#582).
11149 ///
11150 /// The caller that skips a thin column standing IN a gutter -- printed line
11151 /// numbers down the middle of a statute, which occupy every x of the gutter
11152 /// and make the cut unfindable -- has to say so here too. Counted as ink, a
11153 /// column beside the gutter puts a wide strip of white between itself and
11154 /// the rest of its side on every band, which is exactly the shape
11155 /// `the_cut_is_a_column_of_a_table` refuses a cut for: the guard would
11156 /// refuse the cut that skipping the column was meant to allow.
11157 #[test]
11158 fn a_span_the_scan_skipped_is_not_a_cell_boundary() {
11159 // Four bands. Each carries a line of the left column, a line number
11160 // sitting in the gutter, and a line of the right column.
11161 let mut spans = Vec::new();
11162 for band in 0..4 {
11163 let y = 700.0 - band as f64 * 14.0;
11164 spans.push(span("the committee considered the whole", 72.0, y, 150.0));
11165 spans.push(span("12", 240.0, y, 10.0));
11166 spans.push(span("of the proposals put before it and", 300.0, y, 150.0));
11167 }
11168 let stats = PageStats::from_spans(&spans);
11169 let bands = group_spans_into_bands_with_stats(spans.clone(), &stats);
11170 let cut_x = 270.0;
11171 let gap = 50.0;
11172
11173 // With the line numbers counted as ink, the left side of the cut holds
11174 // two runs a band -- the text and the number -- with 18 points of white
11175 // between them, and each side reads as a bank of cells.
11176 let counted = one_side_of_a_cut(&bands, cut_x, true, cut_x - 72.0, 18.0, &[])
11177 .expect("the left side has ink on every band");
11178 assert_eq!(
11179 (counted.inked, counted.broken),
11180 (4, 4),
11181 "the line numbers are not being read as a second run of ink"
11182 );
11183
11184 // Skipped, the side is one run of ink a band and breaks nowhere, so the
11185 // guard has nothing to refuse the cut for.
11186 // One set per band, which is the whole point: the index of the line
11187 // number is not the same number on every band.
11188 let skipped: Vec<HashSet<usize>> = bands
11189 .iter()
11190 .map(|band| {
11191 band.spans
11192 .iter()
11193 .enumerate()
11194 .filter(|(_, s)| s.text.trim() == "12")
11195 .map(|(i, _)| i)
11196 .collect()
11197 })
11198 .collect();
11199 let without = one_side_of_a_cut(&bands, cut_x, true, cut_x - 72.0, 18.0, &skipped)
11200 .expect("the left side still has ink on every band");
11201 assert_eq!(
11202 (without.inked, without.broken),
11203 (4, 0),
11204 "a span the scan skipped is still being measured as a cell boundary"
11205 );
11206 assert!(
11207 !the_cut_is_a_column_of_a_table(&bands, cut_x, gap, &skipped),
11208 "the cut was refused for a column the scan had already skipped"
11209 );
11210 }
11211
11212 /// The gutter is only there in the MEASURED edges. `right()` returns
11213 /// x + width.max(estimated_width()), and on tracemonkey's abstract that
11214 /// estimate overshoots the measured edge by 61pt -- wider than the gutter
11215 /// it then covers, so the scan finds no corridor and the guards never run.
11216 #[test]
11217 fn a_column_gap_is_found_on_measured_edges_not_estimated_ones() {
11218 let spans = two_column_page();
11219 let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
11220 let stats = PageStats::from_spans(&body);
11221
11222 let overshoots = body.iter().any(|s| s.right() > s.measured_right() + 1.0);
11223 assert!(
11224 overshoots,
11225 "fixture no longer exercises the overshoot; right() must exceed measured_right() for this test to mean anything"
11226 );
11227
11228 let cut = try_vertical_cut(&body, &stats, &[]);
11229 assert!(
11230 cut.is_some(),
11231 "no column cut found on a clean two-column body"
11232 );
11233 }
11234
11235 fn block_texts(spans: Vec<TextSpan>) -> Vec<String> {
11236 block_texts_with_rules(spans, &[])
11237 }
11238
11239 /// The same, with the horizontal rules the page drew (#545).
11240 fn block_texts_with_rules(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<String> {
11241 group_spans_into_blocks(spans, rules)
11242 .into_iter()
11243 .map(|block| block.text())
11244 .collect()
11245 }
11246
11247 /// The same, but only the leaf of the XY-cut: the bands as
11248 /// `group_spans_into_blocks_legacy_from_bands` sees them.
11249 ///
11250 /// Some of the shapes below are refused by the leaf and taken by the column
11251 /// cut above it instead, which proves nothing about the leaf. This says what
11252 /// the leaf does with them.
11253 fn leaf_texts(spans: Vec<TextSpan>) -> Vec<String> {
11254 let bands = group_spans_into_bands(spans);
11255 group_spans_into_blocks_legacy_from_bands(bands, &[])
11256 .iter()
11257 .map(TextBlock::text)
11258 .collect()
11259 }
11260
11261 #[test]
11262 fn empty_device_produces_empty_text() {
11263 let dev = TextExtractionDevice::new();
11264 assert!(dev.into_text().is_empty());
11265 }
11266
11267 #[test]
11268 fn single_column_stays_row_major() {
11269 let texts = block_texts(vec![
11270 span("Single Column Line 1", 40.0, 700.0, 140.0),
11271 span("Single Column Line 2", 40.0, 684.0, 140.0),
11272 span("Single Column Line 3", 40.0, 668.0, 140.0),
11273 ]);
11274
11275 assert_eq!(
11276 texts,
11277 vec![
11278 "Single Column Line 1",
11279 "Single Column Line 2",
11280 "Single Column Line 3",
11281 ]
11282 );
11283 }
11284
11285 /// A header, three bands each of which draws two slices with a gutter
11286 /// between them, and a footer. Read as columns unless the page says
11287 /// otherwise, which is what the three tests after this one are about.
11288 fn two_column_region_spans() -> Vec<TextSpan> {
11289 vec![
11290 span("Header", 200.0, 740.0, 80.0),
11291 span("Left column line one", 40.0, 700.0, 115.0),
11292 span("Right column line one", 320.0, 700.0, 120.0),
11293 span("Left column line two", 40.0, 684.0, 115.0),
11294 span("Right column line two", 320.0, 684.0, 120.0),
11295 span("Left column line three", 40.0, 668.0, 125.0),
11296 span("Right column line three", 320.0, 668.0, 130.0),
11297 span("Footer", 200.0, 620.0, 80.0),
11298 ]
11299 }
11300
11301 #[test]
11302 fn two_column_region_reads_column_major() {
11303 let texts = block_texts(two_column_region_spans());
11304
11305 assert_eq!(
11306 texts,
11307 vec![
11308 "Header",
11309 "Left column line one",
11310 "Left column line two",
11311 "Left column line three",
11312 "Right column line one",
11313 "Right column line two",
11314 "Right column line three",
11315 "Footer",
11316 ]
11317 );
11318 }
11319
11320 /// The rules the page draws between its rows, for the fixture above: a
11321 /// horizontal line across the whole table, in every gap between two rows.
11322 fn rules_between_the_rows() -> Vec<HorizontalRule> {
11323 [692.0, 676.0]
11324 .into_iter()
11325 .map(|y| HorizontalRule {
11326 left: 35.0,
11327 right: 445.0,
11328 y,
11329 })
11330 .collect()
11331 }
11332
11333 /// The same three bands that read column by column above, with a ruling
11334 /// line between each pair of them, read across instead (#545). This is the
11335 /// Isartor appendix: two columns of text that are the two cells of a row,
11336 /// and nothing but the page's own graphics says so.
11337 #[test]
11338 fn a_ruled_two_column_region_is_read_row_by_row() {
11339 let texts = block_texts_with_rules(two_column_region_spans(), &rules_between_the_rows());
11340
11341 assert_eq!(
11342 texts,
11343 vec![
11344 "Header",
11345 "Left column line one Right column line one",
11346 "Left column line two Right column line two",
11347 "Left column line three Right column line three",
11348 "Footer",
11349 ]
11350 );
11351 }
11352
11353 /// A rule under one cell is not a rule between two rows. Widening the test
11354 /// to any line that happens to sit in the gap turns every underlined cell
11355 /// into a table, so the rule has to cross the region it claims to divide.
11356 #[test]
11357 fn a_rule_that_crosses_only_one_column_leaves_the_columns_alone() {
11358 let short: Vec<HorizontalRule> = rules_between_the_rows()
11359 .into_iter()
11360 .map(|rule| HorizontalRule {
11361 right: 160.0,
11362 ..rule
11363 })
11364 .collect();
11365
11366 let texts = block_texts_with_rules(two_column_region_spans(), &short);
11367
11368 assert_eq!(
11369 texts,
11370 vec![
11371 "Header",
11372 "Left column line one",
11373 "Left column line two",
11374 "Left column line three",
11375 "Right column line one",
11376 "Right column line two",
11377 "Right column line three",
11378 "Footer",
11379 ]
11380 );
11381 }
11382
11383 /// One rule is a headline's underline, a footnote's separator, the line
11384 /// above a page number -- every document has them, and none of them makes
11385 /// the text below a table.
11386 ///
11387 /// Asked of the predicate rather than of the page, because a region of
11388 /// three bands with one rule in it is refused by the column cut above the
11389 /// leaf for its own reasons, and a page-level assertion would be green
11390 /// whatever this rule said.
11391 #[test]
11392 fn a_single_rule_does_not_make_a_region_a_table() {
11393 let bands = group_spans_into_bands(two_column_region_spans());
11394 let rows: Vec<usize> = bands
11395 .iter()
11396 .enumerate()
11397 .filter(|(_, band)| band.y < 720.0 && band.y > 640.0)
11398 .map(|(idx, _)| idx)
11399 .collect();
11400 assert_eq!(
11401 rows.len(),
11402 3,
11403 "the fixture's three rows are the region here"
11404 );
11405
11406 let both = rules_between_the_rows();
11407 assert!(
11408 region_is_ruled(&bands, &rows, &both),
11409 "a rule in every gap of the region is a ruled table"
11410 );
11411 assert!(
11412 !region_is_ruled(&bands, &rows, &both[..1]),
11413 "one rule is not a table"
11414 );
11415 }
11416
11417 /// One path may hold every rule a table draws, so a cap asked once per
11418 /// `draw_path` bounds nothing. The vector it would grow is walked per band
11419 /// by `region_is_ruled`, which is why the bound is on the vector and not on
11420 /// the call.
11421 #[test]
11422 fn one_path_of_many_rules_still_stops_at_the_cap() {
11423 let mut path = BezPath::new();
11424 for row in 0..(MAX_RECORDED_RULES * 2) {
11425 let y = 700.0 - row as f64;
11426 path.move_to((40.0, y));
11427 path.line_to((440.0, y));
11428 path.close_path();
11429 }
11430
11431 let mut device = TextExtractionDevice::new();
11432 device.record_rule(&path, Affine::IDENTITY);
11433
11434 assert_eq!(device.rules.len(), MAX_RECORDED_RULES);
11435 }
11436
11437 /// A Form XObject's `/BBox` reaches this device as a clip, with the
11438 /// geometry inside it unclipped. A line ruled across one cell of such a
11439 /// form therefore arrives as a line across the page, and two of those are
11440 /// enough to refuse a column cut that should have been taken.
11441 #[test]
11442 fn a_rule_is_recorded_at_the_width_the_clip_leaves_it() {
11443 let mut wide = BezPath::new();
11444 wide.move_to((40.0, 600.0));
11445 wide.line_to((440.0, 600.0));
11446
11447 let mut device = TextExtractionDevice::new();
11448 device.push_clip_path(&ClipPath {
11449 path: kurbo::Rect::new(40.0, 500.0, 160.0, 700.0).to_path(0.1),
11450 fill: pdf_render::pdf_interpret::FillRule::NonZero,
11451 });
11452 device.record_rule(&wide, Affine::IDENTITY);
11453
11454 assert_eq!(device.rules.len(), 1, "the visible part is still a rule");
11455 assert!(
11456 device.rules[0].right < 161.0,
11457 "the rule was recorded past its clip: {:?}",
11458 device.rules[0]
11459 );
11460
11461 let mut above = BezPath::new();
11462 above.move_to((40.0, 800.0));
11463 above.line_to((440.0, 800.0));
11464 device.record_rule(&above, Affine::IDENTITY);
11465 assert_eq!(
11466 device.rules.len(),
11467 1,
11468 "a rule the clip cuts away entirely was recorded: {:?}",
11469 device.rules
11470 );
11471
11472 device.pop_clip_path();
11473 device.record_rule(&wide, Affine::IDENTITY);
11474 assert_eq!(
11475 device.rules[1].right, 440.0,
11476 "the clip outlived the pop: {:?}",
11477 device.rules[1]
11478 );
11479 }
11480
11481 /// A table rules most of its gaps and a page of prose rules a couple: the
11482 /// share is what separates them, and it is measured rather than assumed.
11483 /// The Isartor appendix rules 26 of its 28 gaps; the two it leaves are the
11484 /// rows whose cell wraps, which is why the share is a half and not all.
11485 #[test]
11486 fn a_region_with_a_rule_in_a_minority_of_its_gaps_is_not_a_table() {
11487 let mut spans = Vec::new();
11488 for row in 0..6 {
11489 let y = 700.0 - row as f64 * 16.0;
11490 spans.push(span("Left column line", 40.0, y, 115.0));
11491 spans.push(span("Right column line", 320.0, y, 120.0));
11492 }
11493 let bands = group_spans_into_bands(spans);
11494 let rows: Vec<usize> = (0..bands.len()).collect();
11495 assert_eq!(bands.len(), 6, "six rows, so five gaps between them");
11496
11497 let rule_at = |y: f64| HorizontalRule {
11498 left: 35.0,
11499 right: 445.0,
11500 y,
11501 };
11502 let two = vec![rule_at(692.0), rule_at(676.0)];
11503 let three = vec![rule_at(692.0), rule_at(676.0), rule_at(660.0)];
11504
11505 assert!(
11506 !region_is_ruled(&bands, &rows, &two),
11507 "two of five is prose"
11508 );
11509 assert!(
11510 region_is_ruled(&bands, &rows, &three),
11511 "three of five is a table"
11512 );
11513 }
11514
11515 #[test]
11516 fn mixed_single_and_multi_column_regions_preserve_shared_bands() {
11517 let texts = block_texts(vec![
11518 span("Intro paragraph", 40.0, 740.0, 180.0),
11519 span("L1 words here", 40.0, 700.0, 110.0),
11520 span("R1 words here", 320.0, 700.0, 110.0),
11521 span("L2 words here", 40.0, 684.0, 110.0),
11522 span("R2 words here", 320.0, 684.0, 110.0),
11523 span("L3 words here", 40.0, 668.0, 110.0),
11524 span("R3 words here", 320.0, 668.0, 110.0),
11525 span("Outro paragraph", 40.0, 620.0, 180.0),
11526 ]);
11527
11528 assert_eq!(
11529 texts,
11530 vec![
11531 "Intro paragraph",
11532 "L1 words here",
11533 "L2 words here",
11534 "L3 words here",
11535 "R1 words here",
11536 "R2 words here",
11537 "R3 words here",
11538 "Outro paragraph",
11539 ]
11540 );
11541 }
11542
11543 /// A patent's front page: fields numbered by INID codes down the left,
11544 /// mostly short, with one long field line; references down the right on
11545 /// the same baselines. The long line is the column's measure, not a banner
11546 /// over both columns, so the page is read column by column (#574).
11547 #[test]
11548 fn a_patent_front_page_is_read_column_by_column() {
11549 // The measurements of 6942822's front page: a 14 pt gutter, the
11550 // column's long field line reaching into it, the rest ~150 pt.
11551 let fields = [
11552 ("(21) Appl. No.: 10/290,458", 150.0),
11553 ("(22) Filed: Nov. 8, 2002", 140.0),
11554 ("(51) Int. Cl. H01B 1/12", 160.0),
11555 ("(52) U.S. Cl. 252/500; 528/210", 150.0),
11556 ("(58) Field of Search .......... 252/500; 528/422", 219.0),
11557 ("(56) References Cited", 140.0),
11558 ];
11559 let mut spans = Vec::new();
11560 for (k, (field, width)) in fields.iter().enumerate() {
11561 let y = 700.0 - k as f64 * 16.0;
11562 spans.push(span(field, 77.0, y, *width));
11563 // The long field stands on a band of its own, as on the page:
11564 // the reference beside it ended a line higher.
11565 if k != 4 {
11566 spans.push(span(
11567 "Journal of Polymer Science, vol. 38, pp. 3390",
11568 310.0,
11569 y,
11570 220.0,
11571 ));
11572 }
11573 }
11574 let texts = block_texts(spans);
11575 assert!(
11576 texts
11577 .iter()
11578 .all(|t| !(t.contains(")") && t.contains("Journal"))),
11579 "a field was joined to the reference beside it: {texts:?}"
11580 );
11581 }
11582
11583 #[test]
11584 fn short_table_like_rows_fall_back_to_row_major() {
11585 let texts = block_texts(vec![
11586 span("Name", 40.0, 700.0, 30.0),
11587 span("Age", 320.0, 700.0, 20.0),
11588 span("Alice", 40.0, 684.0, 35.0),
11589 span("30", 320.0, 684.0, 15.0),
11590 span("Bob", 40.0, 668.0, 24.0),
11591 span("25", 320.0, 668.0, 15.0),
11592 ]);
11593
11594 assert_eq!(texts, vec!["Name Age", "Alice 30", "Bob 25"]);
11595 }
11596
11597 /// A three-column table row whose middle cell wraps, at the block level:
11598 /// the reordering happens in the leaf of the XY-cut, and this is the leaf's
11599 /// own view of `8.2.5.26-t05-pass-d.pdf`'s second row (#434).
11600 #[test]
11601 fn a_wrapped_cell_is_emitted_with_its_own_cell() {
11602 let texts = block_texts(vec![
11603 span("Index", 60.0, 700.0, 30.0),
11604 span("Failure condition", 200.0, 700.0, 100.0),
11605 span("Section", 400.0, 700.0, 42.0),
11606 span("15-003", 60.0, 680.0, 36.0),
11607 span("In a table not organized", 200.0, 680.0, 144.0),
11608 span("UA1:7.5-2", 400.0, 680.0, 54.0),
11609 span("a cell without a Scope", 200.0, 668.0, 132.0),
11610 ]);
11611
11612 assert_eq!(
11613 texts,
11614 vec![
11615 "Index Failure condition Section",
11616 "15-003",
11617 "In a table not organized",
11618 "a cell without a Scope",
11619 "UA1:7.5-2",
11620 ]
11621 );
11622 }
11623
11624 /// The failure case the fix names: when every cell of the row has a line
11625 /// under it, nothing says which of those lines continues the row and which
11626 /// one begins the next -- the same shape a two-column region takes when one
11627 /// column runs on alone. Such a row keeps today's row-major order rather
11628 /// than being guessed at.
11629 /// The leading is deliberately tight, so that all three lines pass the
11630 /// distance test in their own cell and it is the last guard -- every cell
11631 /// of the row having a line under it -- that has to refuse the row. At that
11632 /// leading the column cut above the leaf takes the page, so this one asks
11633 /// the leaf directly.
11634 #[test]
11635 fn a_row_whose_every_cell_wraps_stays_row_major() {
11636 let texts = leaf_texts(vec![
11637 span("Index", 60.0, 700.0, 30.0),
11638 span("Failure condition", 200.0, 700.0, 100.0),
11639 span("Section", 400.0, 700.0, 42.0),
11640 span("15-003", 60.0, 680.0, 36.0),
11641 span("In a table not organized", 200.0, 680.0, 144.0),
11642 span("UA1:7.5-2", 400.0, 680.0, 54.0),
11643 span("is the index", 60.0, 674.0, 72.0),
11644 span("a cell without a Scope", 200.0, 668.0, 132.0),
11645 span("of the suite", 400.0, 662.0, 72.0),
11646 ]);
11647
11648 assert_eq!(
11649 texts,
11650 vec![
11651 "Index Failure condition Section",
11652 "15-003 In a table not organized UA1:7.5-2",
11653 "is the index",
11654 "a cell without a Scope",
11655 "of the suite",
11656 ]
11657 );
11658 }
11659
11660 /// The next row of a table with an empty cell also has fewer spans than the
11661 /// row above it and also starts on the same edges. It is the next row all
11662 /// the same, and what says so is that it fills two cells where a wrapped
11663 /// line fills one. The header of this table is padded, so the body's rows
11664 /// sit closer together than the pitch under the header and the distance
11665 /// alone would let this one pass.
11666 #[test]
11667 fn a_row_with_an_empty_cell_is_a_row_and_not_a_wrap() {
11668 let texts = block_texts(vec![
11669 span("Index", 60.0, 700.0, 30.0),
11670 span("Type", 200.0, 700.0, 24.0),
11671 span("How", 400.0, 700.0, 18.0),
11672 span("15-003", 60.0, 680.0, 36.0),
11673 span("Object", 200.0, 680.0, 36.0),
11674 span("Machine", 400.0, 680.0, 42.0),
11675 span("15-004", 60.0, 666.0, 36.0),
11676 span("Rope", 400.0, 666.0, 24.0),
11677 ]);
11678
11679 assert_eq!(
11680 texts,
11681 vec!["Index Type How", "15-003 Object Machine", "15-004 Rope"]
11682 );
11683 }
11684
11685 /// A row that fills a single cell, one line under a wrapped one. Measured
11686 /// against the wrapped line it is a line's distance away; measured against
11687 /// the cell it would join -- which has no line under it yet, so the row
11688 /// itself -- it is two, and it is a row.
11689 #[test]
11690 fn a_single_cell_row_under_a_wrapped_line_is_still_a_row() {
11691 let texts = block_texts(vec![
11692 span("Index", 60.0, 700.0, 30.0),
11693 span("Type", 200.0, 700.0, 24.0),
11694 span("How", 400.0, 700.0, 18.0),
11695 span("15-003", 60.0, 680.0, 36.0),
11696 span("In a table not organized", 200.0, 680.0, 144.0),
11697 span("UA1:7.5-2", 400.0, 680.0, 54.0),
11698 span("a cell without a Scope", 200.0, 668.0, 132.0),
11699 span("15-004", 60.0, 656.0, 36.0),
11700 ]);
11701
11702 assert_eq!(
11703 texts,
11704 vec![
11705 "Index Type How",
11706 "15-003",
11707 "In a table not organized",
11708 "a cell without a Scope",
11709 "UA1:7.5-2",
11710 "15-004",
11711 ]
11712 );
11713 }
11714
11715 /// The same, with a row that fills a single cell: nothing but the pitch
11716 /// separates it from a wrapped line, and the pitch is what says it.
11717 #[test]
11718 fn a_line_a_whole_row_pitch_down_is_a_row_and_not_a_wrap() {
11719 let texts = block_texts(vec![
11720 span("Index", 60.0, 700.0, 30.0),
11721 span("Type", 200.0, 700.0, 24.0),
11722 span("How", 400.0, 700.0, 18.0),
11723 span("15-003", 60.0, 680.0, 36.0),
11724 span("Object", 200.0, 680.0, 36.0),
11725 span("Machine", 400.0, 680.0, 42.0),
11726 span("Rope", 200.0, 660.0, 24.0),
11727 ]);
11728
11729 assert_eq!(
11730 texts,
11731 vec!["Index Type How", "15-003 Object Machine", "Rope"]
11732 );
11733 }
11734
11735 #[test]
11736 fn three_column_regions_are_supported() {
11737 let texts = block_texts(vec![
11738 span("Column one line one", 40.0, 700.0, 105.0),
11739 span("Column two line one", 220.0, 700.0, 105.0),
11740 span("Column three line one", 400.0, 700.0, 120.0),
11741 span("Column one line two", 40.0, 684.0, 105.0),
11742 span("Column two line two", 220.0, 684.0, 105.0),
11743 span("Column three line two", 400.0, 684.0, 120.0),
11744 span("Column one line three", 40.0, 668.0, 120.0),
11745 span("Column two line three", 220.0, 668.0, 120.0),
11746 span("Column three line three", 400.0, 668.0, 135.0),
11747 ]);
11748
11749 assert_eq!(
11750 texts,
11751 vec![
11752 "Column one line one",
11753 "Column one line two",
11754 "Column one line three",
11755 "Column two line one",
11756 "Column two line two",
11757 "Column two line three",
11758 "Column three line one",
11759 "Column three line two",
11760 "Column three line three",
11761 ]
11762 );
11763 }
11764
11765 #[test]
11766 fn text_block_concatenation_spaced() {
11767 let block = TextBlock {
11768 spans: vec![span("A", 0.0, 0.0, 6.0), span("B", 20.0, 0.0, 6.0)],
11769 };
11770 assert_eq!(block.text(), "A B");
11771 }
11772
11773 #[test]
11774 fn a_space_drawn_over_a_ligature_is_no_word_space() {
11775 // `Specifi` then ` cally`, the second run starting 0.3 em before the
11776 // first ends (#574): the page draws one word in two runs, and a real
11777 // word space never starts left of the letter before it.
11778 let block = TextBlock {
11779 spans: vec![
11780 span("Specifi", 0.0, 0.0, 60.0),
11781 span(" cally", 56.4, 0.0, 36.0),
11782 ],
11783 };
11784 assert_eq!(block.text(), "Specifically");
11785 assert_eq!(crate::ligatures::fold(&block.text()), "Specifically");
11786 }
11787
11788 #[test]
11789 fn a_space_drawn_over_an_initial_is_no_word_space() {
11790 // `T` then ` ransition`, the second run starting 0.6 em back: the
11791 // same signature without a ligature (#574).
11792 let block = TextBlock {
11793 spans: vec![span("T", 0.0, 0.0, 8.0), span(" ransition", 0.8, 0.0, 60.0)],
11794 };
11795 assert_eq!(block.text(), "Transition");
11796 }
11797
11798 #[test]
11799 fn a_real_word_space_stays() {
11800 // `There` then ` is`, the second run starting 0.2 em AFTER the first
11801 // ends: a gap in reading direction is a word space (#574).
11802 let block = TextBlock {
11803 spans: vec![span("There", 0.0, 0.0, 30.0), span(" is", 32.4, 0.0, 15.0)],
11804 };
11805 assert_eq!(block.text(), "There is");
11806 }
11807
11808 #[test]
11809 fn an_overlapping_run_that_starts_with_a_capital_or_digit_keeps_its_space() {
11810 // Formulas and tables overlap runs that open with a capital or a
11811 // digit; only a lower-case letter joins (#574).
11812 let digit = TextBlock {
11813 spans: vec![span("v", 0.0, 0.0, 8.0), span(" 2", 4.4, 0.0, 10.0)],
11814 };
11815 assert_eq!(digit.text(), "v 2");
11816 let capital = TextBlock {
11817 spans: vec![span("v", 0.0, 0.0, 8.0), span(" N", 4.4, 0.0, 10.0)],
11818 };
11819 assert_eq!(capital.text(), "v N");
11820 }
11821
11822 #[test]
11823 fn adaptive_column_gap_fallback_for_no_gaps() {
11824 // Single-span bands produce no measurable gaps → fallback
11825 let bands = vec![
11826 TextBand::new(span("Hello", 40.0, 700.0, 80.0)),
11827 TextBand::new(span("World", 40.0, 684.0, 80.0)),
11828 ];
11829 let threshold = compute_adaptive_column_gap(&bands);
11830 assert!((threshold - COLUMN_GAP_THRESHOLD_FALLBACK).abs() < 0.01);
11831 }
11832
11833 #[test]
11834 fn adaptive_column_gap_uses_median() {
11835 // Three bands with word gaps of ~4pt each → median ≈ 4, threshold = 12
11836 let mut bands = Vec::new();
11837 for y in [700.0, 684.0, 668.0] {
11838 let mut band = TextBand::new(span("word1", 40.0, y, 30.0));
11839 band.spans.push(span("word2", 74.0, y, 30.0)); // gap = 4
11840 band.spans.push(span("word3", 108.0, y, 30.0)); // gap = 4
11841 bands.push(band);
11842 }
11843 let threshold = compute_adaptive_column_gap(&bands);
11844 // median gap = 4, × 3 = 12, clamped to [10, 40] → 12
11845 assert!(
11846 (10.0..=14.0).contains(&threshold),
11847 "expected ~12, got {threshold}"
11848 );
11849 }
11850
11851 #[test]
11852 fn adaptive_column_gap_clamps_to_min() {
11853 // Tight gaps (2pt) across many bands → median = 2, 3×2 = 6 → clamped to 10
11854 let mut bands = Vec::new();
11855 for y in [700.0, 684.0, 668.0, 652.0] {
11856 let mut band = TextBand::new(span("abc", 0.0, y, 18.0));
11857 // right of "abc" = max(18, 12*0.5*3=18) = 18; gap = 20-18 = 2
11858 band.spans.push(span("def", 20.0, y, 18.0));
11859 bands.push(band);
11860 }
11861 let threshold = compute_adaptive_column_gap(&bands);
11862 assert!(
11863 (threshold - COLUMN_GAP_THRESHOLD_MIN).abs() < 0.01,
11864 "expected {COLUMN_GAP_THRESHOLD_MIN}, got {threshold}"
11865 );
11866 }
11867
11868 #[test]
11869 fn adaptive_column_gap_all_large_gaps_uses_fraction_of_min() {
11870 // When all gaps are large (> MIN), threshold = 0.75 × min_gap.
11871 let mut band = TextBand::new(span("Left", 0.0, 700.0, 30.0));
11872 band.spans.push(span("Right", 80.0, 700.0, 30.0)); // gap = 50
11873 let bands = vec![band];
11874 let threshold = compute_adaptive_column_gap(&bands);
11875 assert!(
11876 (threshold - 37.5).abs() < 0.01,
11877 "expected 37.5 (0.75×50), got {threshold}"
11878 );
11879 }
11880
11881 #[test]
11882 fn normalize_trims_trailing_whitespace_per_line() {
11883 assert_eq!(
11884 normalize_text_output("hello \nworld \n"),
11885 "hello\nworld\n"
11886 );
11887 }
11888
11889 #[test]
11890 fn normalize_collapses_excess_newlines() {
11891 // >2 blank lines collapse to 2 (meaning 3 \n in a row: line, blank, blank)
11892 assert_eq!(
11893 normalize_text_output("hello\n\n\n\n\nworld\n"),
11894 "hello\n\n\nworld\n"
11895 );
11896 }
11897
11898 #[test]
11899 fn normalize_preserves_double_newline() {
11900 assert_eq!(
11901 normalize_text_output("paragraph one\n\nparagraph two\n"),
11902 "paragraph one\n\nparagraph two\n"
11903 );
11904 }
11905
11906 #[test]
11907 fn normalize_preserves_form_feed() {
11908 assert_eq!(
11909 normalize_text_output("page1\n\n\x0Cpage2\n"),
11910 "page1\n\n\x0Cpage2\n"
11911 );
11912 }
11913
11914 #[test]
11915 fn normalize_adds_trailing_newline() {
11916 assert_eq!(normalize_text_output("hello"), "hello\n");
11917 }
11918
11919 #[test]
11920 fn normalize_empty_input() {
11921 assert_eq!(normalize_text_output(""), "");
11922 }
11923
11924 #[test]
11925 fn normalize_only_whitespace() {
11926 assert_eq!(normalize_text_output(" \n \n"), "");
11927 }
11928
11929 // --- Hyphen stitching tests ---
11930
11931 #[test]
11932 fn hyphen_stitch_joins_wrapped_word() {
11933 let lines = vec!["the aver-".into(), "age rainfall".into()];
11934 assert_eq!(stitch_hyphenated_lines(&lines), "the average rainfall");
11935 }
11936
11937 #[test]
11938 fn hyphen_stitch_handles_leading_whitespace() {
11939 let lines = vec!["pre-".into(), " dict the outcome".into()];
11940 // "pre" is only 3 chars → meets >= 3 guard
11941 assert_eq!(stitch_hyphenated_lines(&lines), "predict the outcome");
11942 }
11943
11944 #[test]
11945 fn hyphen_stitch_capital_continuation_not_stitched() {
11946 let lines = vec!["Section three-".into(), "Summary here".into()];
11947 assert_eq!(
11948 stitch_hyphenated_lines(&lines),
11949 "Section three-\nSummary here"
11950 );
11951 }
11952
11953 #[test]
11954 fn hyphen_stitch_bullet_dash_not_stitched() {
11955 // "-" alone: char before hyphen is not alphabetic
11956 let lines = vec!["Items:".into(), "-".into(), "milk".into()];
11957 assert_eq!(stitch_hyphenated_lines(&lines), "Items:\n-\nmilk");
11958 }
11959
11960 #[test]
11961 fn hyphen_stitch_numeric_range_not_stitched() {
11962 // "42-" — char before hyphen is digit, not alphabetic
11963 let lines = vec!["page 42-".into(), "seventy".into()];
11964 assert_eq!(stitch_hyphenated_lines(&lines), "page 42-\nseventy");
11965 }
11966
11967 #[test]
11968 fn hyphen_stitch_short_prefix_not_stitched() {
11969 // "re-" only 2 alpha chars before hyphen → below 3-char guard
11970 let lines = vec!["re-".into(), "organize".into()];
11971 assert_eq!(stitch_hyphenated_lines(&lines), "re-\norganize");
11972 }
11973
11974 #[test]
11975 fn hyphen_stitch_short_continuation_not_stitched() {
11976 // Next line starts with "an" (2 chars) → below 3-char guard
11977 let lines = vec!["counter-".into(), "an example".into()];
11978 assert_eq!(stitch_hyphenated_lines(&lines), "counter-\nan example");
11979 }
11980
11981 #[test]
11982 fn hyphen_stitch_compound_word_midline_preserved() {
11983 // "real-time" is mid-line, not end-of-line — no stitching applies
11984 // because stitch only operates on line boundaries
11985 let lines = vec!["real-time system".into()];
11986 assert_eq!(stitch_hyphenated_lines(&lines), "real-time system");
11987 }
11988
11989 #[test]
11990 fn hyphen_stitch_single_line_unchanged() {
11991 let lines = vec!["only line".into()];
11992 assert_eq!(stitch_hyphenated_lines(&lines), "only line");
11993 }
11994
11995 #[test]
11996 fn hyphen_stitch_empty_input() {
11997 let lines: Vec<String> = vec![];
11998 assert_eq!(stitch_hyphenated_lines(&lines), "");
11999 }
12000
12001 // --- TEX1 multi-signal space consensus tests ---
12002
12003 fn make_device_with_median(median: f64) -> TextExtractionDevice {
12004 let mut dev = TextExtractionDevice::new();
12005 // Seed enough samples for the median to resolve to `median`.
12006 for _ in 0..MEDIAN_REFRESH {
12007 dev.glyph_widths.push(median);
12008 }
12009 dev.refresh_median_char_width();
12010 assert!((dev.cached_median_char_width - median).abs() < 1e-9);
12011 dev
12012 }
12013
12014 #[test]
12015 fn consensus_inserts_space_on_strong_tj_offset_alone() {
12016 // Gap is below the geometric threshold, but the TJ offset is large
12017 // enough that the consensus must still fire.
12018 let mut dev = make_device_with_median(6.0);
12019 dev.pending_tj_offset = -250.0; // full em-space, to the right
12020 assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
12021 }
12022
12023 #[test]
12024 fn a_pair_kern_of_any_size_is_not_a_word_space() {
12025 // `[(FINANCIAL RESUL)134.6(TS)] TJ`: the positive TJ pulls the `T`
12026 // back under the `L`, and the gap it leaves is below zero.
12027 let mut dev = make_device_with_median(6.0);
12028 dev.pending_tj_offset = 134.6;
12029 assert!(!dev.evaluate_space_consensus(-1.15, 8.5, "RESUL", "TS"));
12030 }
12031
12032 #[test]
12033 fn consensus_inserts_space_on_geometric_gap_alone() {
12034 // No TJ, no character transition, but a clearly wide geometric gap.
12035 let dev = make_device_with_median(6.0);
12036 // gap > 0.3 * 6.0 = 1.8 → fires gap signal (0.80), below threshold
12037 // on its own? 0.80 < 0.75 threshold? No, 0.80 > 0.75, so it fires.
12038 assert!(dev.evaluate_space_consensus(2.5, 12.0, "hello", "world"));
12039 }
12040
12041 #[test]
12042 fn consensus_no_space_on_kerning_gap() {
12043 // Small kerning-size gap with no other signals must not inject a
12044 // space (regression guard against false-positive spaces inside
12045 // tightly kerned words).
12046 let dev = make_device_with_median(6.0);
12047 assert!(!dev.evaluate_space_consensus(0.5, 12.0, "fi", "lm"));
12048 }
12049
12050 #[test]
12051 fn consensus_inserts_space_on_camel_case_plus_gap() {
12052 // CamelCase heuristic (0.60) alone doesn't reach threshold, but a
12053 // moderate gap (0.60 gap + 0.60 heuristic if gap fires) should.
12054 // Here gap = 2.5 > 1.8 → gap fires → total 0.80 + 0.60 = 1.40.
12055 let dev = make_device_with_median(6.0);
12056 assert!(dev.evaluate_space_consensus(2.5, 12.0, "helloWorld", "Inc"));
12057 }
12058
12059 #[test]
12060 fn consensus_inserts_space_on_digit_letter_transition_with_gap() {
12061 let dev = make_device_with_median(6.0);
12062 assert!(dev.evaluate_space_consensus(2.5, 12.0, "123", "abc"));
12063 }
12064
12065 #[test]
12066 fn consensus_heuristic_alone_is_insufficient() {
12067 // Heuristic (0.60) on its own is below the 0.75 threshold — the
12068 // design deliberately requires a second corroborating signal to
12069 // avoid gluing spaces into existing CamelCase identifiers that
12070 // have no geometric break.
12071 let dev = make_device_with_median(6.0);
12072 assert!(!dev.evaluate_space_consensus(0.5, 12.0, "camel", "Case"));
12073 }
12074
12075 #[test]
12076 fn consensus_falls_back_to_font_size_when_no_median() {
12077 // No samples → median is 0; geometric reference uses font-size.
12078 let dev = TextExtractionDevice::new();
12079 // gap 1.9 > 0.15 * 12.0 = 1.8 → gap signal fires
12080 assert!(dev.evaluate_space_consensus(1.9, 12.0, "a", "b"));
12081 // gap 1.5 < 1.8 → no signal
12082 assert!(!dev.evaluate_space_consensus(1.5, 12.0, "a", "b"));
12083 }
12084
12085 #[test]
12086 fn consensus_ignores_tiny_tj_offsets() {
12087 // TJ offsets below the threshold are kerning, not word breaks.
12088 let mut dev = make_device_with_median(6.0);
12089 dev.pending_tj_offset = 50.0;
12090 assert!(!dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
12091 }
12092
12093 #[test]
12094 fn consensus_accepts_negative_tj_offsets() {
12095 // A negative TJ offset moves the next glyph right, an explicit
12096 // inter-substring shift, and counts toward the consensus.
12097 let mut dev = make_device_with_median(6.0);
12098 dev.pending_tj_offset = -250.0;
12099 assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
12100 }
12101
12102 #[test]
12103 fn text_adjustment_accumulates_until_glyph() {
12104 let mut dev = TextExtractionDevice::new();
12105 dev.text_adjustment(120.0);
12106 dev.text_adjustment(140.0);
12107 assert!((dev.pending_tj_offset - 260.0).abs() < 1e-6);
12108 }
12109
12110 // --- TEX2 XY-Cut tests ---
12111
12112 #[test]
12113 fn xy_cut_header_body_footer_with_two_columns() {
12114 // Header and footer sit in the mid-x range that would
12115 // accidentally fall into a left-column bucket with a naive
12116 // vertical-first cut. The largest-gap-first rule plus the
12117 // alignment guard ensure header and footer bracket the
12118 // columnar body.
12119 let texts = block_texts(vec![
12120 span("HEADLINE TITLE", 180.0, 760.0, 120.0),
12121 span("Left col line A", 40.0, 700.0, 110.0),
12122 span("Right col line A", 320.0, 700.0, 115.0),
12123 span("Left col line B", 40.0, 684.0, 110.0),
12124 span("Right col line B", 320.0, 684.0, 115.0),
12125 span("Left col line C", 40.0, 668.0, 110.0),
12126 span("Right col line C", 320.0, 668.0, 115.0),
12127 span("FOOTER LINE TEXT", 180.0, 600.0, 120.0),
12128 ]);
12129 assert_eq!(texts.first().map(String::as_str), Some("HEADLINE TITLE"));
12130 assert_eq!(texts.last().map(String::as_str), Some("FOOTER LINE TEXT"));
12131 // Left column lines all come before right column lines.
12132 let left_c_idx = texts.iter().position(|s| s == "Left col line C").unwrap();
12133 let right_a_idx = texts.iter().position(|s| s == "Right col line A").unwrap();
12134 assert!(
12135 left_c_idx < right_a_idx,
12136 "expected column-major ordering in body: {texts:?}"
12137 );
12138 }
12139
12140 #[test]
12141 fn xy_cut_rejects_column_split_on_table_rows() {
12142 // The density guard must still reject the 280pt inter-cell gap
12143 // in a short-cell table, preserving row-major reading order.
12144 let texts = block_texts(vec![
12145 span("Name", 40.0, 700.0, 30.0),
12146 span("Age", 320.0, 700.0, 20.0),
12147 span("Alice", 40.0, 684.0, 35.0),
12148 span("30", 320.0, 684.0, 15.0),
12149 ]);
12150 assert_eq!(texts, vec!["Name Age", "Alice 30"]);
12151 }
12152
12153 #[test]
12154 fn xy_cut_rejects_column_split_when_one_band_is_full_width() {
12155 // The alignment guard catches a full-width paragraph that
12156 // would otherwise be forced into the left column of a 2-column
12157 // region below it.
12158 let texts = block_texts(vec![
12159 span(
12160 "Full width intro spanning both columns here",
12161 40.0,
12162 740.0,
12163 360.0,
12164 ),
12165 span("Left A", 40.0, 700.0, 50.0),
12166 span("Right A", 320.0, 700.0, 50.0),
12167 span("Left B", 40.0, 684.0, 50.0),
12168 span("Right B", 320.0, 684.0, 50.0),
12169 ]);
12170 assert!(
12171 texts[0].contains("Full width intro"),
12172 "expected full-width intro first: {texts:?}"
12173 );
12174 }
12175
12176 #[test]
12177 fn xy_cut_horizontal_split_for_zone_boundaries() {
12178 // Pure horizontal cut on a single-column page with a big
12179 // vertical gap between paragraphs — the cut fires and both
12180 // paragraphs stay in their own blocks.
12181 let texts = block_texts(vec![
12182 span("First paragraph body text", 40.0, 740.0, 200.0),
12183 span("Second paragraph body", 40.0, 680.0, 180.0),
12184 ]);
12185 assert_eq!(texts.len(), 2);
12186 assert!(texts[0].starts_with("First"));
12187 assert!(texts[1].starts_with("Second"));
12188 }
12189
12190 #[test]
12191 fn xy_cut_recursion_terminates_with_single_span() {
12192 let texts = block_texts(vec![span("Only one span on the page", 40.0, 700.0, 180.0)]);
12193 assert_eq!(texts, vec!["Only one span on the page"]);
12194 }
12195
12196 #[test]
12197 fn median_font_size_handles_mixed_sizes() {
12198 let spans = vec![
12199 TextSpan {
12200 text: "small".into(),
12201 width: 10.0,
12202 height: 8.0,
12203 font_size: 8.0,
12204 ..TextSpan::default()
12205 },
12206 TextSpan {
12207 text: "medium".into(),
12208 width: 10.0,
12209 height: 12.0,
12210 font_size: 12.0,
12211 ..TextSpan::default()
12212 },
12213 TextSpan {
12214 text: "large".into(),
12215 width: 10.0,
12216 height: 24.0,
12217 font_size: 24.0,
12218 ..TextSpan::default()
12219 },
12220 ];
12221 assert!((median_font_size(&spans) - 12.0).abs() < 1e-9);
12222 }
12223
12224 #[test]
12225 fn columns_band_aligned_accepts_aligned_columns() {
12226 let spans = vec![
12227 span("L1", 40.0, 700.0, 60.0),
12228 span("R1", 300.0, 700.0, 60.0),
12229 span("L2", 40.0, 684.0, 60.0),
12230 span("R2", 300.0, 684.0, 60.0),
12231 ];
12232 let stats = PageStats::from_spans(&spans);
12233 // cut_x between 100 and 300 → 200. Every band straddles the cut.
12234 assert!(columns_are_band_aligned(&spans, 200.0, 40.0, 360.0, &stats));
12235 }
12236
12237 #[test]
12238 fn columns_band_aligned_rejects_wide_single_side_band() {
12239 let spans = vec![
12240 span("Wide banner line across top", 40.0, 740.0, 280.0),
12241 span("L1", 40.0, 700.0, 60.0),
12242 span("R1", 300.0, 700.0, 60.0),
12243 ];
12244 let stats = PageStats::from_spans(&spans);
12245 // cut_x = 200. Banner only in left group (midpoint < 200). Width
12246 // exceeds 0.7 × left column width → rejected.
12247 assert!(!columns_are_band_aligned(
12248 &spans, 200.0, 40.0, 360.0, &stats
12249 ));
12250 }
12251
12252 #[test]
12253 fn page_stats_computes_median_values() {
12254 let spans = vec![
12255 span("one", 40.0, 700.0, 30.0),
12256 span("two", 40.0, 680.0, 30.0),
12257 span("three", 40.0, 660.0, 50.0),
12258 ];
12259 let stats = PageStats::from_spans(&spans);
12260 assert!((stats.median_font_size - 12.0).abs() < 1e-9);
12261 // char width = width / chars. one=30/3=10, two=30/3=10, three=50/5=10. median=10.
12262 assert!((stats.median_char_width - 10.0).abs() < 1e-9);
12263 // line spacing: bands at 700, 680, 660. gaps = 20, 20. median = 20.
12264 assert!((stats.median_line_spacing - 20.0).abs() < 1e-9);
12265 }
12266
12267 #[test]
12268 fn page_stats_handles_empty_input() {
12269 let stats = PageStats::from_spans(&[]);
12270 assert!((stats.median_font_size - 12.0).abs() < 1e-9);
12271 assert!((stats.median_char_width - 6.0).abs() < 1e-9);
12272 assert_eq!(stats.median_line_spacing, 0.0);
12273 }
12274
12275 #[test]
12276 fn narrow_gutter_detected_with_adaptive_threshold() {
12277 // Academic paper layout: 12pt gutter between columns.
12278 // With old fixed 20pt threshold, this was not detected as columnar.
12279 // With adaptive: median word gap ~4pt, threshold = 12pt → detects 12pt gutter.
12280 let mut spans = Vec::new();
12281 for y in [700.0, 684.0, 668.0] {
12282 // Left column: two words with 4pt gap, ending at x=145
12283 spans.push(span("Lorem ipsum", 40.0, y, 100.0));
12284 spans.push(span("dolor sit", 144.0, y, 80.0));
12285 // Right column starts at 236 (gap = 12pt from 224)
12286 spans.push(span("amet consec", 236.0, y, 100.0));
12287 spans.push(span("tetur adipi", 340.0, y, 80.0));
12288 }
12289 let texts = block_texts(spans);
12290 // Should detect 2-column layout and read column-major
12291 assert!(
12292 texts.len() >= 6,
12293 "expected column-major output, got {texts:?}"
12294 );
12295 // First three blocks should be left column lines
12296 assert!(
12297 texts[0].contains("Lorem"),
12298 "first block should be left column: {texts:?}"
12299 );
12300 }
12301
12302 /// One table whose rows are grouped under labels is not cut between the
12303 /// groups (#596).
12304 ///
12305 /// The shape is the tuning set's commonest table: a label column whose cell is
12306 /// filled on the first row of a group and empty under it, with a little more
12307 /// white between the groups than between the rows. The threshold of
12308 /// `try_horizontal_cut` is a multiple of the ROW pitch, so the gap between
12309 /// groups clears it and the table is cut into one region per group -- on
12310 /// `PMC3037919-p0007` that is twelve tables where the page holds one.
12311 ///
12312 /// Red without the guard: `try_horizontal_cut` returns a cut.
12313 #[test]
12314 fn one_table_is_not_cut_between_its_label_groups() {
12315 // Four columns, with gaps wide enough to separate runs, and a label column
12316 // that is empty on every row but the first of each group.
12317 let row = |label: &str, second: &str, third: &str, fourth: &str, y: f64| {
12318 let mut spans = Vec::new();
12319 if !label.is_empty() {
12320 spans.push(span(label, 72.0, y, 58.0));
12321 }
12322 spans.push(span(second, 160.0, y, 90.0));
12323 spans.push(span(third, 300.0, y, 30.0));
12324 spans.push(span(fourth, 360.0, y, 40.0));
12325 spans
12326 };
12327 let mut spans = Vec::new();
12328 spans.extend(row("Study Design", "Case control", "7", "25.93", 700.0));
12329 spans.extend(row("", "Cohort", "9", "33.33", 688.0));
12330 spans.extend(row("", "Cross-sectional", "11", "40.74", 676.0));
12331 // 24 points to the next group, against a 12 point row pitch.
12332 spans.extend(row("Gender studied", "Both", "21", "77.78", 652.0));
12333 spans.extend(row("", "Men", "3", "11.11", 640.0));
12334
12335 let stats = PageStats::from_spans(&spans);
12336 let bands = group_spans_into_bands(spans.clone());
12337 let cut_y = (676.0 + 652.0) * 0.5;
12338 let gap = 676.0 - 652.0;
12339
12340 // The premises, so the fixture cannot quietly stop being the defect: the
12341 // gap clears the unguarded threshold, and the leading floor of #580 does
12342 // not already refuse it.
12343 let unguarded = stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER;
12344 assert!(
12345 gap >= unguarded,
12346 "the gap {gap} does not clear the unguarded threshold {unguarded}, so \
12347 this fixture would pass without the guard"
12348 );
12349 let floor = leading_of_a_region(&spans, &stats) * HORIZONTAL_CUT_LEADING_FLOOR;
12350 assert!(
12351 gap >= floor,
12352 "the #580 floor {floor} already refuses this gap {gap}, so the guard \
12353 is not what this test measures"
12354 );
12355
12356 assert!(
12357 the_cut_is_inside_a_grid(&bands, cut_y, gap),
12358 "the bands either side of the cut were not read as one grid"
12359 );
12360 assert!(
12361 try_horizontal_cut(&spans, &stats).is_none(),
12362 "the table was cut between its label groups"
12363 );
12364 }
12365
12366 /// Three one-line zones, equally spaced, are not a leading (codex on #580,
12367 /// round two).
12368 ///
12369 /// Their two gaps are every gap the region has, so counting occurrences
12370 /// cannot tell them from three lines of a paragraph. What can is the left
12371 /// edge: lines set together are flush, and a masthead over a centred title
12372 /// over an indented byline are not.
12373 ///
12374 /// Red without the alignment filter: the zone gap becomes the leading, the
12375 /// floor rises above it and both of the region's cuts are refused.
12376 #[test]
12377 fn three_zones_equally_spaced_are_not_a_leading() {
12378 let stats = PageStats::from_spans(&[span("body", 72.0, 700.0, 100.0)]);
12379 let em = stats.median_font_size;
12380 // Equally spaced by 20pt, which is inside [0.8, 3.0] ems of a 12pt line.
12381 let zones = vec![
12382 span("MASTHEAD", 72.0, 700.0, 120.0),
12383 span("A centred title", 200.0, 680.0, 120.0),
12384 span("by a named author", 140.0, 660.0, 120.0),
12385 ];
12386 assert!(
12387 20.0 >= LEADING_MIN_EM * em && 20.0 <= LEADING_MAX_EM * em,
12388 "the fixture's gap is outside the bounds, so it never reaches the rule"
12389 );
12390 assert_eq!(
12391 leading_of_a_region(&zones, &stats),
12392 0.0,
12393 "three zones that are not flush were read as a leading"
12394 );
12395
12396 // And the same three steps DO count when the bands are flush, or the
12397 // filter would refuse every real paragraph as well.
12398 let lines = vec![
12399 span("the first line of a paragraph", 72.0, 700.0, 180.0),
12400 span("the second line of the same one", 72.0, 680.0, 180.0),
12401 span("and the third line of it", 72.0, 660.0, 150.0),
12402 ];
12403 assert_eq!(
12404 leading_of_a_region(&lines, &stats),
12405 20.0,
12406 "three flush lines did not yield their own leading"
12407 );
12408 }
12409
12410 /// A region of two bands has no leading, so the floor does not refuse its
12411 /// only cut (codex on #580).
12412 ///
12413 /// The shape has to be a two-band REGION on a page that is not two bands,
12414 /// because `PageStats` is page-wide: a page of two bands would put its only
12415 /// gap in `median_line_spacing` too and the threshold would refuse the cut
12416 /// with or without a floor. What the review describes is a page whose
12417 /// quartile is pulled down by dense content -- a table -- and a sparse region
12418 /// further down that the recursion reaches with two bands in it.
12419 ///
12420 /// Here the table sets the quartile to 6, so the threshold is 10.8; the
12421 /// widest gap on the page is the 82 points above the banner, so the first cut
12422 /// separates the table from everything below it and the region left over is
12423 /// the banner and one line of body, 25 points apart. Without the occurrence
12424 /// rule that 25 is "the leading" of a two-band region on a count of one, the
12425 /// floor rises to 26.25, and the only cut that region has is refused: the
12426 /// banner is glued to the body it is not part of.
12427 ///
12428 /// Red without the occurrence rule.
12429 #[test]
12430 fn two_bands_have_no_leading_so_the_only_cut_survives() {
12431 let mut spans = Vec::new();
12432 // Dense rows, to pull the page-wide quartile under the gap below.
12433 for (row, y) in [700.0, 694.0, 688.0, 682.0].iter().enumerate() {
12434 spans.push(span(["SWA", "BHM", "SBS", "VBS"][row], 72.0, *y, 30.0));
12435 spans.push(span("2011", 150.0, *y, 30.0));
12436 }
12437 spans.push(span("Federal Register banner", 72.0, 600.0, 260.0));
12438 spans.push(span(
12439 "the body of the notice begins here",
12440 72.0,
12441 575.0,
12442 200.0,
12443 ));
12444
12445 let stats = PageStats::from_spans(&spans);
12446 let region = vec![
12447 span("Federal Register banner", 72.0, 600.0, 260.0),
12448 span("the body of the notice begins here", 72.0, 575.0, 200.0),
12449 ];
12450 assert_eq!(
12451 leading_of_a_region(®ion, &stats),
12452 0.0,
12453 "a single gap was read as the leading of a two-band region"
12454 );
12455 // And the fixture is the review's case: the gap is inside the bounds, so
12456 // only the occurrence rule keeps it from becoming a floor above itself.
12457 let gap = 600.0 - 575.0;
12458 assert!(
12459 gap >= LEADING_MIN_EM * stats.median_font_size
12460 && gap <= LEADING_MAX_EM * stats.median_font_size,
12461 "the fixture's gap is refused by the bounds, so it would not reach \
12462 the occurrence rule"
12463 );
12464 assert!(
12465 gap > stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER,
12466 "the cut would not fire even unfloored, so this fixture proves nothing"
12467 );
12468
12469 let texts = block_texts(spans);
12470 assert!(
12471 texts.iter().any(|t| t == "Federal Register banner"),
12472 "the banner was glued to the body: {texts:?}"
12473 );
12474 }
12475
12476 /// A paragraph is not cut between its own lines because something tighter
12477 /// than the body is on the page too (#580).
12478 ///
12479 /// The shape is the one the tuning set is full of: a bank of table rows set
12480 /// on 6 points over a body set on 14. `median_line_spacing` is the FIRST
12481 /// QUARTILE of the band steps, so it lands on the table's pitch (6) and the
12482 /// paragraph-break threshold on 10.8 -- under the body's leading. Every line
12483 /// break then qualifies as a zone break, the recursion cuts the paragraph
12484 /// into one region per line, and `blocks::paragraphs` cannot put it back
12485 /// because its stretches are bounded per region.
12486 ///
12487 /// Red without the floor: the four lines come back as four blocks.
12488 #[test]
12489 fn a_paragraph_is_not_cut_at_its_own_leading_because_a_table_is_tighter() {
12490 fn line(text: &str, x: f64, y: f64) -> TextSpan {
12491 // The measured width kept equal to `estimated_width()` (font_size *
12492 // 0.5 * chars), so the line fills the measure by both readings and
12493 // the merge is deciding about the gap and nothing else.
12494 span(text, x, y, 6.0 * text.chars().count() as f64)
12495 }
12496
12497 let mut spans = Vec::new();
12498 // A bank of figures on a 6 point pitch: three columns, four rows.
12499 for (row, y) in [700.0, 694.0, 688.0, 682.0].iter().enumerate() {
12500 spans.push(line(["SWA", "BHM", "SBS", "VBS"][row], 72.0, *y));
12501 spans.push(line("2011", 150.0, *y));
12502 spans.push(line(["11820", "2618", "3504", "2215"][row], 230.0, *y));
12503 }
12504 // One paragraph of prose on a 14 point leading, every line but the last
12505 // filling the measure and none of them ending a sentence.
12506 let prose = [
12507 "the occurrence of brachygnathia inferior and hernias",
12508 "showed statistically highly significant differences be",
12509 "tween breeds and the most common defect was recorded",
12510 "in the flocks of the region.",
12511 ];
12512 for (text, y) in prose.iter().zip([640.0, 626.0, 612.0, 598.0]) {
12513 spans.push(line(text, 72.0, y));
12514 }
12515
12516 let stats = PageStats::from_spans(&spans);
12517 let leading = leading_of_a_region(&spans, &stats);
12518 assert!(
12519 stats.median_line_spacing < leading,
12520 "the fixture is not the defect: quartile {} is not under the leading {leading}",
12521 stats.median_line_spacing
12522 );
12523 assert!(
12524 stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER < leading,
12525 "the unfloored threshold {} is not under the leading {leading}, so this \
12526 fixture would pass without the fix",
12527 stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
12528 );
12529
12530 // `paragraph_blocks` and not `block_texts`: the merge is what this is
12531 // about, and `block_texts` stops at the bands.
12532 let texts: Vec<String> = paragraph_blocks(spans, &[], 0, 0, TableStrategy::Both)
12533 .into_iter()
12534 .map(|block| block.text)
12535 .collect();
12536 let whole = prose.join(" ");
12537 assert!(
12538 texts.iter().any(|block| block == &whole),
12539 "the paragraph was cut at its own leading: {texts:?}"
12540 );
12541 }
12542
12543 #[test]
12544 fn xy_cut_leaf_falls_back_to_legacy_columns_for_header_plus_three_columns() {
12545 let texts = block_texts(vec![
12546 span("73022", 45.0, 750.0, 70.0),
12547 span("Federal Register banner", 125.6, 750.0, 260.0),
12548 span("Left column line one", 45.0, 725.0, 140.0),
12549 span("Middle column line one", 222.0, 725.0, 140.0),
12550 span("Right column line one", 399.0, 725.0, 120.0),
12551 span("Left column line two", 45.0, 715.0, 140.0),
12552 span("Middle column line two", 210.0, 715.0, 152.0),
12553 span("Right column line two", 388.0, 715.0, 132.0),
12554 span("Left column line three", 45.0, 705.0, 140.0),
12555 span("Middle column line three", 235.0, 705.0, 135.0),
12556 span("Right column line three", 408.0, 705.0, 118.0),
12557 ]);
12558
12559 assert_eq!(
12560 texts,
12561 vec![
12562 "73022 Federal Register banner",
12563 "Left column line one",
12564 "Left column line two",
12565 "Left column line three",
12566 "Middle column line one",
12567 "Middle column line two",
12568 "Middle column line three",
12569 "Right column line one",
12570 "Right column line two",
12571 "Right column line three",
12572 ]
12573 );
12574 }
12575
12576 #[test]
12577 fn overlapping_fake_bold_spans_collapse_to_single_copy() {
12578 let texts = block_texts(vec![
12579 span("1 This is fakebold text.", 25.9, 785.3, 320.0),
12580 span("1 This is fakebold text.", 26.2, 785.3, 320.0),
12581 span("1 This is fakebold text.", 26.4, 785.3, 320.0),
12582 span("1 This is fakebold text.", 26.7, 785.3, 320.0),
12583 span("2 This is a fakebold", 27.0, 714.8, 142.0),
12584 span(" fakebold", 169.8, 714.8, 70.0),
12585 span(" fakebold", 170.1, 714.8, 70.0),
12586 span(" fakebold word.", 170.4, 714.8, 110.0),
12587 ]);
12588
12589 assert_eq!(
12590 texts,
12591 vec!["1 This is fakebold text.", "2 This is a fakebold word.",]
12592 );
12593 }
12594
12595 /// A span drawn at `degrees`, with the transform a text matrix at that
12596 /// angle composes and the origin the glyphs start from.
12597 fn turned_span(text: &str, degrees: f64, x: f64, y: f64, width: f64) -> TextSpan {
12598 let (sin, cos) = degrees.to_radians().sin_cos();
12599 let scale = 12.0 / 1000.0;
12600 TextSpan {
12601 transform: Some([cos * scale, sin * scale, -sin * scale, cos * scale, x, y]),
12602 ..span(text, x, y, width)
12603 }
12604 }
12605
12606 /// Two lines of one rotated block share the place the page's order held
12607 /// them, and they must come back in the order they are read in. Each is
12608 /// inserted at the same index, and an insertion there pushes the last one
12609 /// down -- so inserting them front to back would emit the block backwards
12610 /// (#435, review round 2).
12611 #[test]
12612 fn two_lines_at_one_place_come_back_in_reading_order() {
12613 // A 90-degree run reads up the page, and its next line is a leading
12614 // further along +x: text space moves down, and down is +x once the
12615 // matrix has turned a quarter turn.
12616 let mut spans = vec![span("the page itself", 100.0, 700.0, 80.0)];
12617 for (index, text) in ["first", "second"].iter().enumerate() {
12618 let x = 60.0 + 14.0 * index as f64;
12619 spans.push(turned_span(text, 90.0, x, 200.0, 30.0));
12620 }
12621 let blocks = vec![TextBlock { spans }];
12622
12623 let out = reassemble_cross_direction_runs(blocks);
12624
12625 let lines: Vec<String> = out.iter().map(|block| block.text()).collect();
12626 assert_eq!(
12627 lines,
12628 vec!["first", "second", "the page itself"],
12629 "a rotated block came back with its lines the wrong way round"
12630 );
12631 }
12632
12633 /// The page's direction is weighed in glyphs, and a span holds more
12634 /// characters than glyphs whenever a glyph decodes to several of them or
12635 /// the space consensus put a space between two -- neither of which was
12636 /// drawn. Counting characters lets a minority direction outvote the page's
12637 /// own (#435, review round 2).
12638 #[test]
12639 fn the_pages_direction_is_weighed_in_glyphs_and_not_characters() {
12640 // Eight characters drawn as two glyphs: a pair of ligatures.
12641 let mut upright = span("fifffifl", 72.0, 700.0, 24.0);
12642 upright.char_bounds = vec![[0.0; 4]; 2];
12643 // Five characters drawn as five glyphs.
12644 let mut turned = turned_span("TURNS", 90.0, 300.0, 300.0, 30.0);
12645 turned.char_bounds = vec![[0.0; 4]; 5];
12646
12647 let direction = page_direction(&[TextBlock {
12648 spans: vec![upright, turned],
12649 }])
12650 .expect("a direction");
12651
12652 assert!(
12653 (direction.1).abs() > 0.5,
12654 "two ligature glyphs outvoted five drawn ones, so the count is of characters"
12655 );
12656 }
12657
12658 /// A run drawn at an angle stops where its last glyph stops, and that is
12659 /// further along its baseline than its page-space width says: a 30-degree
12660 /// run advances `cos 30` of its length in x. Measuring the gap to the next
12661 /// span from `x + width` leaves an eighth of the run as phantom gap, which
12662 /// at a style boundary in the middle of a word puts a space there (#435,
12663 /// review round 1).
12664 #[test]
12665 fn a_turned_run_ends_where_its_last_glyph_ends() {
12666 const DEGREES: f64 = 30.0;
12667 const ADVANCE: f64 = 7.2; // Courier at 12 pt
12668 let (sin, cos) = DEGREES.to_radians().sin_cos();
12669
12670 // "HELLO", five glyphs from (100, 400) along the 30-degree baseline,
12671 // as `draw_glyph` accumulates them: page-space bounds per glyph, and a
12672 // width that is the widest reach in page x.
12673 let mut hello = turned_span("HELLO", DEGREES, 100.0, 400.0, 0.0);
12674 for index in 0..5 {
12675 let x = 100.0 + cos * ADVANCE * index as f64;
12676 let y = 400.0 + sin * ADVANCE * index as f64;
12677 hello.char_bounds.push([x, y, x + ADVANCE, y + 12.0]);
12678 hello.width = hello.width.max(x + ADVANCE - 100.0);
12679 }
12680 // "WORLD" begins exactly where the fifth glyph's advance ended.
12681 let world = turned_span(
12682 "WORLD",
12683 DEGREES,
12684 100.0 + cos * ADVANCE * 5.0,
12685 400.0 + sin * ADVANCE * 5.0,
12686 ADVANCE * 5.0,
12687 );
12688
12689 let block = TextBlock {
12690 spans: vec![hello, world],
12691 };
12692 assert_eq!(
12693 block.text(),
12694 "HELLOWORLD",
12695 "a space was invented at a seam the document draws closed"
12696 );
12697 }
12698
12699 /// A page written entirely at an angle is a page of upright text seen
12700 /// turned, and its own bands read it correctly. Nothing is taken out of
12701 /// them: the direction most of the glyphs are drawn in IS the page's,
12702 /// whatever that direction is (#435).
12703 #[test]
12704 fn a_page_written_entirely_at_an_angle_keeps_its_own_bands() {
12705 let blocks = vec![
12706 TextBlock {
12707 spans: vec![
12708 turned_span("first line", 90.0, 100.0, 100.0, 60.0),
12709 turned_span("carried on", 90.0, 100.0, 160.0, 60.0),
12710 ],
12711 },
12712 TextBlock {
12713 spans: vec![turned_span("second line", 90.0, 120.0, 100.0, 66.0)],
12714 },
12715 ];
12716
12717 let out = reassemble_cross_direction_runs(blocks.clone());
12718
12719 let shape: Vec<Vec<&str>> = out
12720 .iter()
12721 .map(|b| b.spans.iter().map(|s| s.text.as_str()).collect())
12722 .collect();
12723 assert_eq!(
12724 shape,
12725 vec![vec!["first line", "carried on"], vec!["second line"]],
12726 "a page in one direction was rearranged by a rule that is about two"
12727 );
12728 }
12729
12730 /// Turned glyphs on one baseline come back as one span, ordered along the
12731 /// baseline with a word space where the gap is one (#574).
12732 #[test]
12733 fn turned_glyphs_on_one_baseline_merge_into_one_span() {
12734 // `AB CD` at 90 degrees: the glyphs climb the page one span each,
12735 // with a word gap between the words wide enough to part the run at
12736 // the cut, which is what merges it.
12737 let spans = vec![
12738 turned_span("A", 90.0, 100.0, 100.0, 7.0),
12739 turned_span("B", 90.0, 100.0, 107.0, 7.0),
12740 turned_span("C", 90.0, 100.0, 128.0, 7.0),
12741 turned_span("D", 90.0, 100.0, 135.0, 7.0),
12742 ];
12743 let out = merge_rotated_runs(spans);
12744 assert_eq!(out.len(), 1, "the run was not joined: {out:?}");
12745 assert_eq!(out[0].text, "AB CD");
12746 assert_eq!((out[0].x, out[0].y), (100.0, 100.0));
12747 }
12748
12749 /// Two runs that share a baseline but stand more than a word gap apart
12750 /// stay two (#574). Each piece earns its own merge: the tight pair stays
12751 /// as drawn under the lone-tight rule, exactly as if it stood alone.
12752 #[test]
12753 fn turned_runs_split_where_the_white_is_wider_than_any_word_gap() {
12754 let spans = vec![
12755 turned_span("A", 90.0, 100.0, 100.0, 7.0),
12756 turned_span("B", 90.0, 100.0, 107.0, 7.0),
12757 turned_span("C", 90.0, 100.0, 127.0, 7.0),
12758 turned_span("D", 90.0, 100.0, 180.0, 7.0),
12759 turned_span("E", 90.0, 100.0, 187.0, 7.0),
12760 ];
12761 let out = merge_rotated_runs(spans);
12762 let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12763 assert_eq!(texts, vec!["AB C", "D", "E"]);
12764 }
12765
12766 /// A style change still ends a turned run, the way it ends a span in the
12767 /// device: a bold word inside one keeps its face (#574).
12768 #[test]
12769 fn a_bold_word_inside_a_turned_run_keeps_its_own_span() {
12770 let lawful = turned_span("A", 90.0, 100.0, 100.0, 7.0);
12771 let mut bold = turned_span("B", 90.0, 100.0, 107.0, 7.0);
12772 bold.is_bold = true;
12773 let out = merge_rotated_runs(vec![lawful, bold]);
12774 let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12775 assert_eq!(texts, vec!["A", "B"]);
12776 assert!(!out[0].is_bold && out[1].is_bold);
12777 }
12778
12779 /// A skew below ten degrees is not quarter-turned: its spans come back as
12780 /// they went in (#574).
12781 #[test]
12782 fn a_skew_below_ten_degrees_is_left_alone() {
12783 let spans = vec![
12784 turned_span("A", 3.0, 100.0, 100.0, 7.0),
12785 turned_span("B", 3.0, 107.0, 100.5, 7.0),
12786 ];
12787 assert!(!spans.iter().any(span_is_quarter_turned));
12788 let out = merge_rotated_runs(spans);
12789 assert_eq!(out.len(), 2);
12790 assert_eq!(out[0].text, "A");
12791 assert_eq!(out[1].text, "B");
12792 }
12793
12794 /// Upright spans are never candidates, however they stack (#574).
12795 #[test]
12796 fn upright_spans_come_back_untouched() {
12797 let spans = vec![
12798 span("upper", 100.0, 200.0, 35.0),
12799 span("lower", 100.0, 100.0, 35.0),
12800 ];
12801 let out = merge_rotated_runs(spans);
12802 assert_eq!(out.len(), 2);
12803 assert_eq!(out[0].text, "upper");
12804 assert_eq!(out[1].text, "lower");
12805 }
12806
12807 /// A turned span with no partner on its baseline keeps its place and its
12808 /// shape (#574).
12809 #[test]
12810 fn a_turned_span_with_no_partner_is_left_exactly_as_it_was() {
12811 let spans = vec![
12812 span("body", 72.0, 700.0, 30.0),
12813 turned_span("!", 90.0, 500.0, 300.0, 7.0),
12814 ];
12815 let out = merge_rotated_runs(spans);
12816 assert_eq!(out.len(), 2);
12817 assert_eq!(
12818 (out[1].text.as_str(), out[1].x, out[1].y),
12819 ("!", 500.0, 300.0)
12820 );
12821 }
12822
12823 /// A turned span's right edge is measured, not estimated from its
12824 /// characters: the estimate runs along the baseline, which for a run
12825 /// stood up the page is not an x extent at all (#574).
12826 #[test]
12827 fn a_turned_spans_right_edge_is_measured() {
12828 let turned = turned_span("a whole head", 90.0, 100.0, 540.0, 7.0);
12829 assert_eq!(turned.right(), 107.0);
12830 let upright = span("a whole head", 100.0, 540.0, 72.0);
12831 assert_eq!(upright.right(), 172.0);
12832 }
12833
12834 /// A lone run with tight gaps is left as drawn: #435 already reads it
12835 /// forwards, and merging it only changes the shape downstream counts
12836 /// (#574).
12837 #[test]
12838 fn a_lone_tight_run_is_left_as_drawn() {
12839 let spans = vec![
12840 turned_span("A", 90.0, 100.0, 100.0, 7.0),
12841 turned_span("B", 90.0, 100.0, 107.0, 7.0),
12842 ];
12843 let out = merge_rotated_runs(spans);
12844 let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12845 assert_eq!(texts, vec!["A", "B"]);
12846 }
12847
12848 /// Capitals never join across the small digits set between them: a gap
12849 /// another turned run's glyph starts in is not a word gap of this run,
12850 /// however word-wide it is (#574: an axis label's `Delta Omega 01 W`).
12851 #[test]
12852 fn capitals_do_not_join_across_digits_set_between_them() {
12853 let capitals = [
12854 turned_span("O", 90.0, 100.0, 100.0, 7.0),
12855 turned_span("W", 90.0, 100.0, 128.0, 7.0),
12856 ];
12857 let mut zero = turned_span("0", 90.0, 101.8, 110.0, 7.0);
12858 zero.font_name = Some("SmallDigits".to_string());
12859 let mut one = turned_span("1", 90.0, 101.8, 117.0, 7.0);
12860 one.font_name = Some("SmallDigits".to_string());
12861 // The capitals 21 points apart would merge on the gap alone; the
12862 // digits starting inside it part them instead.
12863 let out = merge_rotated_runs(vec![capitals[0].clone(), zero, one, capitals[1].clone()]);
12864 let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12865 assert_eq!(texts, vec!["O", "0", "1", "W"]);
12866 // And the control: nothing between the capitals, the run joins.
12867 let out = merge_rotated_runs(capitals.to_vec());
12868 assert_eq!(out.len(), 1, "the control run did not join: {out:?}");
12869 assert_eq!(out[0].text, "O W");
12870 }
12871
12872 /// Parts of one run split by a style change are consecutive, not beside
12873 /// each other: their reading-first baselines stand an advance apart, so
12874 /// neither is the other's sibling and both stay as drawn (#574).
12875 #[test]
12876 fn style_split_parts_of_one_run_are_not_siblings() {
12877 let mut bold_a = turned_span("A", 90.0, 100.0, 100.0, 7.0);
12878 bold_a.is_bold = true;
12879 let mut bold_b = turned_span("B", 90.0, 100.0, 107.0, 7.0);
12880 bold_b.is_bold = true;
12881 let spans = vec![
12882 bold_a,
12883 bold_b,
12884 turned_span("C", 90.0, 100.0, 114.0, 7.0),
12885 turned_span("D", 90.0, 100.0, 121.0, 7.0),
12886 ];
12887 let out = merge_rotated_runs(spans);
12888 let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12889 assert_eq!(texts, vec!["A", "B", "C", "D"]);
12890 }
12891
12892 /// A run with a gap on the paragraph-break scale merges: the cut would
12893 /// part it there, and the pieces come back in page order (#574).
12894 #[test]
12895 fn a_run_with_a_cut_scale_gap_merges() {
12896 let spans = vec![
12897 turned_span("A", 90.0, 100.0, 100.0, 7.0),
12898 turned_span("B", 90.0, 100.0, 130.0, 7.0),
12899 ];
12900 let out = merge_rotated_runs(spans);
12901 assert_eq!(out.len(), 1);
12902 assert_eq!(out[0].text, "A B");
12903 }
12904
12905 /// Two turned runs beside each other merge: the grid would row them
12906 /// together, one-glyph cells across (#574). Two stacked runs, whose
12907 /// reading-first baselines stand rows apart, stay as drawn.
12908 #[test]
12909 fn sibling_runs_beside_each_other_merge() {
12910 let beside = vec![
12911 turned_span("A", 90.0, 100.0, 100.0, 7.0),
12912 turned_span("B", 90.0, 100.0, 107.0, 7.0),
12913 turned_span("C", 90.0, 200.0, 100.0, 7.0),
12914 turned_span("D", 90.0, 200.0, 107.0, 7.0),
12915 ];
12916 let out = merge_rotated_runs(beside);
12917 let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12918 assert_eq!(texts, vec!["AB", "CD"]);
12919
12920 let stacked = vec![
12921 turned_span("A", 90.0, 100.0, 100.0, 7.0),
12922 turned_span("B", 90.0, 100.0, 107.0, 7.0),
12923 turned_span("C", 90.0, 100.0, 200.0, 7.0),
12924 turned_span("D", 90.0, 100.0, 207.0, 7.0),
12925 ];
12926 let out = merge_rotated_runs(stacked);
12927 let texts: Vec<&str> = out.iter().map(|span| span.text.as_str()).collect();
12928 assert_eq!(texts, vec!["A", "B", "C", "D"]);
12929 }
12930
12931 /// The page's direction is the one most of its GLYPHS are drawn in, and a
12932 /// tie goes to the direction that was drawn first -- a rule the answer to
12933 /// which must not depend on which way an iterator happens to walk (#435).
12934 #[test]
12935 fn the_pages_direction_is_the_heaviest_and_a_tie_goes_to_the_first() {
12936 let upright = TextBlock {
12937 spans: vec![span("eight ch", 0.0, 700.0, 48.0)],
12938 };
12939 let turned = TextBlock {
12940 spans: vec![turned_span("ninety deg", 90.0, 300.0, 300.0, 60.0)],
12941 };
12942
12943 let heavier = page_direction(&[upright.clone(), turned.clone()]).expect("a direction");
12944 assert!(
12945 (heavier.1).abs() > 0.5,
12946 "ten glyphs at ninety degrees outweigh eight upright ones, and did not"
12947 );
12948
12949 let tie = TextBlock {
12950 spans: vec![span("ninety deg", 0.0, 700.0, 60.0)],
12951 };
12952 let first = page_direction(&[tie, turned]).expect("a direction");
12953 assert!(
12954 (first.1).abs() < 0.5,
12955 "a tie went to the direction that was not drawn first"
12956 );
12957 }
12958
12959 /// A page whose every line runs up it is read in the frame it was written
12960 /// in. Its bands lie across its lines, so without the turn the two lines
12961 /// share one band and come back as one (#546).
12962 #[test]
12963 fn a_turned_page_is_read_in_the_frame_it_was_written_in() {
12964 // A quarter-turned page reads up the page, and its next line is a
12965 // leading further along +x: text space moves down, and down is +x once
12966 // the matrix has turned a quarter turn.
12967 let spans: Vec<TextSpan> = ["the first line", "the second line"]
12968 .iter()
12969 .enumerate()
12970 .map(|(index, text)| turned_span(text, 90.0, 100.0 + 16.0 * index as f64, 100.0, 80.0))
12971 .collect();
12972
12973 let lines = block_texts(spans);
12974
12975 assert_eq!(
12976 lines,
12977 vec!["the first line", "the second line"],
12978 "the lines of a turned page were read in the bands of the frame it is drawn in"
12979 );
12980 }
12981
12982 /// The turn into the page's own frame and the turn back move no coordinate
12983 /// by a single bit: a quarter turn's sine and cosine are 0 and ±1, so what
12984 /// a caller reads off a span of a turned page is the page's own number and
12985 /// not a rounding of it (#546).
12986 #[test]
12987 fn the_turn_into_the_frame_and_back_leaves_every_number_as_it_was() {
12988 // Courier at 12 pt, and a run of six glyphs up the page.
12989 const ADVANCE: f64 = 7.2;
12990 const X: f64 = 123.75;
12991 const Y: f64 = 456.5;
12992 // The width is what `draw_glyph` accumulates: the furthest a glyph
12993 // reaches in page x, which for a quarter turn is one advance however
12994 // long the run is. Written as that subtraction and not as the advance,
12995 // because `x + advance - x` is not `advance` in binary and the number
12996 // the caller gets back is the number the device measured.
12997 let mut original = turned_span("TURNED", 90.0, X, Y, (X + ADVANCE) - X);
12998 for index in 0..6 {
12999 let y = Y + ADVANCE * index as f64;
13000 original.char_bounds.push([X, y, X + ADVANCE, y + 12.0]);
13001 }
13002
13003 for turn in [PageTurn::Quarter, PageTurn::Half, PageTurn::ThreeQuarter] {
13004 let spans = turn.into_frame(vec![original.clone()]);
13005 let blocks = turn.back(vec![TextBlock { spans }]);
13006 let span = &blocks[0].spans[0];
13007
13008 assert_eq!(
13009 (span.x, span.y),
13010 (original.x, original.y),
13011 "{turn:?} moved the span's origin"
13012 );
13013 assert_eq!(
13014 span.transform, original.transform,
13015 "{turn:?} left the baseline turned"
13016 );
13017 assert_eq!(
13018 span.char_bounds, original.char_bounds,
13019 "{turn:?} moved a glyph bound"
13020 );
13021 assert_eq!(
13022 span.width, original.width,
13023 "{turn:?} left a frame width behind"
13024 );
13025 }
13026 }
13027
13028 /// Only a page written in a quarter turn is turned: an upright one, a page
13029 /// drawn entirely at a free angle, and a page whose turned text is the
13030 /// minority all keep the bands they have (#546).
13031 #[test]
13032 fn a_page_is_turned_by_a_quarter_turn_or_by_nothing() {
13033 let cases = [
13034 (0.0, PageTurn::None),
13035 (90.0, PageTurn::Quarter),
13036 (180.0, PageTurn::Half),
13037 (270.0, PageTurn::ThreeQuarter),
13038 (45.0, PageTurn::None),
13039 (-52.0, PageTurn::None),
13040 ];
13041 for (degrees, expected) in cases {
13042 let spans = vec![turned_span("a line of it", degrees, 100.0, 100.0, 72.0)];
13043 assert_eq!(
13044 PageTurn::of(&spans),
13045 expected,
13046 "a page written at {degrees} degrees"
13047 );
13048 }
13049
13050 // The watermark of #435: turned, and outnumbered by the page it is on.
13051 let page = vec![
13052 span(
13053 "the body of the page is written upright",
13054 72.0,
13055 700.0,
13056 230.0,
13057 ),
13058 turned_span("SANDBOX", 90.0, 300.0, 200.0, 42.0),
13059 ];
13060 assert_eq!(
13061 PageTurn::of(&page),
13062 PageTurn::None,
13063 "a turned watermark turned the page it is drawn on"
13064 );
13065 }
13066
13067 /// A half-turned page keeps the ruling lines the page drew, because a half
13068 /// turn maps a horizontal line to a horizontal line; a quarter-turned page
13069 /// keeps none, because what the device recorded there runs the other way
13070 /// across the page (#546, review round 2).
13071 #[test]
13072 fn only_a_half_turn_keeps_the_pages_ruling_lines() {
13073 let rule = HorizontalRule {
13074 left: 72.0,
13075 right: 540.0,
13076 y: 400.0,
13077 };
13078
13079 let drawn = [rule];
13080 let upright = PageTurn::None.rules_in_frame(&drawn);
13081 assert_eq!(
13082 (
13083 upright.len(),
13084 upright[0].left,
13085 upright[0].right,
13086 upright[0].y
13087 ),
13088 (1, rule.left, rule.right, rule.y),
13089 "an upright page's rules were touched at all"
13090 );
13091
13092 let turned = PageTurn::Half.rules_in_frame(&drawn);
13093 let rotation = PageTurn::Half.rotation();
13094 let (left, y) = turn_point(rule.right, rule.y, rotation);
13095 let (right, _) = turn_point(rule.left, rule.y, rotation);
13096 assert_eq!(
13097 (turned.len(), turned[0].left, turned[0].right, turned[0].y),
13098 (1, left, right, y),
13099 "a half turn lost the page's rules, or left them where they were drawn"
13100 );
13101 assert!(
13102 turned[0].left < turned[0].right,
13103 "the ends of a mirrored rule did not swap, so it reads as empty"
13104 );
13105
13106 for turn in [PageTurn::Quarter, PageTurn::ThreeQuarter] {
13107 assert!(
13108 turn.rules_in_frame(&drawn).is_empty(),
13109 "{turn:?} kept a rule that runs across its reading direction"
13110 );
13111 }
13112 }
13113
13114 /// A page has to be WRITTEN in a direction before it is read in it: with
13115 /// its glyphs split four ways, as pdfbox `AcroFormsRotation` splits them
13116 /// over four blocks of form fields at the four rotations, the heaviest
13117 /// direction is a quarter of the page, and turning by it moves every block
13118 /// on a page that was never turned at all (#546).
13119 #[test]
13120 fn a_page_with_no_majority_direction_is_not_turned() {
13121 let quarter_each = vec![
13122 span("rotation zero", 72.0, 700.0, 78.0),
13123 turned_span("rotation ninety", 90.0, 200.0, 200.0, 90.0),
13124 turned_span("rotation oneeighty", 180.0, 400.0, 400.0, 108.0),
13125 turned_span("rotation twoseventy", 270.0, 500.0, 600.0, 114.0),
13126 ];
13127 assert_eq!(
13128 PageTurn::of(&quarter_each),
13129 PageTurn::None,
13130 "a plurality of a quarter of the glyphs turned the whole page"
13131 );
13132
13133 // The same page with the turned block carrying most of the glyphs is
13134 // a page written in that direction, and is read in it.
13135 let mut majority = quarter_each;
13136 majority[1] = turned_span(
13137 "a line of ninety degree text that is most of this page by a street",
13138 90.0,
13139 200.0,
13140 200.0,
13141 400.0,
13142 );
13143 assert_eq!(
13144 PageTurn::of(&majority),
13145 PageTurn::Quarter,
13146 "a direction carrying most of the page did not turn it"
13147 );
13148 }
13149
13150 /// A span drawn by a horizontal reflection, `[-1, 0, 0, 1, e, f]`. Its
13151 /// baseline runs in `(-1, 0)`, which is where a half turn puts one too --
13152 /// and its vertical axis is upright, which is where a half turn does not.
13153 fn mirrored_span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
13154 let scale = 12.0 / 1000.0;
13155 TextSpan {
13156 transform: Some([-scale, 0.0, 0.0, scale, x, y]),
13157 ..span(text, x, y, width)
13158 }
13159 }
13160
13161 /// A page drawn mirrored is not a page drawn upside down, and the baseline
13162 /// vector cannot tell them apart: both run in `(-1, 0)`. Turning it half a
13163 /// turn negates the vertical axis as well, so a block whose lines descend
13164 /// the page is read bottom to top -- an order invented from a page that was
13165 /// never turned. The determinant separates the two. (codex, review round 2)
13166 #[test]
13167 fn a_mirrored_page_is_not_a_half_turned_page() {
13168 let mirrored = vec![
13169 mirrored_span("the first line of a mirrored page", 400.0, 700.0, 300.0),
13170 mirrored_span("and the second line below it", 400.0, 686.0, 260.0),
13171 ];
13172 assert_eq!(
13173 PageTurn::of(&mirrored),
13174 PageTurn::None,
13175 "a reflection was read as a half turn, and turning it reverses the page"
13176 );
13177
13178 // The control, so the assertion above cannot pass by refusing every
13179 // page: the same two lines actually drawn upside down ARE a half turn.
13180 let half_turned: Vec<TextSpan> = vec![
13181 turned_span(
13182 "the first line of a turned page",
13183 180.0,
13184 400.0,
13185 700.0,
13186 300.0,
13187 ),
13188 turned_span("and the second line below it", 180.0, 400.0, 686.0, 260.0),
13189 ];
13190 assert_eq!(
13191 PageTurn::of(&half_turned),
13192 PageTurn::Half,
13193 "a page genuinely drawn at 180 degrees is no longer turned into its frame"
13194 );
13195
13196 // And a page that is mostly mirrored with an upright minority stays
13197 // untouched rather than being read in the minority's direction: the
13198 // share is of the whole page, not of the rotations on it.
13199 let mut mostly_mirrored = mirrored;
13200 mostly_mirrored.push(span("a short upright caption", 72.0, 100.0, 40.0));
13201 assert_eq!(
13202 PageTurn::of(&mostly_mirrored),
13203 PageTurn::None,
13204 "an upright minority carried the vote on a mirrored page"
13205 );
13206 }
13207
13208 /// The seam allowance of `second_run_carries_on_from_the_first` is one
13209 /// GLYPH of mean advance, and a glyph may decode to more than one
13210 /// character. Six characters drawn as five glyphs -- `office` with a `fi`
13211 /// ligature -- have a 6 pt allowance, where dividing the same advance by
13212 /// the six characters would leave 5 pt and reject a 5.5 pt seam that a
13213 /// re-drawn word arrives with (#432, review round 2).
13214 #[test]
13215 fn the_seam_allowance_counts_glyphs_and_not_characters() {
13216 let prev = span("first", 0.0, 700.0, 30.0);
13217 let mut curr = span("office", 24.5, 700.0, 30.0);
13218 curr.char_bounds = vec![[0.0; 4]; 5];
13219
13220 assert!(
13221 second_run_carries_on_from_the_first(&prev, &curr),
13222 "a ligature shrank the seam allowance to the character count"
13223 );
13224
13225 curr.char_bounds.clear();
13226 assert!(
13227 !second_run_carries_on_from_the_first(&prev, &curr),
13228 "the character count is the fallback, so it has to be the stricter one here"
13229 );
13230 }
13231
13232 // ---- G1: read-only metadata field tests ----
13233
13234 #[test]
13235 fn g1_default_text_span_has_empty_metadata() {
13236 let s = TextSpan::default();
13237 assert_eq!(s.font_name, None);
13238 assert!(!s.is_bold);
13239 assert!(!s.is_italic);
13240 assert_eq!(s.color, None);
13241 }
13242
13243 #[test]
13244 fn g1_strip_subset_prefix_handles_six_char_prefix() {
13245 assert_eq!(strip_subset_prefix("AAAAAA+Helvetica"), "Helvetica");
13246 // Non-6-char prefix → keep verbatim.
13247 assert_eq!(strip_subset_prefix("ABC+Helvetica"), "ABC+Helvetica");
13248 // No `+` → unchanged.
13249 assert_eq!(strip_subset_prefix("Helvetica-Bold"), "Helvetica-Bold");
13250 }
13251
13252 #[test]
13253 fn g1_name_style_hints_match_pdf_interpret_rules() {
13254 assert_eq!(name_style_hints("Helvetica-Bold"), (true, false));
13255 assert_eq!(name_style_hints("Times-Italic"), (false, true));
13256 assert_eq!(name_style_hints("MyFont-BoldOblique"), (true, true));
13257 assert_eq!(name_style_hints("Helvetica"), (false, false));
13258 // Semibold / Demi / Heavy / Black variants → bold.
13259 assert_eq!(name_style_hints("Roboto-DemiBold"), (true, false));
13260 assert_eq!(name_style_hints("Roboto-Black"), (true, false));
13261 // Oblique / slant variants → italic.
13262 assert_eq!(name_style_hints("Roboto-Oblique"), (false, true));
13263 assert_eq!(name_style_hints("MyFont-Slanted"), (false, true));
13264 }
13265
13266 // ---- G2: widthSource + char_bounds on TextSpan ----
13267
13268 #[test]
13269 fn g2_default_text_span_has_estimate_width_source() {
13270 let s = TextSpan::default();
13271 assert_eq!(s.width_source, WidthSource::Estimate);
13272 assert!(s.char_bounds.is_empty());
13273 }
13274
13275 /// Verify that a single-glyph span has exactly one char_bound entry
13276 /// and that the bound matches the span's x / width.
13277 #[test]
13278 fn g2_single_glyph_span_has_one_char_bound() {
13279 let s = TextSpan {
13280 text: "A".into(),
13281 x: 10.0,
13282 y: 100.0,
13283 width: 7.22,
13284 height: 10.0,
13285 font_size: 10.0,
13286 width_source: WidthSource::Metric,
13287 char_bounds: vec![[10.0, 100.0, 17.22, 110.0]],
13288 ..Default::default()
13289 };
13290
13291 assert_eq!(s.char_bounds.len(), 1);
13292 let [x0, y0, x1, y1] = s.char_bounds[0];
13293 assert!((x0 - 10.0).abs() < 0.001);
13294 assert!((x1 - 17.22).abs() < 0.001);
13295 assert!((y1 - y0 - s.font_size).abs() < 0.001);
13296 }
13297
13298 /// When merging glyphs into a span the width_source degrades to Estimate
13299 /// if any glyph was estimated.
13300 #[test]
13301 fn g2_merged_span_degrades_width_source_on_estimate() {
13302 let mut s = TextSpan {
13303 width_source: WidthSource::Metric,
13304 char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13305 ..Default::default()
13306 };
13307
13308 // Simulate what draw_glyph does on merge: push bound + downgrade.
13309 s.char_bounds.push([7.0, 0.0, 12.0, 10.0]);
13310 s.width_source = WidthSource::Estimate; // second glyph had no advance
13311
13312 assert_eq!(s.width_source, WidthSource::Estimate);
13313 assert_eq!(s.char_bounds.len(), 2);
13314 }
13315
13316 /// Verify WidthSource enum serialises to the two expected string literals.
13317 #[test]
13318 fn g2_width_source_variants_are_correct() {
13319 assert_eq!(format!("{:?}", WidthSource::Metric), "Metric");
13320 assert_eq!(format!("{:?}", WidthSource::Estimate), "Estimate");
13321 assert_ne!(WidthSource::Metric, WidthSource::Estimate);
13322 assert_eq!(WidthSource::default(), WidthSource::Estimate);
13323 }
13324
13325 // ---- M3: RichGeometry / tight glyph bounds tests ----
13326
13327 #[test]
13328 fn m3_default_text_span_has_basic_mode() {
13329 let s = TextSpan::default();
13330 assert_eq!(s.geometry_mode, GeometryMode::Basic);
13331 assert_eq!(s.bounds_source, BoundsSource::Advance);
13332 assert!(s.tight_char_bounds.is_empty());
13333 assert!(s.glyph_advances.is_empty());
13334 }
13335
13336 #[test]
13337 fn m3_geometry_mode_default_is_basic() {
13338 assert_eq!(GeometryMode::default(), GeometryMode::Basic);
13339 }
13340
13341 #[test]
13342 fn m3_bounds_source_default_is_advance() {
13343 assert_eq!(BoundsSource::default(), BoundsSource::Advance);
13344 }
13345
13346 #[test]
13347 fn m3_geometry_mode_variants_are_distinct() {
13348 assert_ne!(GeometryMode::Basic, GeometryMode::RichGeometry);
13349 assert_eq!(format!("{:?}", GeometryMode::Basic), "Basic");
13350 assert_eq!(format!("{:?}", GeometryMode::RichGeometry), "RichGeometry");
13351 }
13352
13353 #[test]
13354 fn m3_bounds_source_variants_are_distinct() {
13355 assert_ne!(BoundsSource::Advance, BoundsSource::Tight);
13356 assert_ne!(BoundsSource::Tight, BoundsSource::Estimate);
13357 assert_eq!(format!("{:?}", BoundsSource::Advance), "Advance");
13358 assert_eq!(format!("{:?}", BoundsSource::Tight), "Tight");
13359 assert_eq!(format!("{:?}", BoundsSource::Estimate), "Estimate");
13360 }
13361
13362 #[test]
13363 fn m3_text_span_rich_geometry_preserves_existing_fields() {
13364 let s = TextSpan {
13365 text: "Hello".into(),
13366 x: 10.0,
13367 y: 100.0,
13368 width: 30.0,
13369 height: 12.0,
13370 font_size: 12.0,
13371 font_name: Some("Helvetica".into()),
13372 is_bold: true,
13373 is_italic: false,
13374 color: Some([0, 0, 0, 255]),
13375 width_source: WidthSource::Metric,
13376 char_bounds: vec![[10.0, 100.0, 40.0, 112.0]],
13377 geometry_mode: GeometryMode::RichGeometry,
13378 bounds_source: BoundsSource::Tight,
13379 tight_char_bounds: vec![[8.5, 98.5, 41.5, 114.0]],
13380 glyph_advances: vec![30.0],
13381 glyph_bounds_sources: vec![BoundsSource::Tight],
13382 ..Default::default()
13383 };
13384
13385 // Existing fields preserved.
13386 assert_eq!(s.text, "Hello");
13387 assert!((s.x - 10.0).abs() < 0.001);
13388 assert!((s.y - 100.0).abs() < 0.001);
13389 assert_eq!(s.width_source, WidthSource::Metric);
13390 assert_eq!(s.char_bounds.len(), 1);
13391
13392 // M3 fields populated.
13393 assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
13394 assert_eq!(s.bounds_source, BoundsSource::Tight);
13395 assert_eq!(s.tight_char_bounds.len(), 1);
13396 assert_eq!(s.glyph_advances.len(), 1);
13397 assert!((s.glyph_advances[0] - 30.0).abs() < 0.001);
13398 }
13399
13400 #[test]
13401 fn m3_text_span_basic_mode_does_not_populate_tight_fields() {
13402 let s = TextSpan {
13403 text: "A".into(),
13404 x: 0.0,
13405 y: 0.0,
13406 width: 7.0,
13407 height: 10.0,
13408 font_size: 10.0,
13409 geometry_mode: GeometryMode::Basic,
13410 bounds_source: BoundsSource::Advance,
13411 ..Default::default()
13412 };
13413
13414 assert!(s.tight_char_bounds.is_empty());
13415 assert!(s.glyph_advances.is_empty());
13416 }
13417
13418 #[test]
13419 fn m3_tight_char_bounds_are_optional_in_basic_mode() {
13420 // A span from Basic mode should have empty tight fields.
13421 let s = TextSpan {
13422 text: "test".into(),
13423 x: 0.0,
13424 y: 0.0,
13425 width: 40.0,
13426 height: 12.0,
13427 font_size: 12.0,
13428 geometry_mode: GeometryMode::Basic,
13429 ..Default::default()
13430 };
13431 assert!(s.tight_char_bounds.is_empty());
13432 assert!(s.glyph_advances.is_empty());
13433 }
13434
13435 #[test]
13436 fn m3_test_span_constructor_preserves_all_fields() {
13437 // Verify the span constructor used in existing tests still works.
13438 let s = span("hello", 10.0, 100.0, 30.0);
13439 assert_eq!(s.geometry_mode, GeometryMode::Basic);
13440 assert_eq!(s.bounds_source, BoundsSource::Advance);
13441 assert!(s.tight_char_bounds.is_empty());
13442 }
13443
13444 #[test]
13445 fn m3_width_source_unchanged_by_m3() {
13446 // WidthSource should not be affected by RichGeometry.
13447 assert_eq!(WidthSource::Metric, WidthSource::Metric);
13448 assert_eq!(WidthSource::Estimate, WidthSource::Estimate);
13449 assert_eq!(WidthSource::default(), WidthSource::Estimate);
13450 }
13451
13452 #[test]
13453 fn m3_transform_bbox_corners_identity() {
13454 let bbox = [0.0, 0.0, 10.0, 12.0];
13455 let identity = kurbo::Affine::IDENTITY;
13456 let result = transform_bbox_corners(&bbox, &identity);
13457 assert!((result[0] - 0.0).abs() < 0.001);
13458 assert!((result[1] - 0.0).abs() < 0.001);
13459 assert!((result[2] - 10.0).abs() < 0.001);
13460 assert!((result[3] - 12.0).abs() < 0.001);
13461 }
13462
13463 #[test]
13464 fn m3_transform_bbox_corners_translation() {
13465 let bbox = [0.0, 0.0, 10.0, 12.0];
13466 let t = kurbo::Affine::translate((50.0, 100.0));
13467 let result = transform_bbox_corners(&bbox, &t);
13468 assert!((result[0] - 50.0).abs() < 0.001);
13469 assert!((result[1] - 100.0).abs() < 0.001);
13470 assert!((result[2] - 60.0).abs() < 0.001);
13471 assert!((result[3] - 112.0).abs() < 0.001);
13472 }
13473
13474 #[test]
13475 fn m3_transform_bbox_corners_scale() {
13476 let bbox = [0.0, 0.0, 10.0, 12.0];
13477 let s = kurbo::Affine::scale(2.0);
13478 let result = transform_bbox_corners(&bbox, &s);
13479 assert!((result[0] - 0.0).abs() < 0.001);
13480 assert!((result[1] - 0.0).abs() < 0.001);
13481 assert!((result[2] - 20.0).abs() < 0.001);
13482 assert!((result[3] - 24.0).abs() < 0.001);
13483 }
13484
13485 #[test]
13486 fn m3_transform_bbox_corners_with_negative_bbox() {
13487 // Glyphs with descenders have negative y0 in font space.
13488 let bbox = [-5.0, -200.0, 15.0, 800.0];
13489 let s = kurbo::Affine::scale(0.012); // 12pt font at 1000-upem
13490 let result = transform_bbox_corners(&bbox, &s);
13491 assert!((result[0] - (-0.06)).abs() < 0.01);
13492 assert!((result[1] - (-2.4)).abs() < 0.01);
13493 assert!((result[2] - 0.18).abs() < 0.01);
13494 assert!((result[3] - 9.6).abs() < 0.01);
13495 }
13496
13497 #[test]
13498 fn m3_tight_char_bounds_descender_detection() {
13499 // A glyph with a descender should have tight_char_bounds.y0 < origin.y0.
13500 let s = TextSpan {
13501 text: "g".into(),
13502 x: 100.0,
13503 y: 200.0, // baseline
13504 width: 8.0,
13505 height: 12.0,
13506 font_size: 12.0,
13507 geometry_mode: GeometryMode::RichGeometry,
13508 bounds_source: BoundsSource::Tight,
13509 tight_char_bounds: vec![[99.0, 197.5, 109.0, 210.0]],
13510 char_bounds: vec![[100.0, 200.0, 108.0, 212.0]],
13511 glyph_advances: vec![8.0],
13512 ..Default::default()
13513 };
13514
13515 // Tight y0 (197.5) < span y (200.0) — descender extends below baseline.
13516 assert!(
13517 s.tight_char_bounds[0][1] < s.y,
13518 "descender should extend below baseline"
13519 );
13520 }
13521
13522 #[test]
13523 fn m3_tight_char_bounds_ascender_detection() {
13524 // A glyph with an ascender should have tight_char_bounds.y1 > advance y1.
13525 let s = TextSpan {
13526 text: "f".into(),
13527 x: 100.0,
13528 y: 200.0,
13529 width: 7.0,
13530 height: 12.0,
13531 font_size: 12.0,
13532 geometry_mode: GeometryMode::RichGeometry,
13533 bounds_source: BoundsSource::Tight,
13534 tight_char_bounds: vec![[95.0, 200.0, 108.0, 214.0]],
13535 char_bounds: vec![[100.0, 200.0, 107.0, 212.0]],
13536 glyph_advances: vec![7.0],
13537 ..Default::default()
13538 };
13539
13540 // Tight y1 (214.0) > advance y1 (200.0 + 12.0 = 212.0).
13541 let advance_y1 = s.y + s.height;
13542 assert!(
13543 s.tight_char_bounds[0][3] > advance_y1,
13544 "ascender should extend above advance bounds"
13545 );
13546 }
13547
13548 #[test]
13549 fn m3_tight_char_bounds_differ_from_advance() {
13550 // Verify tight bounds produce a different bbox than advance bounds.
13551 let s = TextSpan {
13552 text: "A".into(),
13553 x: 10.0,
13554 y: 100.0,
13555 width: 7.22,
13556 height: 10.0,
13557 font_size: 10.0,
13558 geometry_mode: GeometryMode::RichGeometry,
13559 bounds_source: BoundsSource::Tight,
13560 char_bounds: vec![[10.0, 100.0, 17.22, 110.0]], // advance
13561 tight_char_bounds: vec![[9.5, 100.2, 17.5, 109.5]], // tight (different)
13562 glyph_advances: vec![7.22],
13563 ..Default::default()
13564 };
13565
13566 let ac = s.char_bounds[0];
13567 let tc = s.tight_char_bounds[0];
13568 let differs = (ac[0] - tc[0]).abs() > 0.01
13569 || (ac[1] - tc[1]).abs() > 0.01
13570 || (ac[2] - tc[2]).abs() > 0.01
13571 || (ac[3] - tc[3]).abs() > 0.01;
13572 assert!(differs, "tight bounds should differ from advance bounds");
13573 }
13574
13575 #[test]
13576 fn m3_device_new_uses_basic_mode() {
13577 let dev = TextExtractionDevice::new();
13578 assert_eq!(dev.geometry_mode, GeometryMode::Basic);
13579 assert!(dev.deferred_rich_glyphs.is_empty());
13580 }
13581
13582 #[test]
13583 fn m3_device_with_mode_rich_geometry() {
13584 let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13585 assert_eq!(dev.geometry_mode, GeometryMode::RichGeometry);
13586 assert!(dev.deferred_rich_glyphs.is_empty());
13587 }
13588
13589 /// A synthetic rectangular glyph outline in raw design space, plus its
13590 /// bbox. The renderer (`renderer.rs::fill_glyph`) fills this raw outline
13591 /// with `composed = transform * glyph_transform` and NO upem pre-scaling,
13592 /// so the page-space bbox of a tight glyph MUST equal
13593 /// `(composed * outline).bounding_box()`. These tests use that as the
13594 /// ground-truth oracle, which is what guards against scaling regressions.
13595 #[cfg(test)]
13596 fn synthetic_glyph_rect() -> (BezPath, kurbo::Rect) {
13597 let mut path = BezPath::new();
13598 path.move_to((100.0, 0.0));
13599 path.line_to((600.0, 0.0));
13600 path.line_to((600.0, 700.0));
13601 path.line_to((100.0, 700.0));
13602 path.close_path();
13603 let bbox = path.bounding_box();
13604 (path, bbox)
13605 }
13606
13607 #[test]
13608 fn m3_basic_mode_skips_tight_bounds_computation() {
13609 // Basic mode must NOT compute tight bounds, even when a span is present.
13610 let mut dev = TextExtractionDevice::new();
13611 dev.spans.push(TextSpan {
13612 text: "A".into(),
13613 char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13614 ..Default::default()
13615 });
13616 let spans = dev.into_spans();
13617 assert_eq!(spans.len(), 1);
13618 assert!(spans[0].tight_char_bounds.is_empty());
13619 assert!(spans[0].glyph_bounds_sources.is_empty());
13620 assert_eq!(spans[0].bounds_source, BoundsSource::Advance);
13621 }
13622
13623 #[test]
13624 fn m3_rich_geometry_tight_bounds_match_rendered_outline() {
13625 // Axis-aligned scale 0.01: the page-space tight bbox must equal the
13626 // bbox of the transformed outline — i.e. design [100,600]x[0,700]
13627 // scaled by 0.01 → [1.0, 0.0, 6.0, 7.0]. A double-scale regression
13628 // (e.g. an extra font_size/1000) would yield ~[0.01, 0, 0.06, 0.07].
13629 let (path, bbox) = synthetic_glyph_rect();
13630 let composed = Affine::scale(0.01);
13631 let oracle = (composed * path.clone()).bounding_box();
13632
13633 let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13634 dev.spans.push(TextSpan {
13635 text: "A".into(),
13636 char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13637 geometry_mode: GeometryMode::RichGeometry,
13638 ..Default::default()
13639 });
13640 dev.deferred_rich_glyphs.push(DeferredGlyph {
13641 coeffs: composed.as_coeffs(),
13642 font_size: 10.0,
13643 glyph_width: 7.0,
13644 needs_exact: false,
13645 outline: Some(path),
13646 font_bbox: Some(bbox),
13647 });
13648
13649 let spans = dev.into_spans();
13650 assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
13651 assert_eq!(spans[0].bounds_source, BoundsSource::Tight);
13652 let tb = spans[0].tight_char_bounds[0];
13653 assert!(
13654 (tb[0] - oracle.x0).abs() < 1e-6,
13655 "x0: got {} want {}",
13656 tb[0],
13657 oracle.x0
13658 );
13659 assert!(
13660 (tb[1] - oracle.y0).abs() < 1e-6,
13661 "y0: got {} want {}",
13662 tb[1],
13663 oracle.y0
13664 );
13665 assert!(
13666 (tb[2] - oracle.x1).abs() < 1e-6,
13667 "x1: got {} want {}",
13668 tb[2],
13669 oracle.x1
13670 );
13671 assert!(
13672 (tb[3] - oracle.y1).abs() < 1e-6,
13673 "y1: got {} want {}",
13674 tb[3],
13675 oracle.y1
13676 );
13677 // Sanity: tight height (~7) is commensurate with the advance font_size
13678 // (10), not ~100x smaller — the regression this test exists to catch.
13679 assert!(
13680 tb[3] - tb[1] > 1.0,
13681 "tight height must be commensurate with font size"
13682 );
13683 }
13684
13685 #[test]
13686 fn m3_rich_geometry_tight_bounds_rotation_is_exact() {
13687 // 90° rotation routes through the needs_exact (transform-then-bound)
13688 // path. Oracle = bbox of the rotated outline.
13689 let (path, bbox) = synthetic_glyph_rect();
13690 let composed = Affine::scale(0.01) * Affine::rotate(std::f64::consts::FRAC_PI_2);
13691 let oracle = (composed * path.clone()).bounding_box();
13692
13693 let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13694 dev.spans.push(TextSpan {
13695 text: "A".into(),
13696 char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
13697 geometry_mode: GeometryMode::RichGeometry,
13698 ..Default::default()
13699 });
13700 dev.deferred_rich_glyphs.push(DeferredGlyph {
13701 coeffs: composed.as_coeffs(),
13702 font_size: 10.0,
13703 glyph_width: 7.0,
13704 needs_exact: true,
13705 outline: Some(path),
13706 font_bbox: Some(bbox),
13707 });
13708
13709 let spans = dev.into_spans();
13710 assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
13711 let tb = spans[0].tight_char_bounds[0];
13712 assert!(
13713 (tb[0] - oracle.x0).abs() < 1e-6,
13714 "x0: got {} want {}",
13715 tb[0],
13716 oracle.x0
13717 );
13718 assert!(
13719 (tb[1] - oracle.y0).abs() < 1e-6,
13720 "y0: got {} want {}",
13721 tb[1],
13722 oracle.y0
13723 );
13724 assert!(
13725 (tb[2] - oracle.x1).abs() < 1e-6,
13726 "x1: got {} want {}",
13727 tb[2],
13728 oracle.x1
13729 );
13730 assert!(
13731 (tb[3] - oracle.y1).abs() < 1e-6,
13732 "y1: got {} want {}",
13733 tb[3],
13734 oracle.y1
13735 );
13736 }
13737
13738 #[test]
13739 fn m3_empty_device_rich_geometry_no_panic() {
13740 let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
13741 let text = dev.into_text();
13742 assert!(text.is_empty());
13743 }
13744
13745 #[test]
13746 fn m3_bounds_source_estimate_fallback() {
13747 // Verify that BoundsSource::Estimate can be set on a span.
13748 let s = TextSpan {
13749 geometry_mode: GeometryMode::RichGeometry,
13750 bounds_source: BoundsSource::Estimate,
13751 ..Default::default()
13752 };
13753 assert_eq!(s.bounds_source, BoundsSource::Estimate);
13754 assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
13755 // Fallback still has empty tight fields (no glyphs).
13756 assert!(s.tight_char_bounds.is_empty());
13757 }
13758
13759 /// One paragraph block of a single line, as the merge leaves a line of a
13760 /// double-spaced run: `top` is the top of its box, the box one em deep.
13761 fn line_block(text: &str, left: f64, top: f64, width: f64, size: f64) -> Block {
13762 let span = TextSpan {
13763 text: text.into(),
13764 x: left,
13765 y: top - size * 0.8,
13766 width,
13767 height: size,
13768 font_size: size,
13769 ..TextSpan::default()
13770 };
13771 Block {
13772 kind: BlockKind::Paragraph,
13773 page: 0,
13774 bbox: [left, top - size, left + width, top],
13775 text: text.into(),
13776 spans: vec![span],
13777 index: 0,
13778 continues_previous: false,
13779 confidence: None,
13780 grid: None,
13781 table_origin: TableOrigin::Other,
13782 trace_learned: None,
13783 }
13784 }
13785
13786 /// `texts` as single lines at `pitch`, all `size` on the same left edge
13787 /// and measure.
13788 fn run_of(texts: &[&str], pitch: f64, size: f64) -> Vec<Block> {
13789 texts
13790 .iter()
13791 .enumerate()
13792 .map(|(k, text)| line_block(text, 72.0, 700.0 - pitch * k as f64, 468.0, size))
13793 .collect()
13794 }
13795
13796 #[test]
13797 fn a_double_spaced_paragraph_is_one_paragraph() {
13798 fn line(text: &str, y: f64) -> TextSpan {
13799 // One measure for every line, so the join is deciding about the
13800 // gaps and nothing else.
13801 span(text, 72.0, y, 360.0)
13802 }
13803 let mut spans = Vec::new();
13804 // Single-spaced body: three lines on a 14 point pitch, one paragraph.
13805 for (text, y) in [
13806 "the committee met in ordinary session on tuesday morning",
13807 "and heard the report of the working group on procedure",
13808 "before adjourning for the midday recess.",
13809 ]
13810 .iter()
13811 .zip([700.0, 686.0, 672.0])
13812 {
13813 spans.push(line(text, y));
13814 }
13815 // Six full-width lines on a 24 point pitch: one paragraph set double,
13816 // with a word broken across its second line end.
13817 let run = [
13818 "the occurrence of brachygnathia inferior and hernias showed",
13819 "statistically highly significant differences between breeds and the line-",
13820 "breaks in the middle of a word join back together without a",
13821 "space while the other lines of the passage join with one plain",
13822 "space each so that the whole passage reads as the typesetter",
13823 "set it before the measure broke it into lines.",
13824 ];
13825 for (text, y) in run.iter().zip([630.0, 606.0, 582.0, 558.0, 534.0, 510.0]) {
13826 spans.push(line(text, y));
13827 }
13828 // The join runs after heading classification in the extract paths
13829 // (#636); here every block is still a paragraph, so it runs directly.
13830 let blocks =
13831 join_double_spaced_runs(paragraph_blocks(spans, &[], 0, 0, TableStrategy::Both), 0);
13832 assert_eq!(
13833 blocks.len(),
13834 2,
13835 "the run joined and the body stayed: {:?}",
13836 blocks.iter().map(|block| &block.text).collect::<Vec<_>>()
13837 );
13838 assert_eq!(blocks[0].index, 0);
13839 assert_eq!(blocks[1].index, 1);
13840 assert_eq!(
13841 blocks[1].text,
13842 "the occurrence of brachygnathia inferior and hernias showed statistically \
13843 highly significant differences between breeds and the linebreaks in the \
13844 middle of a word join back together without a space while the other lines \
13845 of the passage join with one plain space each so that the whole passage \
13846 reads as the typesetter set it before the measure broke it into lines."
13847 );
13848 assert_eq!(blocks[1].spans.len(), 6);
13849 assert!(
13850 blocks[1].bbox[1] < 510.0 && blocks[1].bbox[3] > 630.0,
13851 "the box covers the run: {:?}",
13852 blocks[1].bbox
13853 );
13854 }
13855
13856 #[test]
13857 fn a_one_line_list_on_wide_leading_stays_items() {
13858 let blocks = run_of(
13859 &[
13860 "• ripe red apples from the orchard",
13861 "• green pears from the walled garden",
13862 "• dark plums from the far paddock",
13863 "• yellow quinces from the old trees",
13864 "• blue sloes from the hedgerows",
13865 ],
13866 24.0,
13867 12.0,
13868 );
13869 let joined = join_double_spaced_runs(blocks, 0);
13870 assert_eq!(joined.len(), 5);
13871 }
13872
13873 #[test]
13874 fn an_address_block_stays_lines() {
13875 let blocks = run_of(
13876 &[
13877 "Jonathan Pryce",
13878 "12 Red Lion Square",
13879 "London WC1R 4QH",
13880 "United Kingdom",
13881 ],
13882 22.0,
13883 12.0,
13884 );
13885 let joined = join_double_spaced_runs(blocks, 0);
13886 assert_eq!(joined.len(), 4);
13887 }
13888
13889 #[test]
13890 fn label_and_value_lines_stay_apart() {
13891 let blocks = run_of(
13892 &["Ausfertigungsdatum: 20.12.1963", "Vollzitat:"],
13893 24.0,
13894 12.0,
13895 );
13896 let joined = join_double_spaced_runs(blocks, 3);
13897 assert_eq!(joined.len(), 2);
13898 assert_eq!(joined[0].index, 3);
13899 assert_eq!(joined[1].index, 4);
13900 }
13901
13902 #[test]
13903 fn contents_lines_stay_apart() {
13904 let blocks = run_of(
13905 &[
13906 "25 Independent Auditors' Reports",
13907 "26 Consolidated Statements",
13908 ],
13909 24.0,
13910 12.0,
13911 );
13912 let joined = join_double_spaced_runs(blocks, 0);
13913 assert_eq!(joined.len(), 2);
13914 }
13915
13916 #[test]
13917 fn two_paragraphs_on_one_pitch_stay_two() {
13918 let blocks = run_of(
13919 &[
13920 "the surveyors counted every head twice over the long winter.",
13921 "The year before the new regulations came into force was mild",
13922 ],
13923 24.0,
13924 12.0,
13925 );
13926 let joined = join_double_spaced_runs(blocks, 0);
13927 assert_eq!(joined.len(), 2);
13928 }
13929
13930 #[test]
13931 fn section_lines_stay_apart() {
13932 let blocks = run_of(
13933 &[
13934 "§ 2 Begriffe und Definitionen der in diesem Vertrag verwendeten Terminologie",
13935 "§ 3 Anwendungsbereich und Geltung dieser Bestimmungen im gesamten Inland",
13936 ],
13937 24.0,
13938 12.0,
13939 );
13940 let joined = join_double_spaced_runs(blocks, 0);
13941 assert_eq!(joined.len(), 2);
13942 }
13943
13944 #[test]
13945 fn article_lines_stay_apart() {
13946 let blocks = run_of(
13947 &[
13948 "Art. 2 Begriffe und Definitionen der in diesem Vertrag verwendeten Terminologie",
13949 "Art. 3 Anwendungsbereich und Geltung dieser Bestimmungen im gesamten Inland",
13950 ],
13951 24.0,
13952 12.0,
13953 );
13954 let joined = join_double_spaced_runs(blocks, 0);
13955 assert_eq!(joined.len(), 2);
13956 }
13957
13958 #[test]
13959 fn headings_never_join() {
13960 let mut blocks = run_of(
13961 &[
13962 "the occurrence of brachygnathia inferior and hernias showed",
13963 "statistically highly significant differences between breeds and the passage",
13964 ],
13965 24.0,
13966 12.0,
13967 );
13968 blocks[1].kind = BlockKind::Heading { level: 2 };
13969 let joined = join_double_spaced_runs(blocks, 0);
13970 assert_eq!(joined.len(), 2);
13971 }
13972
13973 #[test]
13974 fn m3_multiple_glyphs_tight_bounds_consistent_count() {
13975 // Simulate a span with 3 glyphs — tight_char_bounds and glyph_advances
13976 // should have the same length.
13977 let s = TextSpan {
13978 text: "abc".into(),
13979 x: 0.0,
13980 y: 0.0,
13981 width: 25.0,
13982 height: 12.0,
13983 font_size: 12.0,
13984 geometry_mode: GeometryMode::RichGeometry,
13985 bounds_source: BoundsSource::Tight,
13986 char_bounds: vec![[0., 0., 8., 12.], [8., 0., 16., 12.], [16., 0., 25., 12.]],
13987 tight_char_bounds: vec![
13988 [-1., -2., 9., 14.],
13989 [7., -2., 17., 14.],
13990 [15., -2., 26., 14.],
13991 ],
13992 glyph_advances: vec![8.0, 8.0, 9.0],
13993 ..Default::default()
13994 };
13995
13996 assert_eq!(s.tight_char_bounds.len(), 3);
13997 assert_eq!(s.glyph_advances.len(), 3);
13998 assert_eq!(s.char_bounds.len(), 3);
13999 // Every glyph has advance >= 0.
14000 for &adv in &s.glyph_advances {
14001 assert!(adv >= 0.0, "glyph advance must be non-negative");
14002 }
14003 }
14004
14005 fn styled_span(text: &str, x: f64, y: f64, width: f64, bold: bool) -> TextSpan {
14006 let mut out = span(text, x, y, width);
14007 out.is_bold = bold;
14008 out
14009 }
14010
14011 fn block_of(spans: Vec<TextSpan>) -> Block {
14012 let mut block = Block {
14013 kind: crate::blocks::BlockKind::Paragraph,
14014 page: 0,
14015 bbox: [0.0; 4],
14016 text: String::new(),
14017 spans,
14018 index: 0,
14019 continues_previous: false,
14020 confidence: None,
14021 grid: None,
14022 table_origin: TableOrigin::Other,
14023 trace_learned: None,
14024 };
14025 let lines = lines_of_block(&block);
14026 let texts: Vec<String> = lines.iter().map(|line| line_text(&block, line)).collect();
14027 block.text = crate::blocks::join_lines(&texts);
14028 crate::blocks::recompute_bbox(&mut block);
14029 block
14030 }
14031
14032 #[test]
14033 fn an_inid_field_keeps_its_continuation() {
14034 let upper = block_of(vec![
14035 styled_span("(73)", 72.0, 700.0, 30.0, true),
14036 styled_span(
14037 "Assignee: Schlumberger Technology",
14038 110.0,
14039 700.0,
14040 400.0,
14041 true,
14042 ),
14043 ]);
14044 let lower = block_of(vec![styled_span(
14045 "Corporation, Sugar Land, TX (US)",
14046 110.0,
14047 688.0,
14048 380.0,
14049 false,
14050 )]);
14051 let joined = join_marker_continuations(vec![upper, lower], 0);
14052 assert_eq!(
14053 joined.len(),
14054 1,
14055 "the field was cut off its continuation: {:?}",
14056 joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14057 );
14058 assert!(
14059 joined[0]
14060 .text
14061 .contains("Schlumberger Technology Corporation"),
14062 "the join lost the middle: {:?}",
14063 joined[0].text
14064 );
14065 assert_eq!(joined[0].spans.len(), 3);
14066 }
14067
14068 #[test]
14069 fn a_bulleted_item_keeps_its_hung_line() {
14070 let upper = block_of(vec![styled_span(
14071 "\u{2022} Making investments in research and development and in",
14072 72.0,
14073 700.0,
14074 440.0,
14075 true,
14076 )]);
14077 let lower = block_of(vec![styled_span(
14078 "anticipate future waves of innovation across the industry",
14079 100.0,
14080 688.0,
14081 400.0,
14082 false,
14083 )]);
14084 let joined = join_marker_continuations(vec![upper, lower], 0);
14085 assert_eq!(
14086 joined.len(),
14087 1,
14088 "the item was cut off its hung line: {:?}",
14089 joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14090 );
14091 }
14092
14093 #[test]
14094 fn a_field_label_ending_in_a_colon_stays_apart() {
14095 let upper = block_of(vec![styled_span(
14096 "(56) References cited:",
14097 72.0,
14098 700.0,
14099 440.0,
14100 true,
14101 )]);
14102 let lower = block_of(vec![styled_span(
14103 "A list of patents and publications considered",
14104 100.0,
14105 688.0,
14106 380.0,
14107 false,
14108 )]);
14109 let joined = join_marker_continuations(vec![upper, lower], 3);
14110 assert_eq!(
14111 joined.len(),
14112 2,
14113 "a label ending in a colon was joined to its value: {:?}",
14114 joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14115 );
14116 assert_eq!(joined[0].index, 3);
14117 assert_eq!(joined[1].index, 4);
14118 }
14119
14120 #[test]
14121 fn a_short_marker_line_is_not_joined() {
14122 let upper = block_of(vec![styled_span(
14123 "(12) United States Patent",
14124 72.0,
14125 700.0,
14126 150.0,
14127 true,
14128 )]);
14129 let lower = block_of(vec![styled_span(
14130 "Palaniappan et al.",
14131 100.0,
14132 688.0,
14133 200.0,
14134 false,
14135 )]);
14136 let joined = join_marker_continuations(vec![upper, lower], 0);
14137 assert_eq!(
14138 joined.len(),
14139 2,
14140 "a short marker line was joined to the line under it: {:?}",
14141 joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14142 );
14143 }
14144
14145 #[test]
14146 fn the_next_marker_starts_a_new_block() {
14147 let upper = block_of(vec![styled_span(
14148 "(73) Assignee: Schlumberger Technology",
14149 72.0,
14150 700.0,
14151 440.0,
14152 true,
14153 )]);
14154 // Hung under the upper line on purpose, so that only the marker
14155 // clause keeps the two apart.
14156 let lower = block_of(vec![styled_span(
14157 "(21) Appl. No.: 09/789,473",
14158 100.0,
14159 688.0,
14160 380.0,
14161 false,
14162 )]);
14163 let joined = join_marker_continuations(vec![upper, lower], 0);
14164 assert_eq!(
14165 joined.len(),
14166 2,
14167 "the next marker was joined to the item above it: {:?}",
14168 joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14169 );
14170 }
14171
14172 #[test]
14173 fn a_wrapped_marker_item_keeps_its_hanging_continuation() {
14174 // The marker is on the FIRST line and the wrap on the LAST: three
14175 // lines before the weight cut parts the item from its continuation.
14176 // Read off the last line alone, the item opens with no marker and
14177 // stays cut.
14178 let upper = block_of(vec![
14179 styled_span(
14180 "\u{2022} Making investments in research and",
14181 72.0,
14182 700.0,
14183 440.0,
14184 true,
14185 ),
14186 styled_span(
14187 "development and in new facilities built",
14188 72.0,
14189 688.0,
14190 440.0,
14191 true,
14192 ),
14193 styled_span(
14194 "to meet demand across the coming years to",
14195 72.0,
14196 676.0,
14197 440.0,
14198 true,
14199 ),
14200 ]);
14201 let lower = block_of(vec![styled_span(
14202 "anticipate future waves of innovation across the industry",
14203 100.0,
14204 664.0,
14205 400.0,
14206 false,
14207 )]);
14208 let joined = join_marker_continuations(vec![upper, lower], 0);
14209 assert_eq!(
14210 joined.len(),
14211 1,
14212 "the wrapped item was cut off its hung line: {:?}",
14213 joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14214 );
14215 assert!(
14216 joined[0].text.contains("coming years to anticipate"),
14217 "the join lost the middle: {:?}",
14218 joined[0].text
14219 );
14220 assert_eq!(joined[0].spans.len(), 4);
14221 }
14222
14223 #[test]
14224 fn a_short_heading_line_over_a_full_paragraph_is_not_cut() {
14225 // The short-heading cut is deliberately not shipped with this guard:
14226 // a short line over full lines stays one block through this pass.
14227 let block = block_of(vec![
14228 span("Contingent liabilities", 72.0, 700.0, 150.0),
14229 span(
14230 "Goodwill represents the excess of the consideration transferred",
14231 72.0,
14232 688.0,
14233 440.0,
14234 ),
14235 span(
14236 "over the fair value of the identifiable net assets acquired",
14237 72.0,
14238 676.0,
14239 440.0,
14240 ),
14241 span(
14242 "and is tested for impairment at least once a year.",
14243 72.0,
14244 664.0,
14245 440.0,
14246 ),
14247 ]);
14248 let out = join_marker_continuations(vec![block], 0);
14249 assert_eq!(
14250 out.len(),
14251 1,
14252 "a short heading line was cut off its paragraph: {:?}",
14253 out.iter().map(|b| &b.text).collect::<Vec<_>>()
14254 );
14255 assert_eq!(out[0].spans.len(), 4);
14256 assert_eq!(out[0].index, 0);
14257 }
14258
14259 #[test]
14260 fn a_marker_line_whose_next_line_does_not_hang_stays_apart() {
14261 let upper = block_of(vec![styled_span(
14262 "(73) Assignee: Schlumberger Technology",
14263 72.0,
14264 700.0,
14265 440.0,
14266 true,
14267 )]);
14268 // Flush with the marker line's left edge: a new block, not a hang.
14269 let lower = block_of(vec![styled_span(
14270 "Corporation, Sugar Land, TX (US)",
14271 72.0,
14272 688.0,
14273 380.0,
14274 false,
14275 )]);
14276 let joined = join_marker_continuations(vec![upper, lower], 0);
14277 assert_eq!(
14278 joined.len(),
14279 2,
14280 "a marker line was joined to a line that does not hang under it: {:?}",
14281 joined.iter().map(|b| &b.text).collect::<Vec<_>>()
14282 );
14283 }
14284}